From 6d792f8f31c82084d3831f29a84c12e46e3b97b0 Mon Sep 17 00:00:00 2001 From: klsdjft <24860151+klsdjft@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:51:37 +0800 Subject: [PATCH 1/2] feat: add personal robot registrations to connections hub Rename GUI execution-host labels to Servers and add a personal robot registry using typed RPC. Keep registrations separate from executable nodes and restrict optional health checks to explicit read-only private IPv4 endpoints. Add the Robots tab's hub-head-tab line to both boot goldens and both affected page snapshots by hand to match the new shared header. --- CONTEXT.md | 10 + docs/robot-registration.md | 56 +++ i18n/messages.json | 145 +++--- raven/ops/robots.py | 299 ++++++++++++ raven/rpc/methods/__init__.py | 3 + raven/rpc/methods/robots.py | 83 ++++ raven/rpc/models.py | 69 +++ rpc-schema/openrpc.json | 425 ++++++++++++++++++ tests/test_ops_robots.py | 246 ++++++++++ tests/test_rpc_robots.py | 152 +++++++ tests/test_rpc_schema_match.py | 34 +- ui-tui/src/i18n/messages.generated.ts | 280 ++++++++---- ui-tui/src/rpc/generated.ts | 134 ++++++ ui-web/CONTEXT.md | 9 + .../scripts/__golden__/boot-live-noserver.txt | 1 + ui-web/scripts/__golden__/boot-stub.txt | 1 + ui-web/scripts/fixture-params.mjs | 6 + ui-web/src/app/install.ts | 3 +- ui-web/src/components/HubHead.test.tsx | 8 +- ui-web/src/components/HubHead.tsx | 5 +- .../ConnectionsPage.test.tsx.snap | 1 + .../src/features/extAgents/ExtAgentsPage.tsx | 2 + ui-web/src/features/extAgents/Robots.test.tsx | 129 ++++++ ui-web/src/features/extAgents/Robots.tsx | 191 ++++++++ .../__snapshots__/ExtAgentsPage.test.tsx.snap | 1 + ui-web/src/features/extAgents/manifest.ts | 4 +- ui-web/src/features/extAgents/source.ts | 10 +- ui-web/src/features/extAgents/store.ts | 13 +- ui-web/src/features/extAgents/styles.css | 35 ++ ui-web/src/features/extAgents/types.ts | 13 + ui-web/src/rpc/fixtures/index.ts | 2 + ui-web/src/rpc/fixtures/robots.ts | 52 +++ ui-web/src/rpc/generated.ts | 94 +++- ui-web/src/state/hub.ts | 10 +- ui-web/src/state/sources.ts | 3 +- 35 files changed, 2373 insertions(+), 156 deletions(-) create mode 100644 docs/robot-registration.md create mode 100644 raven/ops/robots.py create mode 100644 raven/rpc/methods/robots.py create mode 100644 tests/test_ops_robots.py create mode 100644 tests/test_rpc_robots.py create mode 100644 ui-web/src/features/extAgents/Robots.test.tsx create mode 100644 ui-web/src/features/extAgents/Robots.tsx create mode 100644 ui-web/src/rpc/fixtures/robots.ts diff --git a/CONTEXT.md b/CONTEXT.md index 2fd0a8db6..5efdfd723 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,3 +1,13 @@ +# Personal robot registration + +A non-executable record in the profile-scoped store implemented by +`raven.ops.robots.RobotStore`, exposed through `robots.*` RPC methods. It is +separate from the machine registry and its SSH/Raven nodes. Only confirmed +personal records are supported; optional status checks are explicit, bounded +GET requests and expire after 60 seconds. Registration or a reachable status +endpoint is not acceptance of physical robot capabilities. See +`docs/robot-registration.md` for the storage and network boundaries. + # Raven Runtime The Python agent runtime: receives messages from chat channels, runs the agent loop diff --git a/docs/robot-registration.md b/docs/robot-registration.md new file mode 100644 index 000000000..c19d581c2 --- /dev/null +++ b/docs/robot-registration.md @@ -0,0 +1,56 @@ +# Personal robot registrations + +The Connections hub separates **Servers**, where Raven may run an agent through +its existing SSH/node support, from **Robots**, which are device records without +an execution node. Existing machine IDs, configuration, SSH credentials, CLI +commands, and `machines.*` methods are unchanged; only their displayed label is +now Servers. + +The Robots tab supports adding, searching, filtering, editing, moving records to +trash, and restoring them. Registration does not establish a connection. +Company devices and credentials are out of scope: the form blocks company +registration, and the runtime accepts only explicitly confirmed personal records. +Use independently governed instances for other security domains rather than +mixing their data into the personal registry. + +## Optional read-only status + +SparkBot and generic HTTP devices can have a status URL. Robot dogs, AGVs, mobile +manipulators, and other device types support metadata only. No robot is treated +as a server and no executable node is installed. + +Only **Check status** contacts a device. It sends one GET, without credentials, +proxies, or redirects, to a literal RFC1918 IPv4 address whose path is `/health` +or `/status`. Ports are limited to 80, 443, or 1024-65535. DNS names, public, +loopback, and link-local addresses, credentials, query parameters, and fragments +are rejected. The timeout is three seconds and the uncompressed JSON response limit is 16 KiB. +Compressed responses and non-standard numeric constants are rejected. +An optional expected `project` value prevents accepting a different device. +Only a bounded allowlist of health fields is retained. + +The states are unknown, not connected, error, and status endpoint reachable. +`ok: true` confirms the status endpoint only: it does not establish chassis, +audio, or motion acceptance. Results expire after 60 seconds, including when a +page remains open. Failed and malformed responses are never shown as online. +There is no scanning or background device polling and no control, shell, ROS, +MQTT, motion, or media endpoint. + +## Persistence and compatibility + +The existing authenticated RPC transport serves `robots.list`, `robots.save`, +`robots.remove`, `robots.restore`, and `robots.check`. Web clients use the same +typed transport and source seam as the other hub tabs. An older gateway without +these methods produces an unavailable message rather than invented records. +Offline fixtures never contact a real device or claim it is connected. + +Records live under `robots//personal/devices.sqlite3` beside the +resolved configuration file. Separate configuration files have separate stores; +the default is therefore under the instance's Raven home. The directory is +owner-only and the database is owner-readable/writable on POSIX. Records are +limited to 100, including trash. Removal is recoverable; restoring or editing a +record clears its old health observation. Revisions prevent a stale editor or +an overlapping health request from overwriting a later edit or removal. + +The feature does not modify the server registry or model/provider configuration. +Reverting the code leaves the separate database intact and stops exposing the +tab and RPC methods. Reapplying the feature reopens those registrations. diff --git a/i18n/messages.json b/i18n/messages.json index f3547095e..68e656e26 100644 --- a/i18n/messages.json +++ b/i18n/messages.json @@ -3411,10 +3411,7 @@ "en": "Channels", "zh": "渠道" }, - "gui.hub.machines": { - "en": "Machines", - "zh": "机器" - }, + "gui.hub.machines": {"en": "Servers", "zh": "服务器"}, "gui.nav.conn": { "en": "Channels", "zh": "入口" @@ -3643,10 +3640,7 @@ "en": "Raven joins the chat apps you already use, so you can hand it work and get results back from WeChat, Feishu, Telegram and more.", "zh": "Raven 可以接入你常用的聊天应用,在微信、飞书、Telegram 里也能直接给它派活、收结果" }, - "gui.page.machines_sub": { - "en": "The machines Raven can hand work to besides this computer: register one, put the node on it, then pick it when an agent is connected.", - "zh": "Raven 除本机之外还能把活派到这些机器上:先登记一台、装上节点,接入智能体时就能选它" - }, + "gui.page.machines_sub": {"en": "The servers Raven can hand work to besides this computer: register one, put the node on it, then pick it when an agent is connected.", "zh": "Raven 除本机之外还能把活派到这些服务器上:先登记一台、装上节点,接入智能体时就能选它"}, "gui.page.cron": { "en": "Schedules", "zh": "定时" @@ -5287,9 +5281,9 @@ "en": "The preset for {name} now reaches it over {to}. The entry is removed and added back from that preset, so handles from runs already in flight stop resolving.", "zh": "{name} 的预设改成走 {to} 了 · 会先移除再按新预设装回来,已经在跑的会话句柄会失效" }, - "gui.agent.on_machine": {"en": "On the registered machine {id}", "zh": "在登记机器 {id} 上"}, - "gui.machine.register": {"en": "Register a machine", "zh": "登记机器"}, - "gui.machine.none": {"en": "No machine is registered yet. Register one, then install the node on it.", "zh": "还没有登记机器。先登记一台,再在它上面安装节点。"}, + "gui.agent.on_machine": {"en": "On the registered server {id}", "zh": "在登记服务器 {id} 上"}, + "gui.machine.register": {"en": "Register a server", "zh": "登记服务器"}, + "gui.machine.none": {"en": "No server is registered yet. Register one, then install the node on it.", "zh": "还没有登记服务器。先登记一台,再在它上面安装节点。"}, "gui.machine.state_connected": {"en": "{n} agents running", "zh": "{n} 个 Agent 在跑"}, "gui.machine.state_idle": {"en": "Link up, no agent running", "zh": "链路在,没有 Agent 在跑"}, "gui.machine.state_lost": {"en": "Link down; agents wait", "zh": "链路断了,Agent 在等"}, @@ -5324,69 +5318,69 @@ "gui.machine.f_key": {"en": "Private key path", "zh": "私钥路径"}, "gui.machine.key_note": {"en": "Only the path is stored. The key never leaves this computer.", "zh": "只记录路径,私钥不离开这台电脑。"}, "gui.machine.f_password": {"en": "Password", "zh": "密码"}, - "gui.machine.password_note": {"en": "The password is kept on this computer, readable by your account alone, and handed to ssh on every connection: never on a command line, never in a log. The first connection records the machine's host key and then sends the password, so on a network you do not trust, ssh once from a terminal and check the fingerprint before registering. When the machine's password changes, remove the machine and register it again.", "zh": "密码保存在这台电脑上,只有你的账号能读;每次连接时读出来交给 ssh,不进命令行、不进日志。第一次连接会先记下机器的主机密钥再送出密码,所以在不信任的网络上,先在终端里 ssh 一次核对指纹,再登记。机器改了密码,先移除这台机器再重新登记。"}, - "gui.machine.auth_note": {"en": "No ssh alias is written for it; an agent reads and writes its files through Raven's own file tools. When its password changes, remove the machine and register it again.", "zh": "没有写 ssh 别名;Agent 读写它上面的文件走 Raven 的文件工具。机器改了密码,先移除这台机器再重新登记。"}, - "gui.machine.f_paths": {"en": "Work directories on the machine, one per line (optional)", "zh": "机器上的工作目录,每行一个(可不填)"}, - "gui.machine.add_hint": {"en": "An ssh login, with a key or with a password. The machine's host key is recorded at the first contact and checked after.", "zh": "用密钥或密码 ssh 登录。第一次连接时记下它的主机密钥,之后每次核对。"}, + "gui.machine.password_note": {"en": "The password is kept on this computer, readable by your account alone, and handed to ssh on every connection: never on a command line, never in a log. The first connection records the server's host key and then sends the password, so on a network you do not trust, ssh once from a terminal and check the fingerprint before registering. When the server's password changes, remove the server and register it again.", "zh": "密码保存在这台电脑上,只有你的账号能读;每次连接时读出来交给 ssh,不进命令行、不进日志。第一次连接会先记下服务器的主机密钥再送出密码,所以在不信任的网络上,先在终端里 ssh 一次核对指纹,再登记。服务器改了密码,先移除这台服务器再重新登记。"}, + "gui.machine.auth_note": {"en": "No ssh alias is written for it; an agent reads and writes its files through Raven's own file tools. When its password changes, remove the server and register it again.", "zh": "没有写 ssh 别名;Agent 读写它上面的文件走 Raven 的文件工具。服务器改了密码,先移除这台服务器再重新登记。"}, + "gui.machine.f_paths": {"en": "Work directories on the server, one per line (optional)", "zh": "服务器上的工作目录,每行一个(可不填)"}, + "gui.machine.add_hint": {"en": "An ssh login, with a key or with a password. The server's host key is recorded at the first contact and checked after.", "zh": "用密钥或密码 ssh 登录。第一次连接时记下它的主机密钥,之后每次核对。"}, "gui.machine.add_do": {"en": "Reach it once and register", "zh": "连一次并登记"}, - "gui.machine.adding": {"en": "Reaching the machine", "zh": "正在连接机器"}, + "gui.machine.adding": {"en": "Reaching the server", "zh": "正在连接服务器"}, "gui.machine.add_bad": {"en": "Not registered", "zh": "没有登记"}, "gui.machine.where": {"en": "Runs on", "zh": "运行在"}, "gui.machine.where_here": {"en": "This computer", "zh": "本机"}, - "gui.machine.remote_cwd": {"en": "Working directory on the machine", "zh": "机器上的工作目录"}, - "gui.machine.where_none": {"en": "No machine is registered; see the Machines tab.", "zh": "还没有登记机器,见“机器”标签。"}, + "gui.machine.remote_cwd": {"en": "Working directory on the server", "zh": "服务器上的工作目录"}, + "gui.machine.where_none": {"en": "No server is registered; see the Servers tab.", "zh": "还没有登记服务器,见“服务器”标签。"}, "gui.machine.needs_node": {"en": "Install the node first", "zh": "先安装节点"}, "gui.machine.needs_update": {"en": "Update the node first", "zh": "先更新节点"}, "gui.machine.node_stale": {"en": "Node {v}, needs updating", "zh": "节点 {v},需要更新"}, "gui.machine.node_here": {"en": "This computer", "zh": "本机"}, "gui.machine.update": {"en": "Update node", "zh": "更新节点"}, "gui.machine.f_node_dir": {"en": "Install into (optional)", "zh": "安装到(可不填)"}, - "gui.machine.node_dir_hint": {"en": "Where the node lives on the machine: ~/.raven-node unless its home disk has no room.", "zh": "节点在机器上的存放目录:默认 ~/.raven-node,只有主目录所在磁盘放不下时才需要改。"}, + "gui.machine.node_dir_hint": {"en": "Where the node lives on the server: ~/.raven-node unless its home disk has no room.", "zh": "节点在服务器上的存放目录:默认 ~/.raven-node,只有主目录所在磁盘放不下时才需要改。"}, "gui.machine.install_bad_hint": {"en": "Fix what the line says, then press Install node again: the next try clears what this one left of the node first.", "zh": "按这一行的提示处理后,再点一次「安装节点」:下一次会先清掉这次没装完的节点。"}, - "gui.machine.remove_do": {"en": "Remove machine", "zh": "移除机器"}, - "gui.machine.remove_body": {"en": "{name} ({id}) is taken out of the registry and its ssh alias goes with it. Agent entries that run there have to be disconnected first. The node installed on the machine stays there.", "zh": "{name}({id})会从登记表里去掉,ssh 别名一起去掉;在它上面跑的 Agent 条目要先断开。装在机器上的节点会留在那里。"}, - "gui.machine.remove_body_password": {"en": "{name} ({id}) is taken out of the registry and the password kept here for it is deleted. Agent entries that run there have to be disconnected first. The node installed on the machine stays there.", "zh": "{name}({id})会从登记表里去掉,为它保存的密码一起删掉;在它上面跑的 Agent 条目要先断开。装在机器上的节点会留在那里。"}, + "gui.machine.remove_do": {"en": "Remove server", "zh": "移除服务器"}, + "gui.machine.remove_body": {"en": "{name} ({id}) is taken out of the registry and its ssh alias goes with it. Agent entries that run there have to be disconnected first. The node installed on the server stays there.", "zh": "{name}({id})会从登记表里去掉,ssh 别名一起去掉;在它上面跑的 Agent 条目要先断开。装在服务器上的节点会留在那里。"}, + "gui.machine.remove_body_password": {"en": "{name} ({id}) is taken out of the registry and the password kept here for it is deleted. Agent entries that run there have to be disconnected first. The node installed on the server stays there.", "zh": "{name}({id})会从登记表里去掉,为它保存的密码一起删掉;在它上面跑的 Agent 条目要先断开。装在服务器上的节点会留在那里。"}, "gui.machine.removing": {"en": "Removing", "zh": "正在移除"}, "gui.machine.removed": {"en": "{name} was removed", "zh": "已移除 {name}"}, - "gui.machine.load_bad": {"en": "The machine list could not be read", "zh": "机器列表读不出来"}, + "gui.machine.load_bad": {"en": "The server list could not be read", "zh": "服务器列表读不出来"}, "gui.machine.file_problems": {"en": "The registry file", "zh": "登记表文件"}, - "gui.machine.why_unreachable": {"en": "The machine could not be reached: check the address and port, and that the machine is on and reachable from this computer.", "zh": "连不上这台机器:检查地址和端口,确认机器开着、从这台电脑能连到它。"}, - "gui.machine.why_login": {"en": "The machine refused the login. With a key: check the user name and the private key path, and that the key's public half is on the machine. With a password: check it, and that the machine accepts password logins at all.", "zh": "机器拒绝了登录。用密钥时:检查用户名和私钥路径,确认对应的公钥已经放到机器上。用密码时:检查密码,并确认这台机器允许密码登录。"}, - "gui.machine.why_prompt": {"en": "The machine asked something the kept password does not answer -- a one-time code, another account's password, or a password prompt that names a host other than the registered one -- so the password was not sent: see the line below.", "zh": "机器问的不是这个账号自己的密码提示(比如一次性验证码、别的账号的密码,或者提示里的主机不是登记的那台),所以没有发送密码:详见下一行。"}, - "gui.machine.why_hostkey": {"en": "The machine's host key differs from the one on record: check the machine before trusting it again, then take the old line out of ~/.ssh/known_hosts.", "zh": "机器的主机密钥和记录的不一样:先确认机器没问题,再把 ~/.ssh/known_hosts 里的旧记录删掉。"}, - "gui.machine.why_timeout": {"en": "The machine did not answer in time: check that it is on and that the port is open.", "zh": "机器没有按时应答:确认机器开着、端口放行。"}, + "gui.machine.why_unreachable": {"en": "The server could not be reached: check the address and port, and that the server is on and reachable from this computer.", "zh": "连不上这台服务器:检查地址和端口,确认服务器开着、从这台电脑能连到它。"}, + "gui.machine.why_login": {"en": "The server refused the login. With a key: check the user name and the private key path, and that the key's public half is on the server. With a password: check it, and that the server accepts password logins at all.", "zh": "服务器拒绝了登录。用密钥时:检查用户名和私钥路径,确认对应的公钥已经放到服务器上。用密码时:检查密码,并确认这台服务器允许密码登录。"}, + "gui.machine.why_prompt": {"en": "The server asked something the kept password does not answer -- a one-time code, another account's password, or a password prompt that names a host other than the registered one -- so the password was not sent: see the line below.", "zh": "服务器问的不是这个账号自己的密码提示(比如一次性验证码、别的账号的密码,或者提示里的主机不是登记的那台),所以没有发送密码:详见下一行。"}, + "gui.machine.why_hostkey": {"en": "The server's host key differs from the one on record: check the server before trusting it again, then take the old line out of ~/.ssh/known_hosts.", "zh": "服务器的主机密钥和记录的不一样:先确认服务器没问题,再把 ~/.ssh/known_hosts 里的旧记录删掉。"}, + "gui.machine.why_timeout": {"en": "The server did not answer in time: check that it is on and that the port is open.", "zh": "服务器没有按时应答:确认服务器开着、端口放行。"}, "gui.machine.why_nossh": {"en": "ssh is not installed on this computer: install OpenSSH, then try again.", "zh": "这台电脑没有 ssh:装好 OpenSSH 再试。"}, - "gui.machine.why_platform": {"en": "This computer is Windows, where registering with a password is not available: pick Key, put the key's public half on the machine, and give the private key's path here.", "zh": "这台电脑是 Windows,不能用密码登记:改选「密钥」,把公钥放到机器上,再在这里填私钥路径。"}, - "gui.machine.why_duplicate": {"en": "That id is taken: pick another, or remove the machine listed under it first.", "zh": "这个 id 已经有机器在用:换一个,或者先移除原来那台。"}, + "gui.machine.why_platform": {"en": "This computer is Windows, where registering with a password is not available: pick Key, put the key's public half on the server, and give the private key's path here.", "zh": "这台电脑是 Windows,不能用密码登记:改选「密钥」,把公钥放到服务器上,再在这里填私钥路径。"}, + "gui.machine.why_duplicate": {"en": "That id is taken: pick another, or remove the server listed under it first.", "zh": "这个 id 已经有服务器在用:换一个,或者先移除原来那台。"}, "gui.machine.why_invalid": {"en": "A field is missing or cannot be used: see the line below.", "zh": "有字段缺失或不能用:见下一行。"}, "gui.machine.why_registry": {"en": "The registry file cannot be changed: fix it first, as the line below says.", "zh": "登记表文件改不了:先按下一行的提示处理。"}, "gui.machine.install_bad_said": {"en": "The install did not finish: {said}", "zh": "安装没有完成:{said}"}, - "gui.machine.remove_named": {"en": "Not removed: {agents} still run on this machine; disconnect them first, in the Connected tab.", "zh": "没有移除:{agents} 还在用这台机器,先到「已接入」里断开它们。"}, - "gui.machine.remove_running": {"en": "Not removed: agents are running on this machine; wait for them to finish, or stop them first. {said}", "zh": "没有移除:还有 Agent 正在这台机器上跑,等它们跑完或先停掉。{said}"}, + "gui.machine.remove_named": {"en": "Not removed: {agents} still run on this server; disconnect them first, in the Connected tab.", "zh": "没有移除:{agents} 还在用这台服务器,先到「已接入」里断开它们。"}, + "gui.machine.remove_running": {"en": "Not removed: agents are running on this server; wait for them to finish, or stop them first. {said}", "zh": "没有移除:还有 Agent 正在这台服务器上跑,等它们跑完或先停掉。{said}"}, "gui.machine.remove_bad_said": {"en": "Not removed: {said}", "zh": "没有移除:{said}"}, - "gui.machine.err_no_node": {"en": "No Raven node on it yet: press Install node. {said}", "zh": "机器上还没有节点,点「安装节点」。{said}"}, + "gui.machine.err_no_node": {"en": "No Raven node on it yet: press Install node. {said}", "zh": "服务器上还没有节点,点「安装节点」。{said}"}, "gui.machine.err_stale_node": {"en": "Its node is another Raven's: press Update node. {said}", "zh": "节点是另一个版本的 Raven 装的,点「更新节点」。{said}"}, - "gui.machine.agents_here": {"en": "Agents on this machine", "zh": "这台机器上的 Agent"}, + "gui.machine.agents_here": {"en": "Agents on this server", "zh": "这台服务器上的 Agent"}, "gui.machine.agents_here_none": {"en": "None yet", "zh": "还没有"}, - "gui.machine.agents_line": {"en": "{n} on this machine: {names}", "zh": "这台机器上有 {n} 个 Agent:{names}"}, + "gui.machine.agents_line": {"en": "{n} on this server: {names}", "zh": "这台服务器上有 {n} 个 Agent:{names}"}, "gui.machine.agents_line_none": {"en": "No agent connected here yet", "zh": "还没有 Agent 接到这里"}, "gui.machine.next_title": {"en": "Next", "zh": "下一步"}, - "gui.machine.next_body": {"en": "Connect an agent that runs on this machine. The agent itself has to be installed and signed in there; Raven's node only starts it.", "zh": "接一个跑在这台机器上的 Agent。Agent 本身要在那台机器上装好并登录,Raven 的节点只负责启动它。"}, - "gui.machine.connect_here": {"en": "Connect an agent on this machine", "zh": "在这台机器上接入 Agent"}, + "gui.machine.next_body": {"en": "Connect an agent that runs on this server. The agent itself has to be installed and signed in there; Raven's node only starts it.", "zh": "接一个跑在这台服务器上的 Agent。Agent 本身要在那台服务器上装好并登录,Raven 的节点只负责启动它。"}, + "gui.machine.connect_here": {"en": "Connect an agent on this server", "zh": "在这台服务器上接入 Agent"}, "gui.machine.connect_title": {"en": "Connect an agent on {name}", "zh": "在 {name} 上接入 Agent"}, - "gui.machine.connect_hint": {"en": "It is started there once first, as proof, and written only when it answers.", "zh": "先在那台机器上真跑一句作证明,答了才写入。"}, + "gui.machine.connect_hint": {"en": "It is started there once first, as proof, and written only when it answers.", "zh": "先在那台服务器上真跑一句作证明,答了才写入。"}, "gui.machine.f_preset": {"en": "Agent", "zh": "Agent"}, - "gui.machine.preset_connected_here": {"en": "{name} (connected on this computer; this adds one on the machine)", "zh": "{name}(本机已接入,再接一份到机器)"}, - "gui.machine.preset_on_machine": {"en": "{name} (already connected on this machine)", "zh": "{name}(已接在这台机器上)"}, - "gui.machine.preset_off_entry": {"en": "{name} (connected on this machine as {entry}, switched off)", "zh": "{name}(已接在这台机器上,已断开:{entry})"}, + "gui.machine.preset_connected_here": {"en": "{name} (connected on this computer; this adds one on the server)", "zh": "{name}(本机已接入,再接一份到服务器)"}, + "gui.machine.preset_on_machine": {"en": "{name} (already connected on this server)", "zh": "{name}(已接在这台服务器上)"}, + "gui.machine.preset_off_entry": {"en": "{name} (connected on this server as {entry}, switched off)", "zh": "{name}(已接在这台服务器上,已断开:{entry})"}, "gui.machine.off_suffix": {"en": "{name} (switched off)", "zh": "{name}(已断开)"}, "gui.machine.reconnect_do": {"en": "Reconnect", "zh": "重新接入"}, - "gui.machine.reconnect_hint": {"en": "The entry is switched back on: it is started there once first, as proof.", "zh": "把这条条目重新启用:先在那台机器上真跑一句作证明。"}, + "gui.machine.reconnect_hint": {"en": "The entry is switched back on: it is started there once first, as proof.", "zh": "把这条条目重新启用:先在那台服务器上真跑一句作证明。"}, "gui.machine.reconnected_said": {"en": "{name} is connected again; it is on the Agents page.", "zh": "已重新接入 {name},到「智能体」页可以看到。"}, - "gui.machine.preset_connecting": {"en": "{name} (being connected on this machine right now)", "zh": "{name}(正在接到这台机器上)"}, - "gui.machine.connecting": {"en": "Starting it on the machine", "zh": "正在机器上启动它"}, - "gui.machine.connecting_for": {"en": "Starting it on the machine, {s}s so far", "zh": "正在机器上启动它,已等 {s} 秒"}, - "gui.machine.connecting_hint": {"en": "The node on the machine starts it, then it has to answer one test message: a few seconds to a minute as a rule, ten minutes at most. Cancel asks it to stop, and this form says whether anything was written.", "zh": "机器上的节点先启动它,再等它答一句测试消息:一般几秒到一分钟,最多等 10 分钟。点「取消」会请求停掉这一跑,这里会说有没有写入。"}, + "gui.machine.preset_connecting": {"en": "{name} (being connected on this server right now)", "zh": "{name}(正在接到这台服务器上)"}, + "gui.machine.connecting": {"en": "Starting it on the server", "zh": "正在服务器上启动它"}, + "gui.machine.connecting_for": {"en": "Starting it on the server, {s}s so far", "zh": "正在服务器上启动它,已等 {s} 秒"}, + "gui.machine.connecting_hint": {"en": "The node on the server starts it, then it has to answer one test message: a few seconds to a minute as a rule, ten minutes at most. Cancel asks it to stop, and this form says whether anything was written.", "zh": "服务器上的节点先启动它,再等它答一句测试消息:一般几秒到一分钟,最多等 10 分钟。点「取消」会请求停掉这一跑,这里会说有没有写入。"}, "gui.machine.cancelling": {"en": "Stopping", "zh": "正在停止"}, "gui.machine.connect_cancelled_head": {"en": "Cancelled", "zh": "已取消"}, "gui.machine.connect_cancelled": {"en": "Cancelled; nothing was written. It can be connected again.", "zh": "已取消,没有写入。可以再接一次。"}, @@ -5402,12 +5396,12 @@ "gui.machine.connect_bad": {"en": "Not connected", "zh": "没有接入"}, "gui.machine.connect_bad_named": {"en": "Not connected: {name}", "zh": "没有接入 {name}"}, "gui.machine.connect_do": {"en": "Connect", "zh": "接入"}, - "gui.machine.presets_none": {"en": "Nothing to connect: every agent that can run on a machine is already connected on this one.", "zh": "没有可接的:能跑在机器上的 Agent 都已经接在这台机器上了。"}, + "gui.machine.presets_none": {"en": "Nothing to connect: every agent that can run on a server is already connected on this one.", "zh": "没有可接的:能跑在服务器上的 Agent 都已经接在这台服务器上了。"}, "gui.machine.presets_loading": {"en": "Reading the agents", "zh": "正在读取 Agent 列表"}, - "gui.machine.presets_pending": {"en": "Something is still being connected on this machine; the choices come back when it ends.", "zh": "这台机器上还有接入在进行,结束后再选。"}, + "gui.machine.presets_pending": {"en": "Something is still being connected on this server; the choices come back when it ends.", "zh": "这台服务器上还有接入在进行,结束后再选。"}, "gui.machine.keys_found": {"en": "Found on this computer and filled in; pick another or type a path.", "zh": "已填入这台电脑上找到的密钥,也可以换一把或自己填路径。"}, "gui.machine.keys_none": {"en": "No ssh key was found on this computer. Type a path, or pick Password: Raven then keeps the password on this computer and logs in with it.", "zh": "这台电脑上没找到 ssh 密钥。可以自己填路径,或改选「密码」:Raven 会把密码保存在本机,每次用它登录。"}, - "gui.machine.missing_but_machine": {"en": "Not installed on this computer, but it can run on a registered machine where it is installed and signed in.", "zh": "本机没装也可以跑在一台登记机器上,那台机器上要装好并登录它。"}, + "gui.machine.missing_but_machine": {"en": "Not installed on this computer, but it can run on a registered server where it is installed and signed in.", "zh": "本机没装也可以跑在一台登记服务器上,那台服务器上要装好并登录它。"}, "gui.machine.connect_on": {"en": "Connect on {name}", "zh": "接入到 {name}"}, "gui.agent.tag_stale": { "en": "Moved", @@ -13160,6 +13154,57 @@ "gui.export.stop_report_label": { "en": "Discard", "zh": "放弃" - } + }, + "gui.extAgents.robot_title": {"en": "Personal robots", "zh": "个人机器人"}, + "gui.extAgents.robot_add": {"en": "Register robot", "zh": "登记机器人"}, + "gui.extAgents.robot_edit": {"en": "Edit registration", "zh": "编辑登记"}, + "gui.extAgents.robot_name": {"en": "Device name", "zh": "设备名称"}, + "gui.extAgents.robot_type": {"en": "Device type", "zh": "设备类型"}, + "gui.extAgents.robot_model": {"en": "Model", "zh": "型号"}, + "gui.extAgents.robot_location": {"en": "Location", "zh": "位置"}, + "gui.extAgents.robot_note": {"en": "Note", "zh": "备注"}, + "gui.extAgents.robot_mode": {"en": "Connection mode", "zh": "连接方式"}, + "gui.extAgents.robot_metadata": {"en": "Metadata only", "zh": "仅登记信息"}, + "gui.extAgents.robot_http": {"en": "Read-only HTTP status", "zh": "HTTP 只读状态"}, + "gui.extAgents.robot_status_url": {"en": "Status URL", "zh": "状态地址"}, + "gui.extAgents.robot_identity": {"en": "Expected project identity (optional)", "zh": "预期设备标识(可选)"}, + "gui.extAgents.robot_url_hint": {"en": "Private IPv4 /health or /status only; no credentials, query parameters, or redirects.", "zh": "仅支持局域网 IPv4 的 /health 或 /status;不接受凭据、查询参数或重定向。"}, + "gui.extAgents.robot_domain": {"en": "Security domain", "zh": "安全域"}, + "gui.extAgents.robot_personal": {"en": "Personal devices", "zh": "个人设备"}, + "gui.extAgents.robot_company": {"en": "Company devices (unsupported here)", "zh": "公司设备(此实例不接收)"}, + "gui.extAgents.robot_company_blocked": {"en": "Company devices require a separate governed instance and are not supported by this personal registry.", "zh": "公司设备需要独立的受管实例,此个人登记库不接收公司设备。"}, + "gui.extAgents.robot_boundary": {"en": "Personal devices only. Do not enter company addresses, accounts, or credentials. Registration does not establish a connection.", "zh": "只登记个人设备,请勿填写公司地址、账号或凭据。登记不等于已连接。"}, + "gui.extAgents.robot_personal_confirm": {"en": "I confirm this is a personal device and contains no company data or credentials.", "zh": "我确认这是个人设备,登记信息不含公司数据或凭据。"}, + "gui.extAgents.robot_readonly_confirm": {"en": "I confirm this endpoint only reports status and does not trigger control or motion.", "zh": "我确认此地址只提供状态,不会触发控制或动作。"}, + "gui.extAgents.robot_metadata_only": {"en": "This device type supports metadata only; no connection or control is attempted.", "zh": "此设备类型仅支持信息登记,不建立连接或执行控制。"}, + "gui.extAgents.robot_safety": {"en": "Check status sends one read-only GET on demand. Results expire after 60 seconds. A reachable endpoint does not verify chassis, audio, or motion. No executable node is installed.", "zh": "仅在点击检查时发送一次只读 GET,结果 60 秒后过期。接口可达不代表底盘、音频或动作能力已验收;不安装执行节点。"}, + "gui.extAgents.robot_search": {"en": "Search devices", "zh": "搜索设备"}, + "gui.extAgents.robot_filter": {"en": "Filter device types", "zh": "筛选设备类型"}, + "gui.extAgents.robot_all_types": {"en": "All types", "zh": "全部类型"}, + "gui.extAgents.robot_trash": {"en": "Trash", "zh": "回收站"}, + "gui.extAgents.robot_back": {"en": "Back to devices", "zh": "返回设备"}, + "gui.extAgents.robot_restore": {"en": "Restore", "zh": "恢复登记"}, + "gui.extAgents.robot_remove": {"en": "Move to trash", "zh": "移入回收站"}, + "gui.extAgents.robot_check": {"en": "Check status", "zh": "检查状态"}, + "gui.extAgents.robot_checking": {"en": "Checking...", "zh": "检查中…"}, + "gui.extAgents.robot_checked": {"en": "Last checked", "zh": "最近检查"}, + "gui.extAgents.robot_loading": {"en": "Loading registrations...", "zh": "正在读取登记…"}, + "gui.extAgents.robot_empty": {"en": "No matching registrations.", "zh": "没有匹配的登记。"}, + "gui.extAgents.robot_save_failed": {"en": "Could not save. Check the fields, or refresh if another client edited this record.", "zh": "保存失败。请检查字段;如其他页面已修改此记录,请刷新后重试。"}, + "gui.extAgents.robot_operation_failed": {"en": "The operation failed. The device has not been assumed online.", "zh": "操作未完成,未将设备认定为在线。"}, + "gui.extAgents.robot_unavailable": {"en": "Robot registration is unavailable on this gateway. Existing registrations have not been changed.", "zh": "此网关的机器人登记暂不可用,原记录未被修改。"}, + "gui.extAgents.robot_unverified": {"en": "No verified connection. Registration alone does not establish availability.", "zh": "连接尚未验证,登记不代表设备可用。"}, + "gui.extAgents.robot_reachable": {"en": "Status endpoint reachable", "zh": "状态接口可达"}, + "gui.extAgents.robot_disconnected": {"en": "Not connected", "zh": "未连接"}, + "gui.extAgents.robot_unknown": {"en": "Unknown", "zh": "未知"}, + "gui.extAgents.robot_error": {"en": "Error", "zh": "错误"}, + "gui.extAgents.robot_sparkbot": {"en": "SparkBot", "zh": "SparkBot"}, + "gui.extAgents.robot_http_device": {"en": "HTTP device", "zh": "HTTP 设备"}, + "gui.extAgents.robot_dog": {"en": "Robot dog", "zh": "机器狗"}, + "gui.extAgents.robot_agv": {"en": "AGV", "zh": "AGV"}, + "gui.extAgents.robot_mobile_manipulator": {"en": "Mobile manipulator", "zh": "轮臂"}, + "gui.extAgents.robot_other": {"en": "Other device", "zh": "其他设备"}, + "gui.hub.robots": {"en": "Robots", "zh": "机器人"}, + "gui.page.robots_sub": {"en": "Register personal devices and read status; no executable nodes or robot control.", "zh": "登记个人设备、读取状态;不安装执行节点,不提供机器人控制"} } } diff --git a/raven/ops/robots.py b/raven/ops/robots.py new file mode 100644 index 000000000..1a701d115 --- /dev/null +++ b/raven/ops/robots.py @@ -0,0 +1,299 @@ +"""Profile-scoped personal robot records and bounded, explicit read-only health checks.""" + +from __future__ import annotations + +import asyncio +import ipaddress +import json +import math +import os +import sqlite3 +import time +import uuid +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import urlsplit + +from aiohttp import ClientError, ClientSession, ClientTimeout + +TYPES = {"sparkbot", "http_device", "robot_dog", "agv", "mobile_manipulator", "other"} +FIELDS = { + "name", + "type", + "model", + "location", + "note", + "connection_mode", + "status_url", + "expected_project", + "domain", + "confirm_personal", + "revision", +} +OBSERVED = {"ok", "project", "version", "uptime_seconds", "wifi_rssi", "chassis_online"} +TTL = 60 +MAX_RESPONSE = 16384 + + +class RegistryError(ValueError): + status = 400 + + +class ConflictError(RegistryError): + status = 409 + + +class MissingError(RegistryError): + status = 404 + + +def now(): + return datetime.now(timezone.utc).isoformat() + + +def clean_text(value, maximum, *, required=False): + if not isinstance(value, str): + raise RegistryError("Fields must be text") + value = value.strip() + if (required and not value) or len(value) > maximum or any(ord(c) < 32 and c != "\n" for c in value): + raise RegistryError("A field is empty, too long, or contains unsupported characters") + return value + + +def validate_url(value, *, test_loopback=False): + """Literal RFC1918 IPv4 only: no DNS, proxies, redirects, queries or credentials.""" + try: + parsed = urlsplit(value) + address = ipaddress.ip_address(parsed.hostname or "") + private = any(address in ipaddress.ip_network(n) for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")) + valid_address = address.version == 4 and (private or (test_loopback and address.is_loopback)) + port = parsed.port if parsed.port is not None else (443 if parsed.scheme == "https" else 80) + if ( + not valid_address + or parsed.scheme not in {"http", "https"} + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + or parsed.path not in {"/health", "/status"} + or not (port in {80, 443} or 1024 <= port <= 65535) + or any(ord(c) <= 32 or ord(c) == 127 for c in value) + ): + raise ValueError() + except (ValueError, TypeError): + raise RegistryError( + "Use a private IPv4 /health or /status URL without credentials, query parameters, or fragments" + ) from None + return value + + +def validate(payload): + if not isinstance(payload, dict) or set(payload) - FIELDS: + raise RegistryError("Unsupported fields; credentials and control interfaces cannot be registered") + if payload.get("domain") != "personal" or payload.get("confirm_personal") is not True: + raise RegistryError("Only confirmed personal devices are supported in this registry") + kind = payload.get("type") + mode = payload.get("connection_mode", "metadata") + if kind not in TYPES or mode not in {"metadata", "http_status"}: + raise RegistryError("Unsupported device type or connection mode") + if kind not in {"sparkbot", "http_device"} and mode != "metadata": + raise RegistryError("Robot dogs, AGVs, and mobile manipulators support metadata registration only") + result = { + "domain": "personal", + "name": clean_text(payload.get("name", ""), 80, required=True), + "type": kind, + "connection_mode": mode, + } + for key, limit in (("model", 100), ("location", 100), ("note", 500), ("expected_project", 80)): + result[key] = clean_text(payload.get(key, ""), limit) + result["status_url"] = validate_url(payload.get("status_url", "")) if mode == "http_status" else "" + return result + + +class RobotStore: + def __init__(self, root: Path): + self.root = Path(root) + if self.root.is_symlink(): + raise RegistryError("The robot directory cannot be a symbolic link") + self.root.mkdir(mode=0o700, parents=True, exist_ok=True) + os.chmod(self.root, 0o700) + self.path = self.root / "devices.sqlite3" + if self.path.is_symlink(): + raise RegistryError("The robot database cannot be a symbolic link") + with self.connect() as db: + db.execute( + "CREATE TABLE IF NOT EXISTS devices (id TEXT PRIMARY KEY, revision INTEGER NOT NULL, data TEXT NOT NULL, observation TEXT, deleted_at TEXT, updated_at TEXT NOT NULL)" + ) + os.chmod(self.path, 0o600) + + @contextmanager + def connect(self): + db = sqlite3.connect(self.path, timeout=5) + db.row_factory = sqlite3.Row + try: + with db: + yield db + finally: + db.close() + + @staticmethod + def unpack(row): + data = json.loads(row["data"]) + if data.get("domain") != "personal": + raise RegistryError("The stored security domain does not match") + observation = json.loads(row["observation"]) if row["observation"] else None + status = "unknown" if data["connection_mode"] == "metadata" else "not_connected" + stale = False + if observation: + age = time.time() - observation["checked_epoch"] + stale = age < -5 or age > TTL + status = "unknown" if stale else observation["status"] + return { + **data, + "id": row["id"], + "revision": row["revision"], + "status": status, + "observation": observation, + "stale": stale, + "deleted_at": row["deleted_at"], + "updated_at": row["updated_at"], + } + + def list(self, *, trash=False): + with self.connect() as db: + rows = db.execute( + "SELECT * FROM devices WHERE deleted_at IS " + + ("NOT NULL" if trash else "NULL") + + " ORDER BY updated_at DESC" + ).fetchall() + return [self.unpack(row) for row in rows] + + def get(self, device_id, *, include_deleted=False): + with self.connect() as db: + row = db.execute("SELECT * FROM devices WHERE id=?", (device_id,)).fetchone() + if row is None or (row["deleted_at"] and not include_deleted): + raise MissingError("The robot does not exist or has been moved to trash") + return self.unpack(row) + + def save(self, payload, device_id=None): + data = validate(payload) + stamp = now() + with self.connect() as db: + db.execute("BEGIN IMMEDIATE") + if device_id: + old = db.execute("SELECT * FROM devices WHERE id=? AND deleted_at IS NULL", (device_id,)).fetchone() + if old is None: + raise MissingError("The robot does not exist") + if type(payload.get("revision")) is not int or payload["revision"] != old["revision"]: + raise ConflictError("The robot was edited by another client; refresh before retrying") + db.execute( + "UPDATE devices SET data=?, observation=NULL, revision=revision+1, updated_at=? WHERE id=?", + (json.dumps(data, ensure_ascii=False), stamp, device_id), + ) + else: + if db.execute("SELECT COUNT(*) FROM devices").fetchone()[0] >= 100: + raise RegistryError("The registry has reached its limit of 100 records") + device_id = uuid.uuid4().hex + db.execute( + "INSERT INTO devices VALUES(?,1,?,NULL,NULL,?)", + (device_id, json.dumps(data, ensure_ascii=False), stamp), + ) + return self.get(device_id) + + def delete(self, device_id): + with self.connect() as db: + cursor = db.execute( + "UPDATE devices SET deleted_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL", + (now(), device_id), + ) + if cursor.rowcount != 1: + raise MissingError("The robot does not exist or has been moved to trash") + + def restore(self, device_id): + with self.connect() as db: + cursor = db.execute( + "UPDATE devices SET deleted_at=NULL, observation=NULL, revision=revision+1, updated_at=? WHERE id=? AND deleted_at IS NOT NULL", + (now(), device_id), + ) + if cursor.rowcount != 1: + raise MissingError("The robot does not exist in trash") + return self.get(device_id) + + def observe(self, device_id, revision, observation): + with self.connect() as db: + cursor = db.execute( + "UPDATE devices SET observation=? WHERE id=? AND revision=? AND deleted_at IS NULL", + (json.dumps(observation, ensure_ascii=False), device_id, revision), + ) + if cursor.rowcount != 1: + raise ConflictError("The robot changed during the check; the stale result was not saved") + return self.get(device_id) + + +def _nonfinite_json(value): + raise ValueError("Non-finite JSON constant") + + +async def probe_status(device, *, test_loopback=False): + url = validate_url(device["status_url"], test_loopback=test_loopback) + result = { + "status": "error", + "checked_at": now(), + "checked_epoch": time.time(), + "http_status": None, + "detail": "", + "observed": {}, + } + try: + async with ClientSession( + trust_env=False, timeout=ClientTimeout(total=3), raise_for_status=False, auto_decompress=False + ) as client: + async with client.get( + url, allow_redirects=False, headers={"Accept": "application/json", "Accept-Encoding": "identity"} + ) as response: + result["http_status"] = response.status + if response.status != 200: + result["detail"] = ( + "The endpoint returned an error; redirects were not followed and no credentials were sent" + ) + return result + raw = bytearray() + async for chunk in response.content.iter_chunked(4096): + raw.extend(chunk) + if len(raw) > MAX_RESPONSE: + result["detail"] = "The status response exceeded 16 KiB" + return result + try: + data = json.loads(raw, parse_constant=_nonfinite_json) + except (ValueError, UnicodeDecodeError, RecursionError): + result["detail"] = "The endpoint did not return valid JSON" + return result + if not isinstance(data, dict): + result["detail"] = "Unsupported status response shape" + return result + expected = device.get("expected_project") + if expected and data.get("project") != expected: + result["detail"] = "The project identity did not match the registered device" + return result + result["observed"] = { + key: value + for key, value in data.items() + if key in OBSERVED + and isinstance(value, (str, bool, int, float)) + and (not isinstance(value, float) or math.isfinite(value)) + and (not isinstance(value, str) or len(value) <= 100) + } + if data.get("ok") is True: + result.update( + status="reachable", + detail="The status endpoint responded; chassis, audio, and motion capabilities have not been verified", + ) + else: + result.update(status="unknown", detail="The endpoint is reachable but did not confirm device health") + except (asyncio.TimeoutError, ClientError, OSError): + result.update( + status="not_connected", + detail="Could not connect to the status endpoint; check device power, address, and network", + ) + return result diff --git a/raven/rpc/methods/__init__.py b/raven/rpc/methods/__init__.py index 3ccc1b2d2..5b08bd402 100644 --- a/raven/rpc/methods/__init__.py +++ b/raven/rpc/methods/__init__.py @@ -39,6 +39,7 @@ from raven.rpc.methods.plughub import register_plughub_methods from raven.rpc.methods.question import register_question_methods from raven.rpc.methods.reload import register_reload_methods +from raven.rpc.methods.robots import register_robots_methods from raven.rpc.methods.session import register_session_methods from raven.rpc.methods.setup import register_setup_methods from raven.rpc.methods.shell import register_shell_methods @@ -271,6 +272,7 @@ def register_aligned_methods_except_system( # unconditionally: the registry is a file, and the node pool is the # process's own. register_machines_methods(dispatcher) + register_robots_methods(dispatcher) __all__ = [ @@ -290,6 +292,7 @@ def register_aligned_methods_except_system( "register_console_methods", "register_knowledge_methods", "register_machines_methods", + "register_robots_methods", "register_memory_methods", "register_playbooks_methods", "register_plughub_methods", diff --git a/raven/rpc/methods/robots.py b/raven/rpc/methods/robots.py new file mode 100644 index 000000000..df324594d --- /dev/null +++ b/raven/rpc/methods/robots.py @@ -0,0 +1,83 @@ +"""Personal robot registration, separate from executable server nodes.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import ValidationError + +from raven.home import get_config_path +from raven.ops.robots import RegistryError, RobotStore, probe_status +from raven.rpc.errors import ConfigValidationError +from raven.rpc.models import RobotsIdParams, RobotsListParams, RobotsSaveParams + +if TYPE_CHECKING: + from raven.rpc.dispatcher import Dispatcher + + +def _store() -> RobotStore: + config = get_config_path().resolve() + return RobotStore(config.parent / "robots" / config.stem / "personal") + + +def _parse(model, params): + try: + return model.model_validate(params) + except ValidationError: + raise ConfigValidationError("Invalid robot registration parameters") from None + + +async def robots_list(params: dict) -> dict: + request = _parse(RobotsListParams, params) + try: + return {"devices": _store().list(trash=request.trash), "status_ttl_seconds": 60} + except RegistryError as exc: + raise ConfigValidationError(str(exc)) from None + + +async def robots_save(params: dict) -> dict: + request = _parse(RobotsSaveParams, params) + try: + payload = request.device.model_dump() + payload["revision"] = request.revision + return {"device": _store().save(payload, request.id)} + except RegistryError as exc: + raise ConfigValidationError(str(exc)) from None + + +async def robots_remove(params: dict) -> dict: + request = _parse(RobotsIdParams, params) + try: + _store().delete(request.id) + return {"ok": True} + except RegistryError as exc: + raise ConfigValidationError(str(exc)) from None + + +async def robots_restore(params: dict) -> dict: + request = _parse(RobotsIdParams, params) + try: + return {"device": _store().restore(request.id)} + except RegistryError as exc: + raise ConfigValidationError(str(exc)) from None + + +async def robots_check(params: dict) -> dict: + request = _parse(RobotsIdParams, params) + try: + store = _store() + device = store.get(request.id) + if device["connection_mode"] != "http_status": + raise RegistryError("Metadata-only robots cannot be connected or controlled") + observation = await probe_status(device) + return {"device": store.observe(device["id"], device["revision"], observation)} + except RegistryError as exc: + raise ConfigValidationError(str(exc)) from None + + +def register_robots_methods(dispatcher: Dispatcher) -> None: + dispatcher.register("robots.list", robots_list) + dispatcher.register("robots.save", robots_save) + dispatcher.register("robots.remove", robots_remove) + dispatcher.register("robots.restore", robots_restore) + dispatcher.register("robots.check", robots_check) diff --git a/raven/rpc/models.py b/raven/rpc/models.py index 0a965ebd7..e777606c9 100644 --- a/raven/rpc/models.py +++ b/raven/rpc/models.py @@ -6882,7 +6882,76 @@ class ImportedUploadResult(_Strict): imported_at: str = Field(..., description="When it was imported, ISO 8601 in UTC.") +class RobotDraft(_Strict): + domain: Literal["personal"] + confirm_personal: bool = Field(..., strict=True) + name: str = Field(..., min_length=1, max_length=80) + type: Literal["sparkbot", "http_device", "robot_dog", "agv", "mobile_manipulator", "other"] + connection_mode: Literal["metadata", "http_status"] = "metadata" + model: str = Field(default="", max_length=100) + location: str = Field(default="", max_length=100) + note: str = Field(default="", max_length=500) + status_url: str = Field(default="", max_length=300) + expected_project: str = Field(default="", max_length=80) + + +class RobotObservation(_Strict): + status: Literal["reachable", "not_connected", "unknown", "error"] + checked_at: str + checked_epoch: float + http_status: int | None + detail: str + observed: dict[str, JsonValue] + + +class RobotRow(_Strict): + domain: Literal["personal"] + name: str + type: Literal["sparkbot", "http_device", "robot_dog", "agv", "mobile_manipulator", "other"] + connection_mode: Literal["metadata", "http_status"] + model: str + location: str + note: str + status_url: str + expected_project: str + id: str + revision: int + status: Literal["reachable", "not_connected", "unknown", "error"] + observation: RobotObservation | None + stale: bool + deleted_at: str | None + updated_at: str + + +class RobotsListParams(_Strict): + trash: bool = False + + +class RobotsListResult(_Strict): + devices: list[RobotRow] + status_ttl_seconds: int + + +class RobotsSaveParams(_Strict): + device: RobotDraft + id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$") + revision: int | None = Field(default=None, ge=1, strict=True) + + +class RobotsIdParams(_Strict): + id: str = Field(..., pattern=r"^[a-f0-9]{32}$") + + +class RobotsDeviceResult(_Strict): + device: RobotRow + + METHOD_MODELS: dict[str, tuple[type[BaseModel], type[BaseModel]]] = { + "robots.list": (RobotsListParams, RobotsListResult), + "robots.save": (RobotsSaveParams, RobotsDeviceResult), + "robots.remove": (RobotsIdParams, OkResult), + "robots.restore": (RobotsIdParams, RobotsDeviceResult), + "robots.check": (RobotsIdParams, RobotsDeviceResult), # knowledge.* -- bases and their documents, served by the in-process engine "knowledge.status": (KnowledgeStatusParams, KnowledgeStatusResult), "knowledge.bases.list": (KnowledgeBasesListParams, KnowledgeBasesListResult), diff --git a/rpc-schema/openrpc.json b/rpc-schema/openrpc.json index 383124ee6..2de4a202e 100644 --- a/rpc-schema/openrpc.json +++ b/rpc-schema/openrpc.json @@ -12118,6 +12118,181 @@ "$ref": "#/components/errors/ImportedBusy" } ] + }, + { + "name": "robots.list", + "summary": "List personal robot registrations or recoverable trash without contacting any device.", + "params": [ + { + "name": "trash", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + } + ], + "result": { + "name": "RobotsListResult", + "schema": { + "additionalProperties": false, + "properties": { + "devices": { + "items": { + "$ref": "#/components/schemas/RobotRow" + }, + "type": "array" + }, + "status_ttl_seconds": { + "type": "integer" + } + }, + "required": [ + "devices", + "status_ttl_seconds" + ], + "type": "object" + } + } + }, + { + "name": "robots.save", + "summary": "Create or update a confirmed personal robot registration; no credentials or control endpoints.", + "params": [ + { + "name": "device", + "required": true, + "schema": { + "$ref": "#/components/schemas/RobotDraft" + } + }, + { + "name": "id", + "required": false, + "schema": { + "default": null, + "pattern": "^[a-f0-9]{32}$", + "type": [ + "string", + "null" + ] + } + }, + { + "name": "revision", + "required": false, + "schema": { + "default": null, + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + } + ], + "result": { + "name": "RobotsDeviceResult", + "schema": { + "additionalProperties": false, + "properties": { + "device": { + "$ref": "#/components/schemas/RobotRow" + } + }, + "required": [ + "device" + ], + "type": "object" + } + } + }, + { + "name": "robots.remove", + "summary": "Move a robot registration to recoverable trash without contacting the device.", + "params": [ + { + "name": "id", + "required": true, + "schema": { + "pattern": "^[a-f0-9]{32}$", + "type": "string" + } + } + ], + "result": { + "name": "OkResult", + "schema": { + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + } + } + }, + { + "name": "robots.restore", + "summary": "Restore a registration from trash and discard its previous health observation.", + "params": [ + { + "name": "id", + "required": true, + "schema": { + "pattern": "^[a-f0-9]{32}$", + "type": "string" + } + } + ], + "result": { + "name": "RobotsDeviceResult", + "schema": { + "additionalProperties": false, + "properties": { + "device": { + "$ref": "#/components/schemas/RobotRow" + } + }, + "required": [ + "device" + ], + "type": "object" + } + } + }, + { + "name": "robots.check", + "summary": "Make one bounded read-only GET to the registered private IPv4 health endpoint; never install or execute a node.", + "params": [ + { + "name": "id", + "required": true, + "schema": { + "pattern": "^[a-f0-9]{32}$", + "type": "string" + } + } + ], + "result": { + "name": "RobotsDeviceResult", + "schema": { + "additionalProperties": false, + "properties": { + "device": { + "$ref": "#/components/schemas/RobotRow" + } + }, + "required": [ + "device" + ], + "type": "object" + } + } } ], "components": { @@ -18653,6 +18828,256 @@ "description": "The agent entries on the machine that are switched off, by name: they still hold the machine for the add, so the card says so instead of counting them nowhere." } } + }, + "RobotObservation": { + "additionalProperties": false, + "properties": { + "status": { + "enum": [ + "reachable", + "not_connected", + "unknown", + "error" + ], + "type": "string" + }, + "checked_at": { + "type": "string" + }, + "checked_epoch": { + "type": "number" + }, + "http_status": { + "type": [ + "integer", + "null" + ] + }, + "detail": { + "type": "string" + }, + "observed": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "type": "object" + } + }, + "required": [ + "status", + "checked_at", + "checked_epoch", + "http_status", + "detail", + "observed" + ], + "type": "object" + }, + "RobotRow": { + "additionalProperties": false, + "properties": { + "domain": { + "const": "personal", + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "enum": [ + "sparkbot", + "http_device", + "robot_dog", + "agv", + "mobile_manipulator", + "other" + ], + "type": "string" + }, + "connection_mode": { + "enum": [ + "metadata", + "http_status" + ], + "type": "string" + }, + "model": { + "type": "string" + }, + "location": { + "type": "string" + }, + "note": { + "type": "string" + }, + "status_url": { + "type": "string" + }, + "expected_project": { + "type": "string" + }, + "id": { + "type": "string" + }, + "revision": { + "type": "integer" + }, + "status": { + "enum": [ + "reachable", + "not_connected", + "unknown", + "error" + ], + "type": "string" + }, + "observation": { + "additionalProperties": false, + "properties": { + "status": { + "enum": [ + "reachable", + "not_connected", + "unknown", + "error" + ], + "type": "string" + }, + "checked_at": { + "type": "string" + }, + "checked_epoch": { + "type": "number" + }, + "http_status": { + "type": [ + "integer", + "null" + ] + }, + "detail": { + "type": "string" + }, + "observed": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "type": "object" + } + }, + "required": [ + "status", + "checked_at", + "checked_epoch", + "http_status", + "detail", + "observed" + ], + "type": [ + "object", + "null" + ] + }, + "stale": { + "type": "boolean" + }, + "deleted_at": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "domain", + "name", + "type", + "connection_mode", + "model", + "location", + "note", + "status_url", + "expected_project", + "id", + "revision", + "status", + "observation", + "stale", + "deleted_at", + "updated_at" + ], + "type": "object" + }, + "RobotDraft": { + "additionalProperties": false, + "properties": { + "domain": { + "const": "personal", + "type": "string" + }, + "confirm_personal": { + "type": "boolean" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "type": { + "enum": [ + "sparkbot", + "http_device", + "robot_dog", + "agv", + "mobile_manipulator", + "other" + ], + "type": "string" + }, + "connection_mode": { + "default": "metadata", + "enum": [ + "metadata", + "http_status" + ], + "type": "string" + }, + "model": { + "default": "", + "maxLength": 100, + "type": "string" + }, + "location": { + "default": "", + "maxLength": 100, + "type": "string" + }, + "note": { + "default": "", + "maxLength": 500, + "type": "string" + }, + "status_url": { + "default": "", + "maxLength": 300, + "type": "string" + }, + "expected_project": { + "default": "", + "maxLength": 80, + "type": "string" + } + }, + "required": [ + "domain", + "confirm_personal", + "name", + "type" + ], + "type": "object" } }, "errors": { diff --git a/tests/test_ops_robots.py b/tests/test_ops_robots.py new file mode 100644 index 000000000..3633a9742 --- /dev/null +++ b/tests/test_ops_robots.py @@ -0,0 +1,246 @@ +"""Persistence, isolation, stale results, and bounded read-only robot health checks.""" + +import asyncio +import gzip +import json +import os +import tempfile +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +from aiohttp import web +from aiohttp.test_utils import TestServer + +from raven.ops.robots import ( + ConflictError, + MissingError, + RegistryError, + RobotStore, + probe_status, + validate, + validate_url, +) + + +def data(**changes): + return dict( + domain="personal", + confirm_personal=True, + name="Test personal device", + type="sparkbot", + connection_mode="metadata", + **changes, + ) + + +class StorageTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) / "registry" + self.db = RobotStore(self.root) + + def tearDown(self): + self.tmp.cleanup() + + def test_invalid_metadata_and_absent_records_preserve_existing_data(self): + original = self.db.save(data()) + for payload in [ + None, + {**data(), "name": " "}, + {**data(), "name": 4}, + {**data(), "name": "bad\x00name"}, + {**data(), "name": "x" * 81}, + {**data(), "type": "unsupported"}, + {**data(), "connection_mode": "ssh"}, + ]: + with self.subTest(payload=payload), self.assertRaises(RegistryError): + self.db.save(payload) + missing = "0" * 32 + for action in [ + lambda: self.db.get(missing), + lambda: self.db.delete(missing), + lambda: self.db.restore(missing), + lambda: self.db.save({**data(), "revision": 1}, missing), + ]: + with self.assertRaises(MissingError): + action() + self.assertEqual(self.db.list()[0]["id"], original["id"]) + + def test_registry_size_and_stored_domain_are_enforced(self): + row = self.db.save(data()) + with self.db.connect() as db: + for i in range(99): + db.execute( + "INSERT INTO devices VALUES(?,1,?,NULL,NULL,?)", + (f"{i:032x}", json.dumps(validate(data())), "fixture"), + ) + with self.assertRaises(RegistryError): + self.db.save(data()) + self.assertEqual(len(self.db.list()), 100) + with self.db.connect() as db: + db.execute( + "UPDATE devices SET data=? WHERE id=?", + (json.dumps({**validate(data()), "domain": "company"}), row["id"]), + ) + with self.assertRaises(RegistryError): + self.db.list() + + @unittest.skipIf(os.name == "nt", "POSIX symlink and permission boundary") + def test_symbolic_link_stores_are_refused(self): + alias = Path(self.tmp.name) / "alias" + alias.symlink_to(self.root, target_is_directory=True) + with self.assertRaises(RegistryError): + RobotStore(alias) + other = Path(self.tmp.name) / "other" + other.mkdir() + (other / "devices.sqlite3").symlink_to(self.db.path) + with self.assertRaises(RegistryError): + RobotStore(other) + + def test_crud_persistence_cas_and_recovery(self): + original = self.db.save(data()) + self.assertEqual(original["status"], "unknown") + reopened = RobotStore(self.root) + edited = reopened.save( + {**data(), "name": "Edited personal device", "revision": original["revision"]}, original["id"] + ) + self.assertEqual(edited["revision"], 2) + with self.assertRaises(ConflictError): + reopened.save({**data(), "revision": 1}, original["id"]) + reopened.delete(original["id"]) + self.assertEqual(reopened.list(), []) + self.assertEqual(len(RobotStore(self.root).list(trash=True)), 1) + restored = reopened.restore(original["id"]) + self.assertEqual(restored["name"], "Edited personal device") + self.assertEqual(restored["status"], "unknown") + with self.assertRaises(MissingError): + reopened.restore(original["id"]) + if os.name != "nt": + self.assertEqual(self.root.stat().st_mode & 0o777, 0o700) + self.assertEqual(reopened.path.stat().st_mode & 0o777, 0o600) + + def test_domain_and_credential_control_fields_are_rejected(self): + for payload in ( + {**data(), "domain": "company"}, + {**data(), "confirm_personal": False}, + {**data(), "api_key": "not-a-key"}, + {**data(), "command": "move"}, + {**data(), "type": "agv", "connection_mode": "http_status"}, + ): + with self.subTest(payload=payload), self.assertRaises(RegistryError): + validate(payload) + self.assertEqual(self.db.list(), []) + + def test_no_dns_public_loopback_credentials_or_control_urls(self): + for address in [ + "http://localhost:8080/health", + "http://127.0.0.1:8080/health", + "http://169.254.169.254/health", + "http://8.8.8.8/health", + "http://[::1]/health", + "http://u:p@192.168.1.2:8080/health", + "http://192.168.1.2:8080/health?command=move", + "http://192.168.1.2:8080/health#token", + "http://192.168.1.2:22/health", + "http://192.168.1.2:0/health", + "http://192.168.1.2:8080/\nhealth", + "http://192.168.1.2:8080/move", + ]: + with self.subTest(address=address), self.assertRaises(RegistryError): + validate_url(address) + self.assertEqual(validate_url("http://192.168.1.2:8080/health"), "http://192.168.1.2:8080/health") + + def test_status_stales_and_late_result_cannot_resurrect_or_overwrite(self): + row = self.db.save({**data(), "connection_mode": "http_status", "status_url": "http://192.168.1.2:8080/health"}) + observation = {"checked_epoch": time.time(), "status": "reachable"} + self.assertEqual(self.db.observe(row["id"], row["revision"], observation)["status"], "reachable") + with patch("raven.ops.robots.time.time", return_value=time.time() + 61): + self.assertEqual(self.db.get(row["id"])["status"], "unknown") + updated = self.db.save({**data(), "revision": row["revision"]}, row["id"]) + with self.assertRaises(ConflictError): + self.db.observe(row["id"], row["revision"], observation) + self.db.delete(row["id"]) + with self.assertRaises(ConflictError): + self.db.observe(row["id"], updated["revision"], observation) + self.assertEqual(self.db.list(), []) + + +class ProbeTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.mode = "good" + self.redirect_hits = 0 + + async def handler(request): + if self.mode == "deep": + return web.Response(body=b"[" * 5000 + b"0" + b"]" * 5000) + if self.mode == "compressed": + return web.Response( + body=gzip.compress(b'{"ok":true,"project":"fixture"}'), headers={"Content-Encoding": "gzip"} + ) + if self.mode == "nonfinite": + return web.Response(body=b'{"ok":true,"project":"fixture","uptime_seconds":NaN}') + if self.mode == "redirect": + raise web.HTTPFound("/control-target") + if self.mode == "oversize": + return web.json_response({"ok": True, "padding": "x" * 17000}) + if self.mode == "invalid": + return web.Response(text="not a device") + if self.mode == "http_error": + return web.json_response({"ok": True}, status=503) + if self.mode == "unhealthy": + return web.json_response({"ok": False, "project": "fixture"}) + if self.mode == "shape": + return web.json_response([]) + if self.mode == "identity": + return web.json_response({"ok": True, "project": "wrong"}) + # Fragmented transfer exercises bounded reads without truncating JSON. + response = web.StreamResponse(headers={"Content-Type": "application/json"}) + await response.prepare(request) + await response.write(b'{"ok":true,"project":"fixture",') + await asyncio.sleep(0.01) + await response.write(b'"chassis_online":false,"secret":"must-not-leak"}') + await response.write_eof() + return response + + async def redirect_target(request): + self.redirect_hits += 1 + return web.json_response({"ok": True}) + + app = web.Application() + app.router.add_get("/health", handler) + app.router.add_get("/control-target", redirect_target) + self.server = TestServer(app) + await self.server.start_server() + self.device = {"status_url": str(self.server.make_url("/health")), "expected_project": "fixture"} + + async def asyncTearDown(self): + await self.server.close() + + async def test_fragmented_response_allowlist_and_identity(self): + result = await probe_status(self.device, test_loopback=True) + self.assertEqual(result["status"], "reachable") + self.assertIs(result["observed"]["chassis_online"], False) + self.assertNotIn("secret", result["observed"]) + self.mode = "identity" + self.assertEqual((await probe_status(self.device, test_loopback=True))["status"], "error") + + async def test_failure_is_never_online_and_redirect_not_followed(self): + for mode in [ + "redirect", + "oversize", + "invalid", + "http_error", + "unhealthy", + "shape", + "compressed", + "nonfinite", + "deep", + ]: + self.mode = mode + with self.subTest(mode=mode): + self.assertNotEqual((await probe_status(self.device, test_loopback=True))["status"], "reachable") + self.assertEqual(self.redirect_hits, 0) + await self.server.close() + self.assertEqual((await probe_status(self.device, test_loopback=True))["status"], "not_connected") diff --git a/tests/test_rpc_robots.py b/tests/test_rpc_robots.py new file mode 100644 index 000000000..c6cdf7227 --- /dev/null +++ b/tests/test_rpc_robots.py @@ -0,0 +1,152 @@ +"""Robot RPC contracts, profile isolation, and refusal of device control.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import jsonschema +import pytest +from pydantic import ValidationError + +from raven.rpc.dispatcher import Dispatcher +from raven.rpc.errors import ConfigValidationError +from raven.rpc.methods import robots +from raven.rpc.models import METHOD_MODELS, RobotDraft + + +def draft(**changes): + return {"domain": "personal", "confirm_personal": True, "name": "Fixture robot", "type": "sparkbot", **changes} + + +@pytest.fixture +def home(tmp_path, monkeypatch): + monkeypatch.setattr(robots, "get_config_path", lambda: tmp_path / "config.json") + return tmp_path + + +def checked(method, result): + METHOD_MODELS[method][1].model_validate(result) + schema = json.loads((Path(__file__).parents[1] / "rpc-schema/openrpc.json").read_text()) + wire = next(item["result"]["schema"] for item in schema["methods"] if item["name"] == method) + jsonschema.validate(result, {"components": schema["components"], **wire}) + return result + + +@pytest.mark.asyncio +async def test_rpc_crud_persistence_and_recoverable_removal(home): + row = checked("robots.save", await robots.robots_save({"device": draft()}))["device"] + assert row["status"] == "unknown" + updated = await robots.robots_save( + {"device": draft(name="Edited fixture"), "id": row["id"], "revision": row["revision"]} + ) + checked("robots.save", updated) + with pytest.raises(ConfigValidationError, match="edited by another client"): + await robots.robots_save({"device": draft(), "id": row["id"], "revision": row["revision"]}) + checked("robots.remove", await robots.robots_remove({"id": row["id"]})) + assert checked("robots.list", await robots.robots_list({}))["devices"] == [] + trash = checked("robots.list", await robots.robots_list({"trash": True}))["devices"] + assert trash[0]["name"] == "Edited fixture" + restored = checked("robots.restore", await robots.robots_restore({"id": row["id"]}))["device"] + assert restored["observation"] is None + assert restored["name"] == "Edited fixture" + assert len(robots._store().list()) == 1 + + +@pytest.mark.asyncio +async def test_metadata_check_and_invalid_inputs_never_probe(home, monkeypatch): + calls = [] + + async def forbidden_probe(*args, **kwargs): + calls.append(args) + raise AssertionError("Unexpected network access") + + monkeypatch.setattr(robots, "probe_status", forbidden_probe) + row = (await robots.robots_save({"device": draft(type="agv")}))["device"] + with pytest.raises(ConfigValidationError, match="Metadata-only"): + await robots.robots_check({"id": row["id"]}) + for change in ( + {"domain": "company"}, + {"confirm_personal": False}, + {"confirm_personal": "true"}, + {"command": "move"}, + {"api_key": "fixture"}, + {"status_url": "http://127.0.0.1:8080/health", "connection_mode": "http_status"}, + ): + with pytest.raises(ConfigValidationError): + await robots.robots_save({"device": draft(**change)}) + with pytest.raises(ConfigValidationError): + await robots.robots_check({"id": "not-an-id"}) + assert not calls + assert len(robots._store().list()) == 1 + + +@pytest.mark.asyncio +async def test_missing_records_and_foreign_stored_domains_are_refused(home): + for operation in [robots.robots_remove, robots.robots_restore]: + with pytest.raises(ConfigValidationError): + await operation({"id": "0" * 32}) + row = (await robots.robots_save({"device": draft()}))["device"] + with robots._store().connect() as db: + db.execute("UPDATE devices SET data=? WHERE id=?", (json.dumps({"domain": "company"}), row["id"])) + with pytest.raises(ConfigValidationError, match="security domain"): + await robots.robots_list({}) + + +@pytest.mark.asyncio +async def test_explicit_check_uses_registered_address_and_stale_result_is_refused(home, monkeypatch): + row = ( + await robots.robots_save( + {"device": draft(connection_mode="http_status", status_url="http://10.0.0.2:8080/health")} + ) + )["device"] + observation = { + "status": "not_connected", + "checked_at": "2026-01-01T00:00:00+00:00", + "checked_epoch": 0.0, + "http_status": None, + "detail": "Fixture disconnected", + "observed": {}, + } + calls = [] + + async def probe(device): + calls.append(device["status_url"]) + return observation + + monkeypatch.setattr(robots, "probe_status", probe) + result = checked("robots.check", await robots.robots_check({"id": row["id"]})) + assert calls == ["http://10.0.0.2:8080/health"] + assert result["device"]["status"] == "unknown" + + async def overlapping_remove(device): + robots._store().delete(device["id"]) + return observation + + monkeypatch.setattr(robots, "probe_status", overlapping_remove) + with pytest.raises(ConfigValidationError, match="stale result"): + await robots.robots_check({"id": row["id"]}) + assert not robots._store().list() + + +@pytest.mark.asyncio +async def test_config_profiles_are_separate_and_have_no_motion_method(home, monkeypatch): + await robots.robots_save({"device": draft()}) + monkeypatch.setattr(robots, "get_config_path", lambda: home / "other.json") + assert (await robots.robots_list({}))["devices"] == [] + dispatcher = Dispatcher() + robots.register_robots_methods(dispatcher) + assert set(dispatcher.methods()) == { + "robots.list", + "robots.save", + "robots.check", + "robots.remove", + "robots.restore", + } + reply = await dispatcher.dispatch({"jsonrpc": "2.0", "id": 1, "method": "robots.move", "params": {}}) + assert reply["error"]["code"] == -32601 + + +def test_draft_does_not_accept_credentials(): + with pytest.raises(ValidationError): + RobotDraft.model_validate(draft(password="fixture")) diff --git a/tests/test_rpc_schema_match.py b/tests/test_rpc_schema_match.py index 8bf6b2f88..ea9ef6233 100644 --- a/tests/test_rpc_schema_match.py +++ b/tests/test_rpc_schema_match.py @@ -89,6 +89,11 @@ def _resolve_pyd_ref(ref: str, root: dict[str, Any]) -> dict[str, Any]: _ANY_REFS = {"JsonValue"} +def _oas_nullable(node: dict[str, Any]) -> bool: + kinds = node.get("type", []) + return isinstance(kinds, list) and "null" in kinds + + def _normalize_oas_type( node: dict[str, Any], schema: dict[str, Any], _seen: frozenset[str] = frozenset() ) -> dict[str, Any]: @@ -138,7 +143,9 @@ def _normalize_oas_type( out["properties"] = { pname: _normalize_oas_type(psub, schema, _seen) for pname, psub in node["properties"].items() } - out["required"] = sorted(node.get("required", [])) + out["required"] = sorted( + name for name in node.get("required", []) if not _oas_nullable(node["properties"][name]) + ) if "additionalProperties" in node: ap = node["additionalProperties"] if isinstance(ap, dict): @@ -230,7 +237,7 @@ def _oas_params_to_canonical(method: dict[str, Any], schema: dict[str, Any]) -> out: dict[str, dict[str, Any]] = {} for p in method.get("params", []): out[p["name"]] = { - "required": bool(p.get("required", False)), + "required": bool(p.get("required", False)) and not _oas_nullable(p["schema"]), "schema": _normalize_oas_type(p["schema"], schema), } return out @@ -270,6 +277,8 @@ def _oas_object_properties( out: dict[str, dict[str, Any]] = {} for name, sub in properties.items(): out[name] = _normalize_oas_type(sub, schema) + if _oas_nullable(sub): + required_set.discard(name) return out, required_set @@ -799,3 +808,24 @@ def test_a_model_is_built_when_first_used_not_at_import(tmp_path: Path) -> None: assert "built at import: []" in done.stdout, done.stdout + done.stderr assert "first use: 3 True" in done.stdout, done.stdout + done.stderr assert "refused: extra_forbidden" in done.stdout, done.stdout + done.stderr + + +def test_required_nullable_arrays_normalize_like_pydantic_without_relaxing_the_wire(): + import jsonschema + + class Example(BaseModel): + value: str | None + + wire = { + "type": "object", + "required": ["value"], + "properties": {"value": {"type": ["string", "null"]}}, + "additionalProperties": False, + } + assert _normalize_oas_type(wire, {}) == _normalize_pyd_type( + Example.model_json_schema(), Example.model_json_schema() + ) + assert _oas_object_properties(wire, {}) == _pyd_object_properties(Example) + jsonschema.validate({"value": None}, wire) + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate({}, wire) diff --git a/ui-tui/src/i18n/messages.generated.ts b/ui-tui/src/i18n/messages.generated.ts index 7c7f124e2..e590b4506 100644 --- a/ui-tui/src/i18n/messages.generated.ts +++ b/ui-tui/src/i18n/messages.generated.ts @@ -1044,7 +1044,7 @@ export const UI_TEXT: Record> = { 'gui.nav.hub': 'Connectors', 'gui.hub.agents': 'Agents', 'gui.hub.channels': 'Channels', - 'gui.hub.machines': 'Machines', + 'gui.hub.machines': 'Servers', 'gui.nav.conn': 'Channels', 'gui.nav.cron': 'Schedules', 'gui.nav.more': 'More', @@ -1106,7 +1106,7 @@ export const UI_TEXT: Record> = { 'gui.page.conn_sub': 'Raven joins the chat apps you already use, so you can hand it work and get results back from WeChat, Feishu, Telegram and more.', 'gui.page.machines_sub': - 'The machines Raven can hand work to besides this computer: register one, put the node on it, then pick it when an agent is connected.', + 'The servers Raven can hand work to besides this computer: register one, put the node on it, then pick it when an agent is connected.', 'gui.page.cron': 'Schedules', 'gui.page.set': 'Settings', 'gui.perm.ask': 'Approve each step', @@ -1527,9 +1527,9 @@ export const UI_TEXT: Record> = { 'gui.agent.migrate_do': 'Switch transport', 'gui.agent.migrate_body': 'The preset for {name} now reaches it over {to}. The entry is removed and added back from that preset, so handles from runs already in flight stop resolving.', - 'gui.agent.on_machine': 'On the registered machine {id}', - 'gui.machine.register': 'Register a machine', - 'gui.machine.none': 'No machine is registered yet. Register one, then install the node on it.', + 'gui.agent.on_machine': 'On the registered server {id}', + 'gui.machine.register': 'Register a server', + 'gui.machine.none': 'No server is registered yet. Register one, then install the node on it.', 'gui.machine.state_connected': '{n} agents running', 'gui.machine.state_idle': 'Link up, no agent running', 'gui.machine.state_lost': 'Link down; agents wait', @@ -1565,83 +1565,83 @@ export const UI_TEXT: Record> = { 'gui.machine.key_note': 'Only the path is stored. The key never leaves this computer.', 'gui.machine.f_password': 'Password', 'gui.machine.password_note': - "The password is kept on this computer, readable by your account alone, and handed to ssh on every connection: never on a command line, never in a log. The first connection records the machine's host key and then sends the password, so on a network you do not trust, ssh once from a terminal and check the fingerprint before registering. When the machine's password changes, remove the machine and register it again.", + "The password is kept on this computer, readable by your account alone, and handed to ssh on every connection: never on a command line, never in a log. The first connection records the server's host key and then sends the password, so on a network you do not trust, ssh once from a terminal and check the fingerprint before registering. When the server's password changes, remove the server and register it again.", 'gui.machine.auth_note': - "No ssh alias is written for it; an agent reads and writes its files through Raven's own file tools. When its password changes, remove the machine and register it again.", - 'gui.machine.f_paths': 'Work directories on the machine, one per line (optional)', + "No ssh alias is written for it; an agent reads and writes its files through Raven's own file tools. When its password changes, remove the server and register it again.", + 'gui.machine.f_paths': 'Work directories on the server, one per line (optional)', 'gui.machine.add_hint': - "An ssh login, with a key or with a password. The machine's host key is recorded at the first contact and checked after.", + "An ssh login, with a key or with a password. The server's host key is recorded at the first contact and checked after.", 'gui.machine.add_do': 'Reach it once and register', - 'gui.machine.adding': 'Reaching the machine', + 'gui.machine.adding': 'Reaching the server', 'gui.machine.add_bad': 'Not registered', 'gui.machine.where': 'Runs on', 'gui.machine.where_here': 'This computer', - 'gui.machine.remote_cwd': 'Working directory on the machine', - 'gui.machine.where_none': 'No machine is registered; see the Machines tab.', + 'gui.machine.remote_cwd': 'Working directory on the server', + 'gui.machine.where_none': 'No server is registered; see the Servers tab.', 'gui.machine.needs_node': 'Install the node first', 'gui.machine.needs_update': 'Update the node first', 'gui.machine.node_stale': 'Node {v}, needs updating', 'gui.machine.node_here': 'This computer', 'gui.machine.update': 'Update node', 'gui.machine.f_node_dir': 'Install into (optional)', - 'gui.machine.node_dir_hint': 'Where the node lives on the machine: ~/.raven-node unless its home disk has no room.', + 'gui.machine.node_dir_hint': 'Where the node lives on the server: ~/.raven-node unless its home disk has no room.', 'gui.machine.install_bad_hint': 'Fix what the line says, then press Install node again: the next try clears what this one left of the node first.', - 'gui.machine.remove_do': 'Remove machine', + 'gui.machine.remove_do': 'Remove server', 'gui.machine.remove_body': - '{name} ({id}) is taken out of the registry and its ssh alias goes with it. Agent entries that run there have to be disconnected first. The node installed on the machine stays there.', + '{name} ({id}) is taken out of the registry and its ssh alias goes with it. Agent entries that run there have to be disconnected first. The node installed on the server stays there.', 'gui.machine.remove_body_password': - '{name} ({id}) is taken out of the registry and the password kept here for it is deleted. Agent entries that run there have to be disconnected first. The node installed on the machine stays there.', + '{name} ({id}) is taken out of the registry and the password kept here for it is deleted. Agent entries that run there have to be disconnected first. The node installed on the server stays there.', 'gui.machine.removing': 'Removing', 'gui.machine.removed': '{name} was removed', - 'gui.machine.load_bad': 'The machine list could not be read', + 'gui.machine.load_bad': 'The server list could not be read', 'gui.machine.file_problems': 'The registry file', 'gui.machine.why_unreachable': - 'The machine could not be reached: check the address and port, and that the machine is on and reachable from this computer.', + 'The server could not be reached: check the address and port, and that the server is on and reachable from this computer.', 'gui.machine.why_login': - "The machine refused the login. With a key: check the user name and the private key path, and that the key's public half is on the machine. With a password: check it, and that the machine accepts password logins at all.", + "The server refused the login. With a key: check the user name and the private key path, and that the key's public half is on the server. With a password: check it, and that the server accepts password logins at all.", 'gui.machine.why_prompt': - "The machine asked something the kept password does not answer -- a one-time code, another account's password, or a password prompt that names a host other than the registered one -- so the password was not sent: see the line below.", + "The server asked something the kept password does not answer -- a one-time code, another account's password, or a password prompt that names a host other than the registered one -- so the password was not sent: see the line below.", 'gui.machine.why_hostkey': - "The machine's host key differs from the one on record: check the machine before trusting it again, then take the old line out of ~/.ssh/known_hosts.", - 'gui.machine.why_timeout': 'The machine did not answer in time: check that it is on and that the port is open.', + "The server's host key differs from the one on record: check the server before trusting it again, then take the old line out of ~/.ssh/known_hosts.", + 'gui.machine.why_timeout': 'The server did not answer in time: check that it is on and that the port is open.', 'gui.machine.why_nossh': 'ssh is not installed on this computer: install OpenSSH, then try again.', 'gui.machine.why_platform': - "This computer is Windows, where registering with a password is not available: pick Key, put the key's public half on the machine, and give the private key's path here.", - 'gui.machine.why_duplicate': 'That id is taken: pick another, or remove the machine listed under it first.', + "This computer is Windows, where registering with a password is not available: pick Key, put the key's public half on the server, and give the private key's path here.", + 'gui.machine.why_duplicate': 'That id is taken: pick another, or remove the server listed under it first.', 'gui.machine.why_invalid': 'A field is missing or cannot be used: see the line below.', 'gui.machine.why_registry': 'The registry file cannot be changed: fix it first, as the line below says.', 'gui.machine.install_bad_said': 'The install did not finish: {said}', 'gui.machine.remove_named': - 'Not removed: {agents} still run on this machine; disconnect them first, in the Connected tab.', + 'Not removed: {agents} still run on this server; disconnect them first, in the Connected tab.', 'gui.machine.remove_running': - 'Not removed: agents are running on this machine; wait for them to finish, or stop them first. {said}', + 'Not removed: agents are running on this server; wait for them to finish, or stop them first. {said}', 'gui.machine.remove_bad_said': 'Not removed: {said}', 'gui.machine.err_no_node': 'No Raven node on it yet: press Install node. {said}', 'gui.machine.err_stale_node': "Its node is another Raven's: press Update node. {said}", - 'gui.machine.agents_here': 'Agents on this machine', + 'gui.machine.agents_here': 'Agents on this server', 'gui.machine.agents_here_none': 'None yet', - 'gui.machine.agents_line': '{n} on this machine: {names}', + 'gui.machine.agents_line': '{n} on this server: {names}', 'gui.machine.agents_line_none': 'No agent connected here yet', 'gui.machine.next_title': 'Next', 'gui.machine.next_body': - "Connect an agent that runs on this machine. The agent itself has to be installed and signed in there; Raven's node only starts it.", - 'gui.machine.connect_here': 'Connect an agent on this machine', + "Connect an agent that runs on this server. The agent itself has to be installed and signed in there; Raven's node only starts it.", + 'gui.machine.connect_here': 'Connect an agent on this server', 'gui.machine.connect_title': 'Connect an agent on {name}', 'gui.machine.connect_hint': 'It is started there once first, as proof, and written only when it answers.', 'gui.machine.f_preset': 'Agent', - 'gui.machine.preset_connected_here': '{name} (connected on this computer; this adds one on the machine)', - 'gui.machine.preset_on_machine': '{name} (already connected on this machine)', - 'gui.machine.preset_off_entry': '{name} (connected on this machine as {entry}, switched off)', + 'gui.machine.preset_connected_here': '{name} (connected on this computer; this adds one on the server)', + 'gui.machine.preset_on_machine': '{name} (already connected on this server)', + 'gui.machine.preset_off_entry': '{name} (connected on this server as {entry}, switched off)', 'gui.machine.off_suffix': '{name} (switched off)', 'gui.machine.reconnect_do': 'Reconnect', 'gui.machine.reconnect_hint': 'The entry is switched back on: it is started there once first, as proof.', 'gui.machine.reconnected_said': '{name} is connected again; it is on the Agents page.', - 'gui.machine.preset_connecting': '{name} (being connected on this machine right now)', - 'gui.machine.connecting': 'Starting it on the machine', - 'gui.machine.connecting_for': 'Starting it on the machine, {s}s so far', + 'gui.machine.preset_connecting': '{name} (being connected on this server right now)', + 'gui.machine.connecting': 'Starting it on the server', + 'gui.machine.connecting_for': 'Starting it on the server, {s}s so far', 'gui.machine.connecting_hint': - 'The node on the machine starts it, then it has to answer one test message: a few seconds to a minute as a rule, ten minutes at most. Cancel asks it to stop, and this form says whether anything was written.', + 'The node on the server starts it, then it has to answer one test message: a few seconds to a minute as a rule, ten minutes at most. Cancel asks it to stop, and this form says whether anything was written.', 'gui.machine.cancelling': 'Stopping', 'gui.machine.connect_cancelled_head': 'Cancelled', 'gui.machine.connect_cancelled': 'Cancelled; nothing was written. It can be connected again.', @@ -1662,15 +1662,15 @@ export const UI_TEXT: Record> = { 'gui.machine.connect_bad_named': 'Not connected: {name}', 'gui.machine.connect_do': 'Connect', 'gui.machine.presets_none': - 'Nothing to connect: every agent that can run on a machine is already connected on this one.', + 'Nothing to connect: every agent that can run on a server is already connected on this one.', 'gui.machine.presets_loading': 'Reading the agents', 'gui.machine.presets_pending': - 'Something is still being connected on this machine; the choices come back when it ends.', + 'Something is still being connected on this server; the choices come back when it ends.', 'gui.machine.keys_found': 'Found on this computer and filled in; pick another or type a path.', 'gui.machine.keys_none': 'No ssh key was found on this computer. Type a path, or pick Password: Raven then keeps the password on this computer and logs in with it.', 'gui.machine.missing_but_machine': - 'Not installed on this computer, but it can run on a registered machine where it is installed and signed in.', + 'Not installed on this computer, but it can run on a registered server where it is installed and signed in.', 'gui.machine.connect_on': 'Connect on {name}', 'gui.agent.tag_stale': 'Moved', 'gui.agent.g_avail': 'Available', @@ -3726,7 +3726,67 @@ export const UI_TEXT: Record> = { 'gui.export.kept_local': 'The report is kept at {dir}. Send it later with: raven trajectory upload-bug-report {id}', 'gui.export.stop_report_title': 'Discard this bug report?', 'gui.export.stop_report_body': 'Your decisions are lost. A report already saved on this machine stays there.', - 'gui.export.stop_report_label': 'Discard' + 'gui.export.stop_report_label': 'Discard', + 'gui.extAgents.robot_title': 'Personal robots', + 'gui.extAgents.robot_add': 'Register robot', + 'gui.extAgents.robot_edit': 'Edit registration', + 'gui.extAgents.robot_name': 'Device name', + 'gui.extAgents.robot_type': 'Device type', + 'gui.extAgents.robot_model': 'Model', + 'gui.extAgents.robot_location': 'Location', + 'gui.extAgents.robot_note': 'Note', + 'gui.extAgents.robot_mode': 'Connection mode', + 'gui.extAgents.robot_metadata': 'Metadata only', + 'gui.extAgents.robot_http': 'Read-only HTTP status', + 'gui.extAgents.robot_status_url': 'Status URL', + 'gui.extAgents.robot_identity': 'Expected project identity (optional)', + 'gui.extAgents.robot_url_hint': + 'Private IPv4 /health or /status only; no credentials, query parameters, or redirects.', + 'gui.extAgents.robot_domain': 'Security domain', + 'gui.extAgents.robot_personal': 'Personal devices', + 'gui.extAgents.robot_company': 'Company devices (unsupported here)', + 'gui.extAgents.robot_company_blocked': + 'Company devices require a separate governed instance and are not supported by this personal registry.', + 'gui.extAgents.robot_boundary': + 'Personal devices only. Do not enter company addresses, accounts, or credentials. Registration does not establish a connection.', + 'gui.extAgents.robot_personal_confirm': + 'I confirm this is a personal device and contains no company data or credentials.', + 'gui.extAgents.robot_readonly_confirm': + 'I confirm this endpoint only reports status and does not trigger control or motion.', + 'gui.extAgents.robot_metadata_only': + 'This device type supports metadata only; no connection or control is attempted.', + 'gui.extAgents.robot_safety': + 'Check status sends one read-only GET on demand. Results expire after 60 seconds. A reachable endpoint does not verify chassis, audio, or motion. No executable node is installed.', + 'gui.extAgents.robot_search': 'Search devices', + 'gui.extAgents.robot_filter': 'Filter device types', + 'gui.extAgents.robot_all_types': 'All types', + 'gui.extAgents.robot_trash': 'Trash', + 'gui.extAgents.robot_back': 'Back to devices', + 'gui.extAgents.robot_restore': 'Restore', + 'gui.extAgents.robot_remove': 'Move to trash', + 'gui.extAgents.robot_check': 'Check status', + 'gui.extAgents.robot_checking': 'Checking...', + 'gui.extAgents.robot_checked': 'Last checked', + 'gui.extAgents.robot_loading': 'Loading registrations...', + 'gui.extAgents.robot_empty': 'No matching registrations.', + 'gui.extAgents.robot_save_failed': + 'Could not save. Check the fields, or refresh if another client edited this record.', + 'gui.extAgents.robot_operation_failed': 'The operation failed. The device has not been assumed online.', + 'gui.extAgents.robot_unavailable': + 'Robot registration is unavailable on this gateway. Existing registrations have not been changed.', + 'gui.extAgents.robot_unverified': 'No verified connection. Registration alone does not establish availability.', + 'gui.extAgents.robot_reachable': 'Status endpoint reachable', + 'gui.extAgents.robot_disconnected': 'Not connected', + 'gui.extAgents.robot_unknown': 'Unknown', + 'gui.extAgents.robot_error': 'Error', + 'gui.extAgents.robot_sparkbot': 'SparkBot', + 'gui.extAgents.robot_http_device': 'HTTP device', + 'gui.extAgents.robot_dog': 'Robot dog', + 'gui.extAgents.robot_agv': 'AGV', + 'gui.extAgents.robot_mobile_manipulator': 'Mobile manipulator', + 'gui.extAgents.robot_other': 'Other device', + 'gui.hub.robots': 'Robots', + 'gui.page.robots_sub': 'Register personal devices and read status; no executable nodes or robot control.' }, zh: { 'gui.act.ing.ask_user': '等待你回答', @@ -4486,7 +4546,7 @@ export const UI_TEXT: Record> = { 'gui.nav.hub': '连接', 'gui.hub.agents': '智能体', 'gui.hub.channels': '渠道', - 'gui.hub.machines': '机器', + 'gui.hub.machines': '服务器', 'gui.nav.conn': '入口', 'gui.nav.cron': '定时', 'gui.nav.more': '更多', @@ -4544,7 +4604,7 @@ export const UI_TEXT: Record> = { 'gui.page.agents_sub': 'Raven 可以统一调度已连接的智能体,委派任务、协调执行、整合结果', 'gui.page.conn': '入口', 'gui.page.conn_sub': 'Raven 可以接入你常用的聊天应用,在微信、飞书、Telegram 里也能直接给它派活、收结果', - 'gui.page.machines_sub': 'Raven 除本机之外还能把活派到这些机器上:先登记一台、装上节点,接入智能体时就能选它', + 'gui.page.machines_sub': 'Raven 除本机之外还能把活派到这些服务器上:先登记一台、装上节点,接入智能体时就能选它', 'gui.page.cron': '定时', 'gui.page.set': '设置', 'gui.perm.ask': '逐项审批', @@ -4960,9 +5020,9 @@ export const UI_TEXT: Record> = { 'gui.conn.retry': '重试', 'gui.agent.migrate_do': '换到新方式', 'gui.agent.migrate_body': '{name} 的预设改成走 {to} 了 · 会先移除再按新预设装回来,已经在跑的会话句柄会失效', - 'gui.agent.on_machine': '在登记机器 {id} 上', - 'gui.machine.register': '登记机器', - 'gui.machine.none': '还没有登记机器。先登记一台,再在它上面安装节点。', + 'gui.agent.on_machine': '在登记服务器 {id} 上', + 'gui.machine.register': '登记服务器', + 'gui.machine.none': '还没有登记服务器。先登记一台,再在它上面安装节点。', 'gui.machine.state_connected': '{n} 个 Agent 在跑', 'gui.machine.state_idle': '链路在,没有 Agent 在跑', 'gui.machine.state_lost': '链路断了,Agent 在等', @@ -4998,78 +5058,78 @@ export const UI_TEXT: Record> = { 'gui.machine.key_note': '只记录路径,私钥不离开这台电脑。', 'gui.machine.f_password': '密码', 'gui.machine.password_note': - '密码保存在这台电脑上,只有你的账号能读;每次连接时读出来交给 ssh,不进命令行、不进日志。第一次连接会先记下机器的主机密钥再送出密码,所以在不信任的网络上,先在终端里 ssh 一次核对指纹,再登记。机器改了密码,先移除这台机器再重新登记。', + '密码保存在这台电脑上,只有你的账号能读;每次连接时读出来交给 ssh,不进命令行、不进日志。第一次连接会先记下服务器的主机密钥再送出密码,所以在不信任的网络上,先在终端里 ssh 一次核对指纹,再登记。服务器改了密码,先移除这台服务器再重新登记。', 'gui.machine.auth_note': - '没有写 ssh 别名;Agent 读写它上面的文件走 Raven 的文件工具。机器改了密码,先移除这台机器再重新登记。', - 'gui.machine.f_paths': '机器上的工作目录,每行一个(可不填)', + '没有写 ssh 别名;Agent 读写它上面的文件走 Raven 的文件工具。服务器改了密码,先移除这台服务器再重新登记。', + 'gui.machine.f_paths': '服务器上的工作目录,每行一个(可不填)', 'gui.machine.add_hint': '用密钥或密码 ssh 登录。第一次连接时记下它的主机密钥,之后每次核对。', 'gui.machine.add_do': '连一次并登记', - 'gui.machine.adding': '正在连接机器', + 'gui.machine.adding': '正在连接服务器', 'gui.machine.add_bad': '没有登记', 'gui.machine.where': '运行在', 'gui.machine.where_here': '本机', - 'gui.machine.remote_cwd': '机器上的工作目录', - 'gui.machine.where_none': '还没有登记机器,见“机器”标签。', + 'gui.machine.remote_cwd': '服务器上的工作目录', + 'gui.machine.where_none': '还没有登记服务器,见“服务器”标签。', 'gui.machine.needs_node': '先安装节点', 'gui.machine.needs_update': '先更新节点', 'gui.machine.node_stale': '节点 {v},需要更新', 'gui.machine.node_here': '本机', 'gui.machine.update': '更新节点', 'gui.machine.f_node_dir': '安装到(可不填)', - 'gui.machine.node_dir_hint': '节点在机器上的存放目录:默认 ~/.raven-node,只有主目录所在磁盘放不下时才需要改。', + 'gui.machine.node_dir_hint': '节点在服务器上的存放目录:默认 ~/.raven-node,只有主目录所在磁盘放不下时才需要改。', 'gui.machine.install_bad_hint': '按这一行的提示处理后,再点一次「安装节点」:下一次会先清掉这次没装完的节点。', - 'gui.machine.remove_do': '移除机器', + 'gui.machine.remove_do': '移除服务器', 'gui.machine.remove_body': - '{name}({id})会从登记表里去掉,ssh 别名一起去掉;在它上面跑的 Agent 条目要先断开。装在机器上的节点会留在那里。', + '{name}({id})会从登记表里去掉,ssh 别名一起去掉;在它上面跑的 Agent 条目要先断开。装在服务器上的节点会留在那里。', 'gui.machine.remove_body_password': - '{name}({id})会从登记表里去掉,为它保存的密码一起删掉;在它上面跑的 Agent 条目要先断开。装在机器上的节点会留在那里。', + '{name}({id})会从登记表里去掉,为它保存的密码一起删掉;在它上面跑的 Agent 条目要先断开。装在服务器上的节点会留在那里。', 'gui.machine.removing': '正在移除', 'gui.machine.removed': '已移除 {name}', - 'gui.machine.load_bad': '机器列表读不出来', + 'gui.machine.load_bad': '服务器列表读不出来', 'gui.machine.file_problems': '登记表文件', - 'gui.machine.why_unreachable': '连不上这台机器:检查地址和端口,确认机器开着、从这台电脑能连到它。', + 'gui.machine.why_unreachable': '连不上这台服务器:检查地址和端口,确认服务器开着、从这台电脑能连到它。', 'gui.machine.why_login': - '机器拒绝了登录。用密钥时:检查用户名和私钥路径,确认对应的公钥已经放到机器上。用密码时:检查密码,并确认这台机器允许密码登录。', + '服务器拒绝了登录。用密钥时:检查用户名和私钥路径,确认对应的公钥已经放到服务器上。用密码时:检查密码,并确认这台服务器允许密码登录。', 'gui.machine.why_prompt': - '机器问的不是这个账号自己的密码提示(比如一次性验证码、别的账号的密码,或者提示里的主机不是登记的那台),所以没有发送密码:详见下一行。', + '服务器问的不是这个账号自己的密码提示(比如一次性验证码、别的账号的密码,或者提示里的主机不是登记的那台),所以没有发送密码:详见下一行。', 'gui.machine.why_hostkey': - '机器的主机密钥和记录的不一样:先确认机器没问题,再把 ~/.ssh/known_hosts 里的旧记录删掉。', - 'gui.machine.why_timeout': '机器没有按时应答:确认机器开着、端口放行。', + '服务器的主机密钥和记录的不一样:先确认服务器没问题,再把 ~/.ssh/known_hosts 里的旧记录删掉。', + 'gui.machine.why_timeout': '服务器没有按时应答:确认服务器开着、端口放行。', 'gui.machine.why_nossh': '这台电脑没有 ssh:装好 OpenSSH 再试。', 'gui.machine.why_platform': - '这台电脑是 Windows,不能用密码登记:改选「密钥」,把公钥放到机器上,再在这里填私钥路径。', - 'gui.machine.why_duplicate': '这个 id 已经有机器在用:换一个,或者先移除原来那台。', + '这台电脑是 Windows,不能用密码登记:改选「密钥」,把公钥放到服务器上,再在这里填私钥路径。', + 'gui.machine.why_duplicate': '这个 id 已经有服务器在用:换一个,或者先移除原来那台。', 'gui.machine.why_invalid': '有字段缺失或不能用:见下一行。', 'gui.machine.why_registry': '登记表文件改不了:先按下一行的提示处理。', 'gui.machine.install_bad_said': '安装没有完成:{said}', - 'gui.machine.remove_named': '没有移除:{agents} 还在用这台机器,先到「已接入」里断开它们。', - 'gui.machine.remove_running': '没有移除:还有 Agent 正在这台机器上跑,等它们跑完或先停掉。{said}', + 'gui.machine.remove_named': '没有移除:{agents} 还在用这台服务器,先到「已接入」里断开它们。', + 'gui.machine.remove_running': '没有移除:还有 Agent 正在这台服务器上跑,等它们跑完或先停掉。{said}', 'gui.machine.remove_bad_said': '没有移除:{said}', - 'gui.machine.err_no_node': '机器上还没有节点,点「安装节点」。{said}', + 'gui.machine.err_no_node': '服务器上还没有节点,点「安装节点」。{said}', 'gui.machine.err_stale_node': '节点是另一个版本的 Raven 装的,点「更新节点」。{said}', - 'gui.machine.agents_here': '这台机器上的 Agent', + 'gui.machine.agents_here': '这台服务器上的 Agent', 'gui.machine.agents_here_none': '还没有', - 'gui.machine.agents_line': '这台机器上有 {n} 个 Agent:{names}', + 'gui.machine.agents_line': '这台服务器上有 {n} 个 Agent:{names}', 'gui.machine.agents_line_none': '还没有 Agent 接到这里', 'gui.machine.next_title': '下一步', 'gui.machine.next_body': - '接一个跑在这台机器上的 Agent。Agent 本身要在那台机器上装好并登录,Raven 的节点只负责启动它。', - 'gui.machine.connect_here': '在这台机器上接入 Agent', + '接一个跑在这台服务器上的 Agent。Agent 本身要在那台服务器上装好并登录,Raven 的节点只负责启动它。', + 'gui.machine.connect_here': '在这台服务器上接入 Agent', 'gui.machine.connect_title': '在 {name} 上接入 Agent', - 'gui.machine.connect_hint': '先在那台机器上真跑一句作证明,答了才写入。', + 'gui.machine.connect_hint': '先在那台服务器上真跑一句作证明,答了才写入。', 'gui.machine.f_preset': 'Agent', - 'gui.machine.preset_connected_here': '{name}(本机已接入,再接一份到机器)', - 'gui.machine.preset_on_machine': '{name}(已接在这台机器上)', - 'gui.machine.preset_off_entry': '{name}(已接在这台机器上,已断开:{entry})', + 'gui.machine.preset_connected_here': '{name}(本机已接入,再接一份到服务器)', + 'gui.machine.preset_on_machine': '{name}(已接在这台服务器上)', + 'gui.machine.preset_off_entry': '{name}(已接在这台服务器上,已断开:{entry})', 'gui.machine.off_suffix': '{name}(已断开)', 'gui.machine.reconnect_do': '重新接入', - 'gui.machine.reconnect_hint': '把这条条目重新启用:先在那台机器上真跑一句作证明。', + 'gui.machine.reconnect_hint': '把这条条目重新启用:先在那台服务器上真跑一句作证明。', 'gui.machine.reconnected_said': '已重新接入 {name},到「智能体」页可以看到。', - 'gui.machine.preset_connecting': '{name}(正在接到这台机器上)', - 'gui.machine.connecting': '正在机器上启动它', - 'gui.machine.connecting_for': '正在机器上启动它,已等 {s} 秒', + 'gui.machine.preset_connecting': '{name}(正在接到这台服务器上)', + 'gui.machine.connecting': '正在服务器上启动它', + 'gui.machine.connecting_for': '正在服务器上启动它,已等 {s} 秒', 'gui.machine.connecting_hint': - '机器上的节点先启动它,再等它答一句测试消息:一般几秒到一分钟,最多等 10 分钟。点「取消」会请求停掉这一跑,这里会说有没有写入。', + '服务器上的节点先启动它,再等它答一句测试消息:一般几秒到一分钟,最多等 10 分钟。点「取消」会请求停掉这一跑,这里会说有没有写入。', 'gui.machine.cancelling': '正在停止', 'gui.machine.connect_cancelled_head': '已取消', 'gui.machine.connect_cancelled': '已取消,没有写入。可以再接一次。', @@ -5088,13 +5148,13 @@ export const UI_TEXT: Record> = { 'gui.machine.connect_bad': '没有接入', 'gui.machine.connect_bad_named': '没有接入 {name}', 'gui.machine.connect_do': '接入', - 'gui.machine.presets_none': '没有可接的:能跑在机器上的 Agent 都已经接在这台机器上了。', + 'gui.machine.presets_none': '没有可接的:能跑在服务器上的 Agent 都已经接在这台服务器上了。', 'gui.machine.presets_loading': '正在读取 Agent 列表', - 'gui.machine.presets_pending': '这台机器上还有接入在进行,结束后再选。', + 'gui.machine.presets_pending': '这台服务器上还有接入在进行,结束后再选。', 'gui.machine.keys_found': '已填入这台电脑上找到的密钥,也可以换一把或自己填路径。', 'gui.machine.keys_none': '这台电脑上没找到 ssh 密钥。可以自己填路径,或改选「密码」:Raven 会把密码保存在本机,每次用它登录。', - 'gui.machine.missing_but_machine': '本机没装也可以跑在一台登记机器上,那台机器上要装好并登录它。', + 'gui.machine.missing_but_machine': '本机没装也可以跑在一台登记服务器上,那台服务器上要装好并登录它。', 'gui.machine.connect_on': '接入到 {name}', 'gui.agent.tag_stale': '需迁移', 'gui.agent.g_avail': '可以接入', @@ -7064,6 +7124,58 @@ export const UI_TEXT: Record> = { 'gui.export.kept_local': '报告已保存在 {dir},可稍后用 raven trajectory upload-bug-report {id} 上传。', 'gui.export.stop_report_title': '放弃这份问题报告?', 'gui.export.stop_report_body': '你的决定将会丢失;已保存到本机的报告会保留。', - 'gui.export.stop_report_label': '放弃' + 'gui.export.stop_report_label': '放弃', + 'gui.extAgents.robot_title': '个人机器人', + 'gui.extAgents.robot_add': '登记机器人', + 'gui.extAgents.robot_edit': '编辑登记', + 'gui.extAgents.robot_name': '设备名称', + 'gui.extAgents.robot_type': '设备类型', + 'gui.extAgents.robot_model': '型号', + 'gui.extAgents.robot_location': '位置', + 'gui.extAgents.robot_note': '备注', + 'gui.extAgents.robot_mode': '连接方式', + 'gui.extAgents.robot_metadata': '仅登记信息', + 'gui.extAgents.robot_http': 'HTTP 只读状态', + 'gui.extAgents.robot_status_url': '状态地址', + 'gui.extAgents.robot_identity': '预期设备标识(可选)', + 'gui.extAgents.robot_url_hint': '仅支持局域网 IPv4 的 /health 或 /status;不接受凭据、查询参数或重定向。', + 'gui.extAgents.robot_domain': '安全域', + 'gui.extAgents.robot_personal': '个人设备', + 'gui.extAgents.robot_company': '公司设备(此实例不接收)', + 'gui.extAgents.robot_company_blocked': '公司设备需要独立的受管实例,此个人登记库不接收公司设备。', + 'gui.extAgents.robot_boundary': '只登记个人设备,请勿填写公司地址、账号或凭据。登记不等于已连接。', + 'gui.extAgents.robot_personal_confirm': '我确认这是个人设备,登记信息不含公司数据或凭据。', + 'gui.extAgents.robot_readonly_confirm': '我确认此地址只提供状态,不会触发控制或动作。', + 'gui.extAgents.robot_metadata_only': '此设备类型仅支持信息登记,不建立连接或执行控制。', + 'gui.extAgents.robot_safety': + '仅在点击检查时发送一次只读 GET,结果 60 秒后过期。接口可达不代表底盘、音频或动作能力已验收;不安装执行节点。', + 'gui.extAgents.robot_search': '搜索设备', + 'gui.extAgents.robot_filter': '筛选设备类型', + 'gui.extAgents.robot_all_types': '全部类型', + 'gui.extAgents.robot_trash': '回收站', + 'gui.extAgents.robot_back': '返回设备', + 'gui.extAgents.robot_restore': '恢复登记', + 'gui.extAgents.robot_remove': '移入回收站', + 'gui.extAgents.robot_check': '检查状态', + 'gui.extAgents.robot_checking': '检查中…', + 'gui.extAgents.robot_checked': '最近检查', + 'gui.extAgents.robot_loading': '正在读取登记…', + 'gui.extAgents.robot_empty': '没有匹配的登记。', + 'gui.extAgents.robot_save_failed': '保存失败。请检查字段;如其他页面已修改此记录,请刷新后重试。', + 'gui.extAgents.robot_operation_failed': '操作未完成,未将设备认定为在线。', + 'gui.extAgents.robot_unavailable': '此网关的机器人登记暂不可用,原记录未被修改。', + 'gui.extAgents.robot_unverified': '连接尚未验证,登记不代表设备可用。', + 'gui.extAgents.robot_reachable': '状态接口可达', + 'gui.extAgents.robot_disconnected': '未连接', + 'gui.extAgents.robot_unknown': '未知', + 'gui.extAgents.robot_error': '错误', + 'gui.extAgents.robot_sparkbot': 'SparkBot', + 'gui.extAgents.robot_http_device': 'HTTP 设备', + 'gui.extAgents.robot_dog': '机器狗', + 'gui.extAgents.robot_agv': 'AGV', + 'gui.extAgents.robot_mobile_manipulator': '轮臂', + 'gui.extAgents.robot_other': '其他设备', + 'gui.hub.robots': '机器人', + 'gui.page.robots_sub': '登记个人设备、读取状态;不安装执行节点,不提供机器人控制' } } as Record> diff --git a/ui-tui/src/rpc/generated.ts b/ui-tui/src/rpc/generated.ts index 7c5c94112..4aa3a086c 100644 --- a/ui-tui/src/rpc/generated.ts +++ b/ui-tui/src/rpc/generated.ts @@ -3365,6 +3365,67 @@ export interface MachineRow { */ agents_off_here: string[]; } +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotObservation". + */ +export interface RobotObservation { + status: 'reachable' | 'not_connected' | 'unknown' | 'error'; + checked_at: string; + checked_epoch: number; + http_status: number | null; + detail: string; + observed: { + [k: string]: JsonValue; + }; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotRow". + */ +export interface RobotRow { + domain: 'personal'; + name: string; + type: 'sparkbot' | 'http_device' | 'robot_dog' | 'agv' | 'mobile_manipulator' | 'other'; + connection_mode: 'metadata' | 'http_status'; + model: string; + location: string; + note: string; + status_url: string; + expected_project: string; + id: string; + revision: number; + status: 'reachable' | 'not_connected' | 'unknown' | 'error'; + observation: { + status: 'reachable' | 'not_connected' | 'unknown' | 'error'; + checked_at: string; + checked_epoch: number; + http_status: number | null; + detail: string; + observed: { + [k: string]: JsonValue; + }; + } | null; + stale: boolean; + deleted_at: string | null; + updated_at: string; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotDraft". + */ +export interface RobotDraft { + domain: 'personal'; + confirm_personal: boolean; + name: string; + type: 'sparkbot' | 'http_device' | 'robot_dog' | 'agv' | 'mobile_manipulator' | 'other'; + connection_mode?: 'metadata' | 'http_status'; + model?: string; + location?: string; + note?: string; + status_url?: string; + expected_project?: string; +} /** * This interface was referenced by `RavenRpcRoot`'s JSON-Schema * via the `definition` "SessionListParams". @@ -8532,6 +8593,79 @@ export interface ImportedUploadResult { */ imported_at: string; } +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotsListParams". + */ +export interface RobotsListParams { + trash?: boolean; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotsListResult". + */ +export interface RobotsListResult { + devices: RobotRow[]; + status_ttl_seconds: number; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotsSaveParams". + */ +export interface RobotsSaveParams { + device: RobotDraft; + id?: string | null; + revision?: number | null; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotsSaveResult". + */ +export interface RobotsSaveResult { + device: RobotRow; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotsRemoveParams". + */ +export interface RobotsRemoveParams { + id: string; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotsRemoveResult". + */ +export interface RobotsRemoveResult { + ok: boolean; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotsRestoreParams". + */ +export interface RobotsRestoreParams { + id: string; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotsRestoreResult". + */ +export interface RobotsRestoreResult { + device: RobotRow; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotsCheckParams". + */ +export interface RobotsCheckParams { + id: string; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "RobotsCheckResult". + */ +export interface RobotsCheckResult { + device: RobotRow; +} // ---- Schema-name aliases for structurally-deduplicated types ---- export type BrowserManageResult = StubResult; diff --git a/ui-web/CONTEXT.md b/ui-web/CONTEXT.md index b28ccfebb..eeea7b99a 100644 --- a/ui-web/CONTEXT.md +++ b/ui-web/CONTEXT.md @@ -1,3 +1,12 @@ +# Personal robots in the Connections hub + +The Robots tab is an `extAgents` island module reading the `robots` source seam. +Its records have no execution node and are not server registrations. The form +handles personal metadata and optional explicit health checks; it does not +offer robot control. Its local clock expires displayed observations without +polling the physical device. The runtime contract and storage boundary are +described in `../docs/robot-registration.md`. + # Web UI The browser front-end (`ui-web/`, React + TypeScript, one page). Renders the chat diff --git a/ui-web/scripts/__golden__/boot-live-noserver.txt b/ui-web/scripts/__golden__/boot-live-noserver.txt index 9c3924a30..820e40296 100644 --- a/ui-web/scripts/__golden__/boot-live-noserver.txt +++ b/ui-web/scripts/__golden__/boot-live-noserver.txt @@ -227,6 +227,7 @@ section#connectionsPage.page[data-open=false] button.hub-head-tab button.hub-head-tab button.hub-head-tab + button.hub-head-tab p.hub-head-sub div.su-tabs button.su-tab diff --git a/ui-web/scripts/__golden__/boot-stub.txt b/ui-web/scripts/__golden__/boot-stub.txt index 9d5372908..44ccb8e9a 100644 --- a/ui-web/scripts/__golden__/boot-stub.txt +++ b/ui-web/scripts/__golden__/boot-stub.txt @@ -229,6 +229,7 @@ section#connectionsPage.page[data-open=false] button.hub-head-tab button.hub-head-tab button.hub-head-tab + button.hub-head-tab p.hub-head-sub div.su-tabs button.su-tab diff --git a/ui-web/scripts/fixture-params.mjs b/ui-web/scripts/fixture-params.mjs index 74b359cef..8e7dace39 100644 --- a/ui-web/scripts/fixture-params.mjs +++ b/ui-web/scripts/fixture-params.mjs @@ -9,6 +9,12 @@ /** One call per method. Anything not named here is called with `{}`. */ export const PARAMS = { + 'robots.list': { trash: false }, + 'robots.save': { device: { domain: 'personal', confirm_personal: true, name: 'Fixture robot', type: 'sparkbot', connection_mode: 'http_status', status_url: 'http://10.0.0.2:8080/health' } }, + 'robots.remove': { id: '00000000000000000000000000000001' }, + 'robots.restore': { id: '00000000000000000000000000000001' }, + 'robots.check': { id: '00000000000000000000000000000001' }, + 'system.hello': { client_version: '0.1.0', surface: 'page' }, 'config.get': { keys: ['language', 'permissions.mode'] }, 'config.set': { key: 'language', value: 'en' }, diff --git a/ui-web/src/app/install.ts b/ui-web/src/app/install.ts index 14e1e42b5..bc565fd08 100644 --- a/ui-web/src/app/install.ts +++ b/ui-web/src/app/install.ts @@ -20,7 +20,7 @@ import { cronSource } from '../features/cron/source' import { openDeskTask } from '../features/desk/store' import { exportSource } from '../features/export/source' import { AgentsStepBody } from '../features/extAgents/AgentsBody' -import { extAgentsSource, machinesSource } from '../features/extAgents/source' +import { extAgentsSource, machinesSource, robotsSource } from '../features/extAgents/source' import * as extAgentsStore from '../features/extAgents/store' import { importedSource } from '../features/imported/source' import { importSyncSource } from '../features/importSync/source' @@ -250,6 +250,7 @@ export function installSources(): void { } sources.extAgents = extAgentsSource sources.machines = machinesSource + sources.robots = robotsSource sources.knowledge = knowledgeSource /* How a citation in the transcript is followed. Registered by the island that can do it rather than imported by the one that wants it: two domains diff --git a/ui-web/src/components/HubHead.test.tsx b/ui-web/src/components/HubHead.test.tsx index 8273f90ff..38875dd51 100644 --- a/ui-web/src/components/HubHead.test.tsx +++ b/ui-web/src/components/HubHead.test.tsx @@ -18,10 +18,10 @@ afterEach(() => { const tabs = (): HTMLButtonElement[] => [...document.querySelectorAll('.hub-head-tab')] describe('the hub header', () => { - it('names the three modules and marks the one on screen', () => { + it('names the four modules and marks the one on screen', () => { render() - expect(tabs().map((b) => b.textContent)).toEqual(['gui.hub.agents', 'gui.hub.channels', 'gui.hub.machines']) - expect(tabs().map((b) => b.getAttribute('aria-selected'))).toEqual(['false', 'true', 'false']) + expect(tabs().map((b) => b.textContent)).toEqual(['gui.hub.agents', 'gui.hub.channels', 'gui.hub.machines', 'gui.hub.robots']) + expect(tabs().map((b) => b.getAttribute('aria-selected'))).toEqual(['false', 'true', 'false', 'false']) }) /* The line under the tabs belongs to the module on screen, not the hub. */ @@ -45,5 +45,7 @@ describe('the hub header', () => { expect(open).toHaveBeenCalledWith('channels') act(() => tabs()[2]!.click()) expect(open).toHaveBeenCalledWith('machines') + act(() => tabs()[3]!.click()) + expect(open).toHaveBeenCalledWith('robots') }) }) diff --git a/ui-web/src/components/HubHead.tsx b/ui-web/src/components/HubHead.tsx index 00f1772f8..8f6385042 100644 --- a/ui-web/src/components/HubHead.tsx +++ b/ui-web/src/components/HubHead.tsx @@ -1,7 +1,7 @@ /* The header the hub's modules share: the module tabs, and one line under them on what the module on screen is for. - Agents, channels and machines are one place on the rail (state/hub.ts), and + Agents, channels, servers and robot registrations are one place on the rail (state/hub.ts), and the rail row already names it, so the page carries no title of its own: the tabs are the top of the page. Underlined rather than filled, so they read as the page's parts and the filter pills a module draws under them stay the @@ -17,7 +17,8 @@ import type { JSX } from 'react' const MODULES: ReadonlyArray<{ which: HubModule; key: string; sub: string }> = [ { which: 'agents', key: 'gui.hub.agents', sub: 'gui.page.agents_sub' }, { which: 'channels', key: 'gui.hub.channels', sub: 'gui.page.conn_sub' }, - { which: 'machines', key: 'gui.hub.machines', sub: 'gui.page.machines_sub' } + { which: 'machines', key: 'gui.hub.machines', sub: 'gui.page.machines_sub' }, + { which: 'robots', key: 'gui.hub.robots', sub: 'gui.page.robots_sub' } ] export function HubHead({ current }: { current: HubModule }): JSX.Element { diff --git a/ui-web/src/features/connections/__snapshots__/ConnectionsPage.test.tsx.snap b/ui-web/src/features/connections/__snapshots__/ConnectionsPage.test.tsx.snap index 21e56921b..a07055929 100644 --- a/ui-web/src/features/connections/__snapshots__/ConnectionsPage.test.tsx.snap +++ b/ui-web/src/features/connections/__snapshots__/ConnectionsPage.test.tsx.snap @@ -5,6 +5,7 @@ exports[`connections island > keeps its rendered shape, list 1`] = ` button.hub-head-tab button.hub-head-tab button.hub-head-tab + button.hub-head-tab p.hub-head-sub div.su-tabs button.su-tab diff --git a/ui-web/src/features/extAgents/ExtAgentsPage.tsx b/ui-web/src/features/extAgents/ExtAgentsPage.tsx index 1e77b6545..59bb2421f 100644 --- a/ui-web/src/features/extAgents/ExtAgentsPage.tsx +++ b/ui-web/src/features/extAgents/ExtAgentsPage.tsx @@ -15,6 +15,7 @@ import { byOf, familyOf, installOf, isOwnRow } from './catalogue' import { badOf, healthOf, kindOf, ledClass, pendingLabel, refusedWrite, shownOf } from './health' import { MachineConnectSheet, MachineSheet, MachinesGrid, RegisterSheet, WhereField } from './MachineCards' import * as machines from './machines' +import { Robots } from './Robots' import { CardGrid, Spin, Tile, WaitGrid, connect, oneLine, ordered, refusedLabel, whyOf } from './Rows' import { gridEntries, hasEntry, sectionOf, stageOf } from './source' import * as store from './store' @@ -972,6 +973,7 @@ export function ExtAgentsApp(): JSX.Element { {s.picking ? : null} ) + if (s.module === 'robots') return <> if (s.module === 'machines') { /* Where the agents may run besides this computer: the registered machines, their links and nodes. The hub's own tab, beside the agents diff --git a/ui-web/src/features/extAgents/Robots.test.tsx b/ui-web/src/features/extAgents/Robots.test.tsx new file mode 100644 index 000000000..bc3e8ef92 --- /dev/null +++ b/ui-web/src/features/extAgents/Robots.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment happy-dom +/* Native-page robot registration against the typed offline RPC transport. */ +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { setTranslator } from '../../i18n/t' +import { demoFixtures } from '../../rpc/fixtures' +import { FixtureTransport } from '../../rpc/fixtureTransport' +import { setGateway } from '../../rpc/gateway' +import { resetSources, setSources } from '../../state/sources' +import { mountPageRoot } from '../../test/pageRoot' +import { ExtAgentsApp } from './ExtAgentsPage' +import * as machines from './machines' +import { robotStatus } from './Robots' +import { extAgentsSource, machinesSource, robotsSource } from './source' +import * as store from './store' + +import type { RobotRow } from './types' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +let unmountPage: () => void +let transport: FixtureTransport + +beforeEach(() => { + setTranslator((key) => key) + transport = new FixtureTransport(demoFixtures) + setGateway(transport) + setSources({ robots: robotsSource, machines: machinesSource, extAgents: extAgentsSource }) + unmountPage = mountPageRoot() +}) + +afterEach(() => { + cleanup() + act(() => unmountPage()) + store._resetForTests() + machines._resetForTests() + resetSources() + setGateway(null) +}) + +async function open(): Promise { + render(, { container: document.getElementById('extAgentsBody')! }) + await act(async () => store.openRobots()) +} + +async function click(name: string): Promise { + await act(async () => fireEvent.click(screen.getByRole('button', { name }))) +} + +describe('personal robot registration', () => { + it('registers, edits, removes, and restores through the native source', async () => { + await open() + await click('gui.extAgents.robot_add') + const form = screen.getByRole('form', { name: 'gui.extAgents.robot_add' }) + fireEvent.change(within(form).getByLabelText('gui.extAgents.robot_name'), { target: { value: 'Fixture device' } }) + fireEvent.click(within(form).getByLabelText('gui.extAgents.robot_personal_confirm')) + await act(async () => fireEvent.submit(form)) + expect(screen.getByRole('article', { name: 'Fixture device' })).toBeTruthy() + expect(screen.queryByRole('button', { name: 'gui.extAgents.robot_check' })).toBeNull() + await click('gui.extAgents.robot_edit') + const edit = screen.getByRole('form', { name: 'gui.extAgents.robot_edit' }) + fireEvent.change(within(edit).getByLabelText('gui.extAgents.robot_name'), { target: { value: 'Edited fixture device' } }) + await act(async () => fireEvent.submit(edit)) + expect(screen.getByRole('article', { name: 'Edited fixture device' })).toBeTruthy() + await click('gui.extAgents.robot_remove') + expect(screen.queryByRole('article')).toBeNull() + await click('gui.extAgents.robot_trash') + await click('gui.extAgents.robot_restore') + await click('gui.extAgents.robot_back') + expect(screen.getByRole('article', { name: 'Edited fixture device' })).toBeTruthy() + expect(transport.calls.filter((call) => call.method === 'robots.check')).toHaveLength(0) + expect((await robotsSource.list(false))).toHaveLength(1) + }) + + it('blocks company registration and limits non-HTTP robots to metadata', async () => { + await open() + await click('gui.extAgents.robot_add') + fireEvent.change(screen.getByLabelText('gui.extAgents.robot_domain'), { target: { value: 'company' } }) + expect(screen.getByRole('button', { name: 'gui.save' }).disabled).toBe(true) + expect(screen.queryByLabelText('gui.extAgents.robot_name')).toBeNull() + expect(screen.getByRole('alert').textContent).toBe('gui.extAgents.robot_company_blocked') + fireEvent.change(screen.getByLabelText('gui.extAgents.robot_domain'), { target: { value: 'personal' } }) + fireEvent.change(screen.getByLabelText('gui.extAgents.robot_type'), { target: { value: 'agv' } }) + expect(screen.getByRole('option', { name: 'gui.extAgents.robot_http' }).disabled).toBe(true) + expect(screen.queryByLabelText('gui.extAgents.robot_status_url')).toBeNull() + expect(transport.calls.filter((call) => call.method === 'robots.save')).toHaveLength(0) + }) + + it('checks HTTP status only on demand and clears the endpoint when changed to metadata', async () => { + await robotsSource.save({ domain: 'personal', confirm_personal: true, name: 'HTTP fixture', type: 'sparkbot', connection_mode: 'http_status', status_url: 'http://10.0.0.2:8080/health' }) + await open() + expect(screen.getByRole('article', { name: 'HTTP fixture' }).textContent).toContain('gui.extAgents.robot_disconnected') + expect(transport.calls.filter((call) => call.method === 'robots.check')).toHaveLength(0) + await click('gui.extAgents.robot_check') + expect(transport.calls.filter((call) => call.method === 'robots.check')).toHaveLength(1) + expect(screen.getByRole('article', { name: 'HTTP fixture' }).textContent).toContain('Offline fixtures never contact physical devices.') + await click('gui.extAgents.robot_edit') + expect(screen.getByRole('button', { name: 'gui.extAgents.robot_trash' }).disabled).toBe(true) + expect(screen.getByRole('button', { name: 'gui.extAgents.robot_check' }).disabled).toBe(true) + fireEvent.change(screen.getByLabelText('gui.extAgents.robot_type'), { target: { value: 'agv' } }) + expect(screen.queryByLabelText('gui.extAgents.robot_status_url')).toBeNull() + const form = screen.getByRole('form', { name: 'gui.extAgents.robot_edit' }) + await act(async () => fireEvent.submit(form)) + const record = (await robotsSource.list(false))[0]! + expect(record.connection_mode).toBe('metadata') + expect(record.status_url).toBe('') + expect(record.observation).toBeNull() + expect(screen.queryByRole('button', { name: 'gui.extAgents.robot_check' })).toBeNull() + }) + + it('degrades to an explicit unavailable state on an older gateway', async () => { + setGateway(new FixtureTransport({})) + await open() + expect(screen.getByRole('status').textContent).toBe('gui.extAgents.robot_unavailable') + expect(screen.queryByRole('article')).toBeNull() + }) + + it('expires successful observations without another network check', () => { + const row: RobotRow = { + id: 'a'.repeat(32), revision: 1, domain: 'personal', name: 'Fixture', type: 'sparkbot', connection_mode: 'http_status', + model: '', location: '', note: '', status_url: '', expected_project: '', updated_at: '', deleted_at: null, stale: false, status: 'reachable', + observation: { status: 'reachable', checked_epoch: 100, checked_at: '', http_status: 200, detail: '', observed: {} }, + } + expect(robotStatus(row, 110_000)).toBe('reachable') + expect(robotStatus(row, 161_000)).toBe('unknown') + expect(robotStatus(row, 94_000)).toBe('unknown') + }) +}) diff --git a/ui-web/src/features/extAgents/Robots.tsx b/ui-web/src/features/extAgents/Robots.tsx new file mode 100644 index 000000000..950a1e39e --- /dev/null +++ b/ui-web/src/features/extAgents/Robots.tsx @@ -0,0 +1,191 @@ +/* Personal robot records and explicit health checks, without an execution node. */ +import { useEffect, useRef, useState } from 'react' + +import { t } from '../../i18n/t' +import { ds } from '../../state/sources' + +import type { RobotDraft, RobotRow } from './types' +import type { JSX } from 'react' + +const TYPE_KEYS = { + sparkbot: 'gui.extAgents.robot_sparkbot', + http_device: 'gui.extAgents.robot_http_device', + robot_dog: 'gui.extAgents.robot_dog', + agv: 'gui.extAgents.robot_agv', + mobile_manipulator: 'gui.extAgents.robot_mobile_manipulator', + other: 'gui.extAgents.robot_other', +} satisfies Record + +const STATUS_KEYS = { + reachable: 'gui.extAgents.robot_reachable', + not_connected: 'gui.extAgents.robot_disconnected', + unknown: 'gui.extAgents.robot_unknown', + error: 'gui.extAgents.robot_error', +} + +export function robotStatus(row: RobotRow, now: number): RobotRow['status'] { + const age = row.observation ? now / 1000 - row.observation.checked_epoch : null + return age !== null && (age > 60 || age < -5) ? 'unknown' : row.status +} + +function RobotForm({ row, saved, close }: { row: RobotRow | null; saved: () => Promise; close: () => void }): JSX.Element { + const form = useRef(null) + const [kind, setKind] = useState(row?.type ?? 'sparkbot') + const [mode, setMode] = useState(row?.connection_mode ?? 'metadata') + const [company, setCompany] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const httpAllowed = kind === 'sparkbot' || kind === 'http_device' + const http = httpAllowed && mode === 'http_status' + return ( +
{ + event.preventDefault() + if (company || busy || !form.current) return + const data = new FormData(form.current) + const text = (key: string): string => String(data.get(key) ?? '') + const device: RobotDraft = { + domain: 'personal', confirm_personal: data.get('confirm_personal') === 'on', + name: text('name'), type: kind, connection_mode: http ? 'http_status' : 'metadata', + model: text('model'), location: text('location'), note: text('note'), + status_url: http ? text('status_url') : '', expected_project: http ? text('expected_project') : '', + } + setBusy(true) + setError('') + void ds('robots').save(device, row?.id, row?.revision).then(saved).catch(() => { + setError(t('gui.extAgents.robot_save_failed')) + }).finally(() => setBusy(false)) + }} + ref={form} + > +

{t(row ? 'gui.extAgents.robot_edit' : 'gui.extAgents.robot_add')}

+ + {company ?

{t('gui.extAgents.robot_company_blocked')}

: ( +
+
+ + + + +
+ + {!httpAllowed ?

{t('gui.extAgents.robot_metadata_only')}

: null} + {http ?
+ + +

{t('gui.extAgents.robot_url_hint')}

+ +
: null} +