From 84bd88b11cab6ff35f96b29d2f386de8c4107d60 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Sat, 10 Oct 2026 20:23:23 -0700 Subject: [PATCH 01/14] docs(style): agent means the AI agent, and status copy writes from the reader's seat The operator ruled (2026-10-10) that 'agent' confuses users when it names the node's background program: the daemon is the daemon (or node daemon), and a space-tight column header reads Version. STYLE.md gains the Daemon/Agent glossary rows, the ban on 'node agent' and an 'Agent' column for the daemon, and a new Status and problem sentences section: name what is true of the reader's machine, then the action that remains; no protocol verbs, no possessive riddles. The worked pair is the held-node line, before and after. .claude/rules/design-system.md carries the two-line pointer so UI agents see the ruling without opening the guide. Co-Authored-By: Claude --- .claude/rules/design-system.md | 2 ++ apps/docs/STYLE.md | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/.claude/rules/design-system.md b/.claude/rules/design-system.md index 106c4223f..b218f0f97 100644 --- a/.claude/rules/design-system.md +++ b/.claude/rules/design-system.md @@ -21,6 +21,8 @@ the system lacks what you need, add the token to every surface in one change. **Every UI explanation is at most two sentences, and UI copy uses no em dashes** (operator ruling 2026-09-21); surplus becomes docs or a disclosure, never help text. A lone em dash in a table or detail cell is an absence placeholder, not copy (the 2026-09-25 shipped-copy sweep kept the placeholders and rewrote only strings carrying words). +**Status copy writes from the reader's seat** (operator ruling 2026-10-10): name what is true of their machine or session, then what they can do; no protocol verbs. "The server will not accept this machine's Subshell daemon version. You can still update it from here.", never "the server refuses its agent" (whose? refuses what?). "**Agent**" means the AI agent a pane drives, never the node daemon: the daemon is the **daemon** (or **node daemon**), and a space-tight column header reads **Version**. Full rule and worked example: `apps/docs/STYLE.md`. + Line items are `label` over `detail`, differing by weight and colour. **Every explanation a control gives about itself is `detail`** (its help text, a "set by the environment" note, a saved-vs-running line, a validation error), diff --git a/apps/docs/STYLE.md b/apps/docs/STYLE.md index 71bcbf40f..544bebc67 100644 --- a/apps/docs/STYLE.md +++ b/apps/docs/STYLE.md @@ -16,10 +16,19 @@ Use these terms consistently: | Term | Meaning | | --- | --- | | Server | The control plane, including its API, database, and web interface. | -| Node | A machine running agents through the `subshell` daemon. | +| Node | A machine enrolled on the server, kept connected by the daemon. | +| Daemon (node daemon) | The `subshell` background program that keeps a node connected and runs its subshells. | | Client | A person's interface to a server. | | Subshell | An agent or shell session, including its stored identity and history. | | Pane | The terminal view or process context when that distinction matters. | +| Agent | The AI coding agent a subshell pane drives. Never the node daemon. | + +The word *agent* means the AI agent inside a subshell, and nothing else. The +node's background program is the **daemon** (or **node daemon** when other +daemons are nearby); a version display may say **version** where the full word +does not fit. Do not write "node agent", "the agent on that machine", or an +"Agent" column header for the daemon: readers meet AI agents everywhere in +Subshell, and the double meaning is a defect, not a shorthand. Use **Subshell Server** and **Subshell Client** for the desktop app names. Use lowercase server, node, client, and subshell for the general concepts. @@ -101,6 +110,28 @@ internal shorthand such as “admin boost,” “scope ceiling,” “wiring,” “foreign row” in user guides. Technical references may use precise terms when they define them or describe an actual schema. +## Status and problem sentences + +Write a status or problem sentence from the reader's seat: name what is true of +their machine or session, then what they can do about it. Do not describe what +the protocol layer or the server's internals did. + +- Write the outcome, not the mechanism. Internal verbs (refuse, reject, + handshake, redial, hold, boost) belong in code and engineering notes. +- Name the thing instead of leaning on a pronoun: “this machine's Subshell + daemon version,” not “its agent.” A reader should never have to solve whose + “it” is before the sentence means anything. +- A negative status ends on the action that remains, stated plainly. + +- Good: “The server will not accept this machine's Subshell daemon version. + You can still update it from here.” +- Avoid: “This node is connected, but the server refuses its agent. Update is + the one command this connection still accepts.” The first version says what + is wrong and what works; the second makes the reader decode a possessive, a + protocol verb, and a punning “command.” + +These rules govern dashboard strings, CLI and installer output, and docs alike. + ## Page structure and instructions Give each page one task or one clearly bounded subject. Overview pages help From c530c640b6861d61c6a0add6179ea1622af5b053 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Sat, 10 Oct 2026 20:23:26 -0700 Subject: [PATCH 02/14] fix(copy): the node daemon is the daemon, everywhere a person reads it The word 'agent' now means only the AI agent a pane drives (ruling 2026-10-10, doctrine committed last commit). This applies it: - SPA: the Nodes table's Agent column becomes Version (the detail page already said Node version); the update card says the daemon restarts itself, and the held-node line becomes the operator-approved sentence 'The server will not accept this machine's Subshell daemon version. You can still update it from here.' replacing the refuse-its-agent original. - Node CLI, setup questions, probe warnings, update refusals, the 4406 exit line, maintenance/service/reset/env-forced messages: daemon, with the reader-seat rewrite where the old text described what the protocol layer did. 'KICKS' prose becomes 'disconnects'. - The daemon's own log file becomes logs/daemon.log. No migration: the plane's agent_log_read, the loopback dashboard, status --json, reset, and the desktop app's reveal-a-log ladder all fall back to agent.log until the daemon has written under the new name; tests pin the ladder on both the node and desktop side. Wire field names (agentLog, agentLogPath, agentVersion, kind 'agent') are identifiers and stay. - Desktop: app descriptions, the downgrade refusal, and the no-log hint. - docs: paths.mdx and security.md name the daemon log (and the quoted Nodes-dialog string is corrected to the label the dialog actually shows); node agent in-repo docs follow the file rename. Kept per the ruling: 'launchd agent' (Apple's term for server supervision) and every AI-meaning agent (Add an Agent, the harness card, Send prompt to agent?). Full suites green: node 905, server 3953, rust fmt/clippy/test, design tokens, prose. Co-Authored-By: Claude --- .changeset/agent-log-becomes-daemon-log.md | 6 +++ .changeset/agent-means-the-ai-agent.md | 8 +++ .claude/rules/security-context.md | 4 +- apps/client/desktop/package.json | 2 +- apps/client/desktop/src-tauri/src/control.rs | 53 +++++++++++++------ apps/client/desktop/src-tauri/src/reset.rs | 8 +-- apps/client/desktop/src-tauri/tauri.conf.json | 2 +- .../ui/src/__tests__/status-screen.test.tsx | 2 +- apps/docs/content/docs/reference/paths.mdx | 2 +- apps/node/agent/AGENTS.md | 2 +- apps/node/agent/docs/logging.md | 2 +- .../src/__tests__/commands-agent-log.test.ts | 13 ++++- apps/node/agent/src/__tests__/daemon.test.ts | 4 +- apps/node/agent/src/__tests__/enroll.test.ts | 10 ++-- .../node/agent/src/__tests__/log-file.test.ts | 35 ++++++++++-- .../agent/src/__tests__/reset-cli.test.ts | 6 +-- apps/node/agent/src/__tests__/service.test.ts | 2 +- apps/node/agent/src/cli.ts | 18 +++---- apps/node/agent/src/commands/agent-log.ts | 6 +-- apps/node/agent/src/commands/set-log-level.ts | 2 +- apps/node/agent/src/config.ts | 2 +- apps/node/agent/src/daemon.ts | 6 +-- apps/node/agent/src/dashboard/routes.ts | 4 +- apps/node/agent/src/debug-logging.ts | 2 +- apps/node/agent/src/log-file.ts | 30 +++++++++-- apps/node/agent/src/log.ts | 4 +- apps/node/agent/src/maintenance-cli.ts | 2 +- apps/node/agent/src/reset-cli.ts | 14 ++--- apps/node/agent/src/retention-settings.ts | 2 +- apps/node/agent/src/runtime.ts | 6 +-- apps/node/agent/src/service.ts | 2 +- apps/node/agent/src/setup.ts | 4 +- apps/node/agent/src/update.ts | 14 ++--- .../__tests__/log-retention-card.test.tsx | 2 +- .../nodes/__tests__/node-table.test.tsx | 2 +- .../nodes/__tests__/node-update-card.test.tsx | 2 +- .../web/src/components/nodes/node-table.tsx | 2 +- .../src/components/nodes/node-update-card.tsx | 5 +- .../routes/__tests__/nodes-detail.test.tsx | 4 +- docs/security.md | 22 ++++---- 40 files changed, 209 insertions(+), 109 deletions(-) create mode 100644 .changeset/agent-log-becomes-daemon-log.md create mode 100644 .changeset/agent-means-the-ai-agent.md diff --git a/.changeset/agent-log-becomes-daemon-log.md b/.changeset/agent-log-becomes-daemon-log.md new file mode 100644 index 000000000..8103ed5f6 --- /dev/null +++ b/.changeset/agent-log-becomes-daemon-log.md @@ -0,0 +1,6 @@ +--- +"@internal/node": patch +"@internal/desktop-client": patch +--- + +The node's own log file is `logs/daemon.log` where it was `logs/agent.log`, the name matching the word. Nothing migrates the file: every reader (the plane's log view, the machine's loopback dashboard, `subshell status`, reset, the desktop app) falls back to the old name until the daemon has written the new one, so an upgraded node shows its history from the first poll rather than after its first line. diff --git a/.changeset/agent-means-the-ai-agent.md b/.changeset/agent-means-the-ai-agent.md new file mode 100644 index 000000000..f67ff5729 --- /dev/null +++ b/.changeset/agent-means-the-ai-agent.md @@ -0,0 +1,8 @@ +--- +"@internal/server-web": patch +"@internal/node": patch +"@internal/desktop-client": patch +"@internal/server": patch +--- + +The word "agent" now means the AI agent a subshell pane drives, and nothing else. The node's background program is the **daemon** (or node daemon), everywhere a person reads about it: the Nodes table's `Agent` column is **Version**, the update card says the daemon restarts itself, a refused version reads "The server will not accept this machine's Subshell daemon version. You can still update it from here.", `subshell setup` asks "Run the Subshell daemon in the background and start it at login?", and the daemon's own refusal lines, probe warnings, update errors and the desktop app's descriptions say daemon too. Status sentences were rewritten from the reader's seat: what is true of their machine, then what they can still do, with no protocol verbs. The rule and its worked example live in `apps/docs/STYLE.md`. diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md index e42ddec21..2e7f5236a 100644 --- a/.claude/rules/security-context.md +++ b/.claude/rules/security-context.md @@ -94,14 +94,14 @@ Node mechanics depth (link close codes and pin lifecycle, §6; setup-key revocat - **Maintenance is a routing preference, never a quarantine:** enrolled, answers everything, takes no new subshells; turning it ON **terminates every subshell running there**, whoever owns them; retroactive by design, and affected owners learn by push. Owner-only (admin on `local`): the re-share gate `canManage`, NOT `nodeCanConfigure` and NOT the wider `canRetire` gate delete/re-register sit on; the machine's own `subshell maintenance on|off` sets the same flag; newer stamp wins, ties to the plane, so the node's mirror check is defence in depth, not a second switch. Containment is deleting the node, rotating its key, or clearing shares, all cookie-gated. Audited. - **Service control** (signed command; cookie only, owner or `edit`, `local` refused, audited `{verb, forced}`): the agent refuses unless the manager reports this very pid, and when the definition would take its panes down without `force`; **`stop`/`uninstall` are owner-only and structurally one-way** (they end the socket that would carry the undo); `force` is refused outright on `start`/`install`. - **Repointing** (signed `set_server_url`) is **owner-only and a real widening**: the machine dials the named host with the node key, valid on THIS plane; loopback refused outright; addresses validated by component, stored canonicalized; audited naming the NEW value only; takes effect next start. -- **Disclosure surfaces:** the runtime report (supervision, paths, tmux) attaches only for an ONLINE agent node, viewer owner or `edit`, never `local`, never `view`. `ready` discloses host facts (agentVersion, os/arch, hostname, dataDir, capabilities, selfInvoke prefix, homeDir); `detect` answers ONLY the binaries/env names the plane asked (union of `subshell.hostEnv` across enabled manifests), never scans, so a plugin install (admin) decides what env names every node may be asked. `held` is disclosed to every viewer; `runtime` is not. A node view carries `ownerLabel` (whose machine) ONLY to a cookie-admin, the `?all=1` setup-key precedent (ruling 2026-10-10); every other viewer's payload lacks the field. Agent log over HTTP (owner/`edit`) never carries pane content or argv. `PUT /api/nodes/:id/logging` (an `edit` act, cookie only, `local` refused) is refused while `SUBSHELL_DEBUG_LOGGING` forces it on: a write the next read would mask is not a success. +- **Disclosure surfaces:** the runtime report (supervision, paths, tmux) attaches only for an ONLINE agent node, viewer owner or `edit`, never `local`, never `view`. `ready` discloses host facts (agentVersion, os/arch, hostname, dataDir, capabilities, selfInvoke prefix, homeDir); `detect` answers ONLY the binaries/env names the plane asked (union of `subshell.hostEnv` across enabled manifests), never scans, so a plugin install (admin) decides what env names every node may be asked. `held` is disclosed to every viewer; `runtime` is not. A node view carries `ownerLabel` (whose machine) ONLY to a cookie-admin, the `?all=1` setup-key precedent (ruling 2026-10-10); every other viewer's payload lacks the field. Daemon log over HTTP (owner/`edit`) never carries pane content or argv. `PUT /api/nodes/:id/logging` (an `edit` act, cookie only, `local` refused) is refused while `SUBSHELL_DEBUG_LOGGING` forces it on: a write the next read would mask is not a success. - **Refused agents are HELD, not closed** (§11.12): offline for every purpose but `update` (frames dropped after envelope parse, before dispatch); the `update` command's wire shape is FROZEN across protocol bumps for this. - **Re-registration keys**: cookie retire-gated (owner or any admin, `canRetire`) on agent nodes; the mint is stored under the node's owner so redemption's ownership check holds; `POST /api/nodes/:id/reregister` mints a single-use 24 h setup key bound to the existing row. Redemption through enroll preserves node relationships and settings, atomically swaps credentials and public identities, revokes the old bearer and unused sibling recovery keys, and evicts live/held links. Minting disconnects nothing. Deleting the target cascades recovery keys. Recovery adds no row, so `allow_node_enrollment` gates only new-node keys. Audits `node.reregister_key|reregister` carry ids, never secrets. - **Setup keys**: single-use, 24 h, plaintext-stored to the key's OWNER only (the future node owner; for an admin-minted recovery key that is the node's owner, not the admin who triggered the mint) (cookie-only; a machine token 403s the list); `setup_key.create` carries no metadata: the key text never enters the trail. Admin may revoke ANY row (`setup_key.revoke`, `{foreign: true}` metadata), `?all=1` is admin-only (deliberate 403, never a silently-narrowed list). The node names itself (`--name`, `SUBSHELL_NODE_NAME`; `enroll` requires it) via `normalizeNodeName`, the same rule rename applies; a RECOVERY redemption validates the supplied name but does not apply it, the existing row keeps its own. Minting is gated by `allow_node_enrollment` (absent = TRUE; enforced at the mint ONLY (the key IS the credential); bounds the future, never revokes what is outstanding). `GET /install.sh` renders identically for absent/unknown/spent/expired keys (never a binary oracle); a VALID key bound to an existing node additionally names the node it re-registers and pre-supplies the wizard's name answer (the recovery path ignores any name; the row keeps its own), which discriminates between two LIVE keys only, and either one's holder can already redeem to control the node; enroll answers three distinct 401s by design. The key rides the install URL → shell history and access logs: the accepted posture of enrollment links. - **Agent artifacts are never anonymous**: cookie, or valid unconsumed setup key, or one-time `?update_token=` (unlocks exactly one file at one digest; never reaches the `.sha256` routes). `install.sh`'s `server=` must be exact membership of the live trusted-origin registry; the rendered script digest-verifies before the first `chmod +x`. - **Lazy release fetch**: every release-source fetch passes `releaseFetchAllowed` (configured origin ∪ GitHub release hosts; refused **by name before any connection**, egress destination pinned); no binary bytes until a machine is served (since 2026-10-09 the tiny index/manifest reads behind the serve decision run per authenticated download, TTL-cached with a one-minute failure backoff); hashed against the digest from the **signed manifest's `assets` map** (sidecar never fetched); bounded by `MAX_ARTIFACT_BYTES` (300 MB); only manifests declaring THIS `NODE_PROTOCOL_VERSION` are offered; only files this instance fetched (`.fetched.json`) are ever superseded or deleted; the shelf is reconciled AT SERVE TIME (2026-10-09: the copy is run ` version`, the newest server-supportable build wins, a hand-published copy speaking this protocol with a higher semver stays truth, a wrong-protocol copy 404s instead of installing a never-connectable agent, and `.sha256` announces the SAME decision the installer's skip depends on), an update download is served release-coherently else 409 naming the remedy. Empty `SUBSHELL_RELEASE_URL` = air-gapped (the shelf is the only source and answers for itself the same way: connectable and self-consistent, or refused). - **CLI-side**: `subshell configure --server` is unprivileged, non-destructive, one-plane-two-names. It does disclose the node key to the host named (treat it as naming a host you trust). `configure --key` refuses an `nsk_` setup key by name. Re-enrolling in Subshell Client IS confirmation-gated (it overwrites `config.json` and discards the old node key). -- **The node's loopback dashboard** (§6): 127.0.0.1:3090 with **no login by design**: the listen address plus the OS user IS the access control (`Host` loopback; `Origin` when present loopback; mutations need `content-type: application/json`). The multi-user-host gap is ACCEPTED and is a widening (a second local user can maintenance-kill, stop, or repoint); `SUBSHELL_DASHBOARD=0` is the remedy. No audit rows here, the agent log is the record; `maintenance on` here kills panes, node-CLI semantics verbatim. +- **The node's loopback dashboard** (§6): 127.0.0.1:3090 with **no login by design**: the listen address plus the OS user IS the access control (`Host` loopback; `Origin` when present loopback; mutations need `content-type: application/json`). The multi-user-host gap is ACCEPTED and is a widening (a second local user can maintenance-kill, stop, or repoint); `SUBSHELL_DASHBOARD=0` is the remedy. No audit rows here, the daemon log is the record; `maintenance on` here kills panes, node-CLI semantics verbatim. ## Plugins & installers Plugin shape and runtime facts (loaded from disk, `PluginHost`-only, JSON identity in `package.json`, static built-ins + the one sanctioned `await import()` in `plugin-runtime.ts`, plane-built argv) live in **`AGENTS.md` ("Plugins are packages…")**; that section is the source. The security half: diff --git a/apps/client/desktop/package.json b/apps/client/desktop/package.json index 80a3dd8f2..5244dc011 100644 --- a/apps/client/desktop/package.json +++ b/apps/client/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@internal/desktop-client", - "description": "Subshell Client \u2014 a Tauri shell that registers this machine as a subshell node and manages its agent", + "description": "Subshell Client \u2014 a Tauri shell that registers this machine as a subshell node and manages its node daemon", "version": "1.2.6", "license": "Apache-2.0", "type": "module", diff --git a/apps/client/desktop/src-tauri/src/control.rs b/apps/client/desktop/src-tauri/src/control.rs index f04cf8275..968f61172 100644 --- a/apps/client/desktop/src-tauri/src/control.rs +++ b/apps/client/desktop/src-tauri/src/control.rs @@ -310,7 +310,7 @@ fn existing_node() -> ExistingNode { /// **The agent's OWN file comes first, on every platform**, which is the same /// order `apps/server/desktop`'s `desktop_logs` reads the server's in and for /// the same reason: since 2026-09-12 the agent writes one capped JSON-lines -/// file (`/logs/agent.log`, 0600, 200 KB, replaced when full) and +/// file (`/logs/daemon.log`, 0600, 200 KB, replaced when full) and /// that is the file the plane's node log view serves. A person revealing the /// log here and a person reading it in a browser must not be looking at two /// different documents. @@ -345,14 +345,17 @@ fn node_log_paths_from( home: Option, has_content: impl Fn(&Path) -> bool, ) -> (Option, Option) { - let own = config.map(|c| c.join("logs").join("agent.log")); + let own = config.as_ref().map(|c| c.join("logs").join("daemon.log")); + // The file's name before the 2026-10 rename; a node that has not written + // under the new name yet still has its real history here. + let own_legacy = config.map(|c| c.join("logs").join("agent.log")); // `filter` rather than an inner `if` on both rungs, so every branch stays // compiled on both platforms — which is what makes a clippy run on either // of them mean something. let managed = home .filter(|_| cfg!(target_os = "macos")) .map(|h| h.join("Library").join("Logs").join("subshell.log")); - for candidate in [own, managed].into_iter().flatten() { + for candidate in [own, own_legacy, managed].into_iter().flatten() { if has_content(&candidate) { return (Some(candidate.to_string_lossy().into_owned()), None); } @@ -367,9 +370,9 @@ fn node_log_paths_from( /// the fallback each has: macOS keeps a second file the plist names, Linux has /// the journal and will never grow a file at all. const NO_LOG_FILE: &str = if cfg!(target_os = "macos") { - "the node has not written a log yet: it appears at ~/.config/subshell/logs/agent.log once the node runs, and the service manager keeps its own copy at ~/Library/Logs/subshell.log" + "the node has not written a log yet: it appears at ~/.config/subshell/logs/daemon.log once the node runs, and the service manager keeps its own copy at ~/Library/Logs/subshell.log" } else { - "the node has not written a log yet: it appears at ~/.config/subshell/logs/agent.log once the node runs; the service manager's own copy is the journal (`journalctl --user -u subshell.service -f`)" + "the node has not written a log yet: it appears at ~/.config/subshell/logs/daemon.log once the node runs; the service manager's own copy is the journal (`journalctl --user -u subshell.service -f`)" }; /// The paths the window may name, and the ones it may ask to reveal. @@ -863,10 +866,10 @@ pub fn install_refusal(bundled: Option<&str>, installed: Option<&str>) -> Option } let (bundled, installed) = (bundled?, installed?); Some(format!( - "the agent already installed on this machine ({installed}) is newer than the one this app ships \ - ({bundled}), so installing would downgrade it: an agent older than the control plane expects enrolls, \ - comes up online and then refuses every launch. Nothing was changed, and the installed agent is the one \ - this app drives." + "the Subshell daemon already installed on this machine ({installed}) is newer than the one this app \ + ships ({bundled}), so installing would downgrade it: a daemon older than the control plane expects \ + enrolls, comes up online and then runs nothing you launch. Nothing was changed, and the installed \ + daemon is the one this app drives." )) } @@ -1890,7 +1893,7 @@ pub enum OpenTarget { ConfigDir, /// The identity/state directory this node was enrolled with. DataDir, - /// The agent's own log file, where the platform has one. + /// The daemon's own log file, where the platform has one. NodeLog, } @@ -3395,7 +3398,9 @@ mod path_tests { assert_ne!(path.is_some(), hint.is_some()); match (path, hint) { (Some(p), None) => assert!( - p.ends_with("/logs/agent.log") || p.ends_with("/Library/Logs/subshell.log"), + p.ends_with("/logs/daemon.log") + || p.ends_with("/logs/agent.log") + || p.ends_with("/Library/Logs/subshell.log"), "{p}" ), (None, Some(h)) => assert!(!h.is_empty()), @@ -3408,7 +3413,7 @@ mod path_tests { // desktop and the browser on two different documents. #[test] fn the_nodes_own_capped_log_wins_over_the_managers_copy() { - let own = PathBuf::from("/home/u/.config/subshell/logs/agent.log"); + let own = PathBuf::from("/home/u/.config/subshell/logs/daemon.log"); let (path, hint) = node_log_paths_from( Some(PathBuf::from("/home/u/.config/subshell")), Some(PathBuf::from("/home/u")), @@ -3422,11 +3427,11 @@ mod path_tests { // the stdout of an agent that died before opening its own file. #[test] fn the_managers_log_is_the_fallback_where_the_platform_keeps_one() { - let own = PathBuf::from("/home/u/.config/subshell/logs/agent.log"); let (path, hint) = node_log_paths_from( Some(PathBuf::from("/home/u/.config/subshell")), Some(PathBuf::from("/home/u")), - |p| p != own, // the agent has written nothing of its own + // the daemon has written nothing of its own, under either name + |p| !(p.ends_with("logs/daemon.log") || p.ends_with("logs/agent.log")), ); if cfg!(target_os = "macos") { assert_eq!(path.as_deref(), Some("/home/u/Library/Logs/subshell.log")); @@ -3438,6 +3443,24 @@ mod path_tests { } } + // The file's name was `agent.log` before the 2026-10 copy ruling. A node + // that has not restarted onto the new spelling yet still has its real + // history under the old name, and the desktop must reveal THAT rather + // than fall through to the manager's copy. + #[test] + fn the_pre_rename_log_wins_when_the_new_name_has_nothing_yet() { + let (path, hint) = node_log_paths_from( + Some(PathBuf::from("/home/u/.config/subshell")), + Some(PathBuf::from("/home/u")), + |p| p.ends_with("logs/agent.log"), // only the old name has content + ); + assert_eq!( + path.as_deref(), + Some("/home/u/.config/subshell/logs/agent.log") + ); + assert!(hint.is_none()); + } + // An empty file is not a log: the capped writer truncates to zero and // starts over, and the manager's copy may still hold what explains the // silence. Same rule as the server app's `server_log_tail`. @@ -3460,7 +3483,7 @@ mod path_tests { #[test] fn each_platform_is_told_the_right_reason_for_having_no_log_file() { assert!( - NO_LOG_FILE.contains("~/.config/subshell/logs/agent.log"), + NO_LOG_FILE.contains("~/.config/subshell/logs/daemon.log"), "{NO_LOG_FILE}" ); if cfg!(target_os = "macos") { diff --git a/apps/client/desktop/src-tauri/src/reset.rs b/apps/client/desktop/src-tauri/src/reset.rs index 139105bc9..48afbf07f 100644 --- a/apps/client/desktop/src-tauri/src/reset.rs +++ b/apps/client/desktop/src-tauri/src/reset.rs @@ -457,21 +457,21 @@ mod tests { } // The CLI's block carries more than the three: `binary` (the installed - // CLI) and `agentLog` (the agent's own capped log). Both are reported so a + // CLI) and `agentLog` (the daemon's own capped log). Both are reported so a // caller can NAME them; neither is deleted. A reset that swept the block // would take out the log that records the reset, and on a machine where - // the binary sat in the data dir, the agent itself. + // the binary sat in the data dir, the daemon itself. #[test] fn keys_beyond_the_three_are_reported_but_never_deleted() { let mut v = full(); v["paths"]["binary"] = json!("/home/u/.local/bin/subshell"); - v["paths"]["agentLog"] = json!("/home/u/.config/subshell/logs/agent.log"); + v["paths"]["agentLog"] = json!("/home/u/.config/subshell/logs/daemon.log"); let plan = parse_delete_plan(&v).expect("extra keys do not refuse the plan"); assert_eq!(plan, parse_delete_plan(&full()).unwrap()); let order = deletion_order(&plan); assert_eq!(order.len(), 3); for path in &order { - assert!(!path.ends_with("logs/agent.log"), "the agent log is not deleted"); + assert!(!path.ends_with("logs/daemon.log"), "the daemon's own log is not deleted"); assert!(!path.ends_with("bin/subshell"), "the installed CLI is not deleted"); } } diff --git a/apps/client/desktop/src-tauri/tauri.conf.json b/apps/client/desktop/src-tauri/tauri.conf.json index 2d944c186..f4f0a3654 100644 --- a/apps/client/desktop/src-tauri/tauri.conf.json +++ b/apps/client/desktop/src-tauri/tauri.conf.json @@ -34,7 +34,7 @@ ], "category": "DeveloperTool", "shortDescription": "Register this machine as a Subshell node", - "longDescription": "Subshell Client registers this machine with a Subshell control plane and keeps its agent running, so agents can be launched here from the browser.", + "longDescription": "Subshell Client registers this machine with a Subshell control plane and keeps its node daemon running, so agents can be launched here from the browser.", "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/apps/client/desktop/ui/src/__tests__/status-screen.test.tsx b/apps/client/desktop/ui/src/__tests__/status-screen.test.tsx index 6fe62b689..d55444191 100644 --- a/apps/client/desktop/ui/src/__tests__/status-screen.test.tsx +++ b/apps/client/desktop/ui/src/__tests__/status-screen.test.tsx @@ -133,7 +133,7 @@ describe("an enrolled, online machine", () => { configDir: "/home/u/.config/subshell", configFile: "/home/u/.config/subshell/config.json", dataDir: "/home/u/.config/subshell/data", - nodeLog: "/home/u/.local/state/subshell/agent.log", + nodeLog: "/home/u/.local/state/subshell/daemon.log", nodeLogHint: null, }, }), diff --git a/apps/docs/content/docs/reference/paths.mdx b/apps/docs/content/docs/reference/paths.mdx index 5a18cbdbf..ce10a641f 100644 --- a/apps/docs/content/docs/reference/paths.mdx +++ b/apps/docs/content/docs/reference/paths.mdx @@ -40,7 +40,7 @@ For Docker, edit the container command or Compose environment and recreate the c ## Node files -The node reports its configuration file, daemon lock, data directory, binary, and agent log. Configuration contains the node credential and trusted control-plane key. +The node reports its configuration file, daemon lock, data directory, binary, and daemon log. Configuration contains the node credential and trusted control-plane key. `unenroll` removes configuration and lock but retains other data. Reset has a broader deletion plan. Do not treat every path in status output as a deletion target. diff --git a/apps/node/agent/AGENTS.md b/apps/node/agent/AGENTS.md index 6efd2525f..ee5b69d64 100644 --- a/apps/node/agent/AGENTS.md +++ b/apps/node/agent/AGENTS.md @@ -98,7 +98,7 @@ deletion targets. **Working on a verb or its flags: read `src/log.ts` is the node's only log surface: LogLayer with the core `ConsoleTransport` plus `CappedFileTransport`, writing -`/logs/agent.log` (JSON lines, 0600, capped at 200 KB and +`/logs/daemon.log` (JSON lines, 0600, capped at 200 KB and REPLACED when full): the one bounded file served to the plane by `agent_log_read`, a deliberate COPY of the server's AGPL `utils/log-file.ts` rather than an import. Debug level is a live switch (`debug-logging.ts`, diff --git a/apps/node/agent/docs/logging.md b/apps/node/agent/docs/logging.md index 1c6ec1772..3f21922d1 100644 --- a/apps/node/agent/docs/logging.md +++ b/apps/node/agent/docs/logging.md @@ -18,7 +18,7 @@ nowhere in particular, which is why `collectRuntime` reports a `logHint` telling a person to go run `journalctl`. Most nodes are headless, so "read this machine's log" has to work from a browser, and it cannot be built on an artifact that only exists on macOS. So the node writes one bounded file of its -own: `/logs/agent.log`, JSON lines, 0600, capped at 200 KB and +own: `/logs/daemon.log`, JSON lines, 0600, capped at 200 KB and REPLACED when full, the same everywhere, served to the plane by `agent_log_read` and reported as `runtime.agentLogPath`. It is a deliberate COPY of the server's `utils/log-file.ts` rather than an import: that file is diff --git a/apps/node/agent/src/__tests__/commands-agent-log.test.ts b/apps/node/agent/src/__tests__/commands-agent-log.test.ts index 79a2a274b..c8c232861 100644 --- a/apps/node/agent/src/__tests__/commands-agent-log.test.ts +++ b/apps/node/agent/src/__tests__/commands-agent-log.test.ts @@ -4,12 +4,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { CommandContext } from "../commands/context.js"; import { dispatchCommand } from "../commands/index.js"; -import { AGENT_LOG_CAP_BYTES, agentLogPath } from "../log-file.js"; +import { AGENT_LOG_CAP_BYTES, daemonLogPath, legacyAgentLogPath } from "../log-file.js"; /** Point the config home at a throwaway dir and write a log file inside it. */ function withLog(text: string): string { process.env.SUBSHELL_CONFIG_HOME = mkdtempSync(join(tmpdir(), "subshell-agentlog-")); - const path = agentLogPath(); + const path = daemonLogPath(); mkdirSync(join(path, ".."), { recursive: true }); writeFileSync(path, text); return path; @@ -50,6 +50,15 @@ describe("agent_log_read", () => { expect(res).toEqual({ ok: true, data: { text: "", nextByte: 0, size: 0, truncated: false } }); }); + it("reads the pre-rename agent.log until the daemon has written the new name", async () => { + process.env.SUBSHELL_CONFIG_HOME = mkdtempSync(join(tmpdir(), "subshell-agentlog-")); + const legacy = legacyAgentLogPath(); + mkdirSync(join(legacy, ".."), { recursive: true }); + writeFileSync(legacy, "old history"); + const res = await dispatchCommand(ctx(), { type: "agent_log_read", fromByte: 0, maxBytes: 100 }); + expect(res).toEqual({ ok: true, data: { text: "old history", nextByte: 11, size: 11, truncated: false } }); + }); + // The caller's cap is not the only one. A frame carrying more than the file // can hold would be a frame built from a number the plane chose. it("clamps the caller's maxBytes to the file's own cap", async () => { diff --git a/apps/node/agent/src/__tests__/daemon.test.ts b/apps/node/agent/src/__tests__/daemon.test.ts index a3911a48f..1147176e1 100644 --- a/apps/node/agent/src/__tests__/daemon.test.ts +++ b/apps/node/agent/src/__tests__/daemon.test.ts @@ -1266,7 +1266,7 @@ test("status --probe: explicit opt-in dials the plane with a loud stderr warning const r = await runCli(["status", "--probe"]); expect(r.code).toBe(0); expect(r.out).toInclude("ONLINE"); - expect(r.err).toInclude("KICKS"); // the loud warning ships with every --probe invocation + expect(r.err).toInclude("disconnects any daemon"); // the warning ships with every --probe invocation expect(h.plane.opens).toBe(opensBefore + 1); // the probe DID dial (opt-in destructiveness) const body = JSON.parse((await runCli(["status", "--probe", "--json"])).out) as Record; expect(body.online).toBe(true); @@ -1285,7 +1285,7 @@ test("status --probe: explicit opt-in dials the plane with a loud stderr warning // Backticked, because a bare "subshell run" also matches the line's own // "no local subshell running" and would compare the wrong position. expect(off.out.indexOf("service start")).toBeLessThan(off.out.indexOf("`subshell run`")); - expect(off.out).toInclude("a probe KICKS a remote agent"); // the warning is NOT dropped + expect(off.out).toInclude("a probe disconnects a daemon running elsewhere"); // the warning is NOT dropped // Probe against a dead port → refusal is a clean offline, still labelled. await saveConfig({ ...h.config, serverUrl: "http://localhost:1" }); const dead = await runCli(["status", "--probe", "--json"]); diff --git a/apps/node/agent/src/__tests__/enroll.test.ts b/apps/node/agent/src/__tests__/enroll.test.ts index 79f961aab..fe416f0d9 100644 --- a/apps/node/agent/src/__tests__/enroll.test.ts +++ b/apps/node/agent/src/__tests__/enroll.test.ts @@ -9,7 +9,7 @@ import { type CliResult, run } from "../cli.js"; import { configPath, loadConfig, saveConfig } from "../config.js"; import { mapOs } from "../enroll.js"; import { lockPath } from "../lock.js"; -import { agentLogPath } from "../log-file.js"; +import { readableDaemonLogPath } from "../log-file.js"; import { DEFAULT_DEPS } from "../service.js"; import { newHome } from "../test-preload.js"; import { NODE_VERSION } from "../version.js"; @@ -567,13 +567,13 @@ test("status --json names the paths a reset would delete, even while offline", a lockFile: lockPath(), dataDir: cfg.dataDir, binary: null, - agentLog: agentLogPath(), + agentLog: readableDaemonLogPath(), }); - expect(isAbsolute(agentLogPath())).toBe(true); + expect(isAbsolute(readableDaemonLogPath())).toBe(true); // The one file the plane serves is the one this names: `agent_log_read` - // reads `agentLogPath()` too, so a second spelling here would send a person + // reads `readableDaemonLogPath()` too, so a second spelling here would send a person // to a file nobody writes. - expect(agentLogPath().startsWith(home)).toBe(true); + expect(readableDaemonLogPath().startsWith(home)).toBe(true); expect(parsed.binarySource).toBe(null); const paths = parsed.paths as Record; expect(isAbsolute(paths.configFile as string)).toBe(true); diff --git a/apps/node/agent/src/__tests__/log-file.test.ts b/apps/node/agent/src/__tests__/log-file.test.ts index ded23c0b4..7ad909cf0 100644 --- a/apps/node/agent/src/__tests__/log-file.test.ts +++ b/apps/node/agent/src/__tests__/log-file.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it } from "bun:test"; -import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { AGENT_LOG_CAP_BYTES, CappedFileTransport, readNodeLogSlice } from "../log-file.js"; +import { + AGENT_LOG_CAP_BYTES, + CappedFileTransport, + daemonLogPath, + readableDaemonLogPath, + readNodeLogSlice, +} from "../log-file.js"; /** A throwaway directory per case, so nothing here shares a file. */ function dir(): string { @@ -19,7 +25,7 @@ function ship(transport: CappedFileTransport, message: string): void { } as never); } -describe("the agent's own log file", () => { +describe("the daemon's own log file", () => { it("writes JSON lines the plane's parser can read", () => { const path = join(dir(), "agent.log"); const t = new CappedFileTransport(path, AGENT_LOG_CAP_BYTES); @@ -98,3 +104,26 @@ describe("reading a slice of it", () => { expect(slice.text).toBe(""); }); }); + +// The 2026-10 rename made the file `daemon.log`; nothing WRITES the old name, +// but a node that has not restarted since the upgrade must still show its +// history, which is what this reader-side ladder pins. +describe("readableDaemonLogPath", () => { + it("prefers daemon.log, falls back to the pre-rename agent.log, and never errors on neither", () => { + const home = mkdtempSync(join(tmpdir(), "subshell-daemonlog-")); + process.env.SUBSHELL_CONFIG_HOME = home; + try { + // No file at all: the answer is the current name (an empty read, not a + // missing-file error). + expect(readableDaemonLogPath()).toBe(daemonLogPath()); + mkdirSync(join(home, "logs"), { recursive: true }); + writeFileSync(join(home, "logs", "agent.log"), "pre-rename history"); + expect(readableDaemonLogPath()).toBe(join(home, "logs", "agent.log")); + // The daemon's first line onto the new name wins immediately. + writeFileSync(join(home, "logs", "daemon.log"), "new"); + expect(readableDaemonLogPath()).toBe(join(home, "logs", "daemon.log")); + } finally { + delete process.env.SUBSHELL_CONFIG_HOME; + } + }); +}); diff --git a/apps/node/agent/src/__tests__/reset-cli.test.ts b/apps/node/agent/src/__tests__/reset-cli.test.ts index e0f3454e3..c65e239a3 100644 --- a/apps/node/agent/src/__tests__/reset-cli.test.ts +++ b/apps/node/agent/src/__tests__/reset-cli.test.ts @@ -231,7 +231,7 @@ describe("the chain (clear-everything after consent)", () => { expect(existsSync(fx.plan.configJson)).toBe(false); expect(existsSync(fx.binary)).toBe(true); expect(r.out).toContain("subshell setup"); - expect(r.out).toContain("agent's own log"); // the record that STAYS is named + expect(r.out).toContain("daemon's own log"); // the record that STAYS is named }); test("a machine with no service definition walks past the absent stop and remove", async () => { @@ -421,14 +421,14 @@ describe("the uninstall data question", () => { const r = await runNodeReset({ uninstall: false }, fx.deps); expect(r.code).toBe(0); expect(r.out).toContain("went with the data directory"); - expect(r.out).not.toContain("left the agent's own log at"); + expect(r.out).not.toContain("left the daemon's own log at"); }); test("a node that never wrote a log is not CLAIMED to have kept one, nor to have lost one", async () => { rmSync(join(fx.root, "config-home", "logs"), { recursive: true, force: true }); const r = await runNodeReset({ uninstall: false }, fx.deps); expect(r.code).toBe(0); - expect(r.out).not.toContain("agent's own log"); + expect(r.out).not.toContain("daemon's own log"); }); test("reset never asks: clearing the data IS reset", async () => { diff --git a/apps/node/agent/src/__tests__/service.test.ts b/apps/node/agent/src/__tests__/service.test.ts index 81aa954a0..296f28c78 100644 --- a/apps/node/agent/src/__tests__/service.test.ts +++ b/apps/node/agent/src/__tests__/service.test.ts @@ -597,7 +597,7 @@ describe("uninstallService — guards", () => { ["systemctl", "--user", "daemon-reload"], ]); expect(s.removed).toEqual([UNIT]); - expect(res.out).toInclude("(no agent config found, nothing else to clean up)"); + expect(res.out).toInclude("(no daemon config found, nothing else to clean up)"); }); test("unsupported platform: exit 1, no removals", async () => { diff --git a/apps/node/agent/src/cli.ts b/apps/node/agent/src/cli.ts index f76734164..402af26e3 100644 --- a/apps/node/agent/src/cli.ts +++ b/apps/node/agent/src/cli.ts @@ -8,7 +8,7 @@ import { startNodeDashboard } from "./dashboard/server.js"; import { runEnroll } from "./enroll.js"; import { clearLock, isPidAlive, lockPath, readLock } from "./lock.js"; import { logger } from "./log.js"; -import { agentLogPath } from "./log-file.js"; +import { readableDaemonLogPath } from "./log-file.js"; import { tightenServiceLogMode } from "./log-hygiene.js"; import { defaultMaintenanceDeps, @@ -99,7 +99,7 @@ usage: subshell dashboard [--dashboard-port ] the loopback dashboard WITHOUT the daemon: reads and maintenance work, restart/update say "not supervised". - For a stopped or broken agent: the machine's page + For a stopped or broken daemon: the machine's page should not need the machine's plane socket subshell service install [--no-autostart] (systemd user unit / launchd agent) --no-autostart runs it now but not at login @@ -972,7 +972,7 @@ export async function run(argv: string[], deps: RunDeps = {}): Promise): Promise { const maxBytes = Math.min(cmd.maxBytes, AGENT_LOG_CAP_BYTES); - const slice = await readNodeLogSlice(agentLogPath(), cmd.fromByte, maxBytes); + const slice = await readNodeLogSlice(readableDaemonLogPath(), cmd.fromByte, maxBytes); return { ok: true, data: { ...slice } }; } diff --git a/apps/node/agent/src/commands/set-log-level.ts b/apps/node/agent/src/commands/set-log-level.ts index 179872248..13fa51d49 100644 --- a/apps/node/agent/src/commands/set-log-level.ts +++ b/apps/node/agent/src/commands/set-log-level.ts @@ -3,7 +3,7 @@ import { log } from "../log.js"; import type { Cmd, CommandContext, CommandResult } from "./context.js"; /** - * `set_log_level`: turn debug-level lines in this agent's own log file on or off. + * `set_log_level`: turn debug-level lines in the daemon's own log file on or off. * * Live — it flips the file transport's level, no restart — and persisted in * `config.json`, because the sessions worth debugging are the ones that end in diff --git a/apps/node/agent/src/config.ts b/apps/node/agent/src/config.ts index f707cf819..8afcef523 100644 --- a/apps/node/agent/src/config.ts +++ b/apps/node/agent/src/config.ts @@ -56,7 +56,7 @@ export interface NodeConfig { */ nodeWsUrl?: string; /** - * Whether debug-level lines reach the agent's own log file. + * Whether debug-level lines reach the daemon's own log file. * * Absent means off, which is also what an older config means. Persisted * rather than per-process because the sessions worth debugging end in a diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index 9cb136e71..f02054405 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -326,8 +326,8 @@ const HAS_MCP = true; */ export function updateRequiredMessage(reason: string | undefined): string { return reason - ? `the control plane refused this agent (close 4406): ${reason}; exiting` - : `the control plane refused this agent (close 4406); a newer subshell is required; exiting`; + ? `the server will not accept this machine's Subshell daemon version (close 4406): ${reason}; exiting` + : `the server will not accept this machine's Subshell daemon version (close 4406); a newer subshell is required; exiting`; } function readyEvent( @@ -1110,7 +1110,7 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom if (shuttingDown) stop(0); // graceful: the socket closed cleanly on our request if (close.code === NODE_CLOSE_SUPERSEDED) { log( - `another subshell is already registered as node '${config.nodeId}' (close 4409); exiting, stop the duplicate agent first`, + `another subshell is already registered as node '${config.nodeId}' (close 4409); exiting, stop the duplicate daemon first`, ); stop(1); } diff --git a/apps/node/agent/src/dashboard/routes.ts b/apps/node/agent/src/dashboard/routes.ts index e972549c6..0619ca0f7 100644 --- a/apps/node/agent/src/dashboard/routes.ts +++ b/apps/node/agent/src/dashboard/routes.ts @@ -15,7 +15,7 @@ import type { NodeConfig } from "../config.js"; import { runConfigure } from "../configure.js"; import { currentDebugLogging, setDebugLogging } from "../debug-logging.js"; import { log } from "../log.js"; -import { AGENT_LOG_CAP_BYTES, agentLogPath, readNodeLogSlice } from "../log-file.js"; +import { AGENT_LOG_CAP_BYTES, readableDaemonLogPath, readNodeLogSlice } from "../log-file.js"; import { writeMaintenance } from "../maintenance.js"; import { defaultMaintenanceDeps, runMaintenance } from "../maintenance-cli.js"; import { readRetentionState, setLogRetention, sweepIsScheduled } from "../retention-settings.js"; @@ -331,7 +331,7 @@ export function buildRoutes( // No cap on `fromByte` at the boundary: a cursor past EOF reads as // empty, and `readNodeLogSlice` clamps a negative one — the file's // own reader is the size authority. - return Response.json(await readNodeLogSlice(agentLogPath(), fromByte, maxBytes)); + return Response.json(await readNodeLogSlice(readableDaemonLogPath(), fromByte, maxBytes)); }, { query: t.Object({ diff --git a/apps/node/agent/src/debug-logging.ts b/apps/node/agent/src/debug-logging.ts index 319064f06..a8875f470 100644 --- a/apps/node/agent/src/debug-logging.ts +++ b/apps/node/agent/src/debug-logging.ts @@ -95,7 +95,7 @@ export async function loadAndApplyDebugLogging(): Promise { export async function setDebugLogging(debug: boolean): Promise { if (currentDebugLogging().source === "process env") { throw new Error( - "SUBSHELL_DEBUG_LOGGING is set in this agent's environment, so debug logging cannot be changed here", + "SUBSHELL_DEBUG_LOGGING is set in the daemon's environment, so debug logging cannot be changed here", ); } // `updateConfig`, not a held snapshot: a retention save landing between this diff --git a/apps/node/agent/src/log-file.ts b/apps/node/agent/src/log-file.ts index f77e894f6..f26f2b3d9 100644 --- a/apps/node/agent/src/log-file.ts +++ b/apps/node/agent/src/log-file.ts @@ -5,7 +5,7 @@ import { BlankTransport, type LogLayerTransportParams, type LogLevelType } from import { clientHome } from "./config.js"; /** - * The agent's own log file: ONE file, JSON lines, capped and REPLACED when + * The daemon's own log file: ONE file, JSON lines, capped and REPLACED when * full (spec 2026-09-12, node half § 4). * * **Why this exists at all.** The agent logs to the console, and what happens @@ -32,18 +32,42 @@ import { clientHome } from "./config.js"; export const AGENT_LOG_CAP_BYTES = 204_800; /** - * `/logs/agent.log` — one file, every platform. + * `/logs/daemon.log` — one file, every platform. * * Under the CONFIG home rather than the configured `dataDir`, because logging * starts before `config.json` is read and has to work on a machine that has * never been enrolled: a daemon that cannot say why it failed to load its * config is the one you most need the log for. The directory is the one * `saveConfig` already keeps at 0700. + * + * The file was `agent.log` until the 2026-10 copy ruling made *agent* mean the + * AI agent a pane drives and nothing else. The name changed with the word; + * readers fall back to {@link legacyAgentLogPath} so a daemon's history + * survives its first restart onto the new spelling without a rename step. */ -export function agentLogPath(): string { +export function daemonLogPath(): string { + return join(clientHome(), "logs", "daemon.log"); +} + +/** The daemon's log file name before the 2026-10 rename. No daemon WRITES it anymore. */ +export function legacyAgentLogPath(): string { return join(clientHome(), "logs", "agent.log"); } +/** + * Which log file to READ: the current one once it exists, else the pre-rename + * file, else the current path (an empty result, never a missing-file error). + * + * Every reader — the plane's `agent_log_read` command, the loopback dashboard, + * `status --json` — takes this instead of {@link daemonLogPath}, so an upgraded + * node shows its history from the first poll rather than after its first line. + */ +export function readableDaemonLogPath(): string { + const current = daemonLogPath(); + if (!existsSync(current) && existsSync(legacyAgentLogPath())) return legacyAgentLogPath(); + return current; +} + /** * Render one log call as a single JSON line. * diff --git a/apps/node/agent/src/log.ts b/apps/node/agent/src/log.ts index 830d8a014..34712cedf 100644 --- a/apps/node/agent/src/log.ts +++ b/apps/node/agent/src/log.ts @@ -1,5 +1,5 @@ import { ConsoleTransport, type ILogLayer, LogLayer } from "loglayer"; -import { AGENT_LOG_CAP_BYTES, agentLogPath, CappedFileTransport } from "./log-file.js"; +import { AGENT_LOG_CAP_BYTES, CappedFileTransport, daemonLogPath } from "./log-file.js"; /** * Builds the agent's log transport. @@ -49,7 +49,7 @@ export function createLogTransport(sink: typeof console = console): ConsoleTrans * deliberately not reachable that way: what the service manager collects stays * at `info` whatever the switch says. */ -export const agentLogFile: CappedFileTransport = new CappedFileTransport(agentLogPath(), AGENT_LOG_CAP_BYTES); +export const agentLogFile: CappedFileTransport = new CappedFileTransport(daemonLogPath(), AGENT_LOG_CAP_BYTES); export const logger: ILogLayer = new LogLayer({ // TWO transports, and both are load-bearing. The console is what a person diff --git a/apps/node/agent/src/maintenance-cli.ts b/apps/node/agent/src/maintenance-cli.ts index 252370c4f..b7ffc9059 100644 --- a/apps/node/agent/src/maintenance-cli.ts +++ b/apps/node/agent/src/maintenance-cli.ts @@ -56,7 +56,7 @@ export function defaultMaintenanceDeps(dataDir: string): MaintenanceDeps { * which may be never, and is still the correct thing to have written down. */ const LEARNS_LINE = - "the control plane learns of this within about 15 seconds while the agent runs, or when it next connects\n"; + "the control plane learns of this within about 15 seconds while the daemon runs, or when it next connects\n"; /** * Runs one `maintenance` invocation. diff --git a/apps/node/agent/src/reset-cli.ts b/apps/node/agent/src/reset-cli.ts index 23eb23cf1..55cd4d770 100644 --- a/apps/node/agent/src/reset-cli.ts +++ b/apps/node/agent/src/reset-cli.ts @@ -5,7 +5,7 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import type { CliResult } from "./cli.js"; import { clientHome, configPath } from "./config.js"; import { isPidAlive, lockPath, readLock } from "./lock.js"; -import { agentLogPath } from "./log-file.js"; +import { readableDaemonLogPath } from "./log-file.js"; import { deleteGuardOk, pathRulesOk } from "./reset-guards.js"; import { controlService, queryService, type ServiceDeps, uninstallService } from "./service.js"; import { resolveNodeBinaryPath, UpdateRefused } from "./update.js"; @@ -61,7 +61,7 @@ export interface NodeResetPlan { binary: string | null; /** Why `binary` is null; the sentence the uninstall prints instead of deleting. */ binaryReason: string | null; - /** The agent's own log: named in the success output as the thing that STAYS. */ + /** The daemon's own log: named in the success output as the thing that STAYS. */ agentLog: string; } @@ -134,7 +134,7 @@ export async function buildNodeResetPlan(): Promise { dataDir: resolve(dataDir), binary, binaryReason, - agentLog: agentLogPath(), + agentLog: readableDaemonLogPath(), }; } @@ -496,9 +496,9 @@ export async function runNodeReset( // the config home itself, and then the walk legitimately took the log's // directory with it; a node that never wrote a log has none to report on. if (existsSync(plan.agentLog)) { - out.push(`left the agent's own log at ${plan.agentLog}: the record of this act, and no credential in it`); + out.push(`left the daemon's own log at ${plan.agentLog}: the record of this act, and no credential in it`); } else if (logWasHere) { - out.push("the agent's own log went with the data directory it lived in"); + out.push("the daemon's own log went with the data directory it lived in"); } } else { out.push("left the data, key and settings in place; `subshell reset` deletes them"); @@ -529,7 +529,9 @@ export async function runNodeReset( } } } else { - out.push(plan.binaryReason ?? "no installed binary: this agent runs from a source checkout, which is left alone"); + out.push( + plan.binaryReason ?? "no installed binary: this daemon runs from a source checkout, which is left alone", + ); } } diff --git a/apps/node/agent/src/retention-settings.ts b/apps/node/agent/src/retention-settings.ts index 246a5a1e5..4c75c5cb8 100644 --- a/apps/node/agent/src/retention-settings.ts +++ b/apps/node/agent/src/retention-settings.ts @@ -122,7 +122,7 @@ export async function setLogRetention( return { ok: false, status: 409, - message: `${ENV_NAME[f]} is set in this agent's environment, so the retention ${f} cannot be changed here`, + message: `${ENV_NAME[f]} is set in the daemon's environment, so the retention ${f} cannot be changed here`, }; } } diff --git a/apps/node/agent/src/runtime.ts b/apps/node/agent/src/runtime.ts index 35da45f5e..903aec430 100644 --- a/apps/node/agent/src/runtime.ts +++ b/apps/node/agent/src/runtime.ts @@ -8,7 +8,7 @@ import type { NodeRuntimeReport } from "@internal/subshell-protocol"; import { configPath } from "./config.js"; import { currentDebugLogging } from "./debug-logging.js"; -import { agentLogPath } from "./log-file.js"; +import { readableDaemonLogPath } from "./log-file.js"; import { selfInvokePrefix } from "./self-invoke.js"; import { AGENT_LOG_HINT, DEFAULT_DEPS, queryService, type ServiceState } from "./service.js"; @@ -24,7 +24,7 @@ export interface RuntimeDeps { which?: (name: string) => string | null; /** The agent config file (default `configPath()`). */ configPath?: string; - /** The agent's own log file (default `agentLogPath()`). */ + /** The daemon's own log file (default `readableDaemonLogPath()`). */ agentLogPath?: string; /** The debug-logging state (default the live one). */ debugLogging?: { debug: boolean; source: "process env" | "setting" | "default" }; @@ -79,7 +79,7 @@ export async function collectRuntime(deps: RuntimeDeps = {}): Promise { // The note rides every success line when the config is gone; error paths // keep their message about the actual failure. - const noConfigNote = (await deps.hasConfig()) ? "" : "(no agent config found, nothing else to clean up)\n"; + const noConfigNote = (await deps.hasConfig()) ? "" : "(no daemon config found, nothing else to clean up)\n"; if (deps.platform === "linux") { const path = unitPath(deps.home); diff --git a/apps/node/agent/src/setup.ts b/apps/node/agent/src/setup.ts index cad645d07..111518336 100644 --- a/apps/node/agent/src/setup.ts +++ b/apps/node/agent/src/setup.ts @@ -83,7 +83,7 @@ export function nodeNameProblem(raw: string): string | undefined { export const NAME_QUESTION = "Name this node"; /** The second question. Default yes — backgrounded is what an operator came for. */ -export const SERVICE_QUESTION = "Run the agent in the background and start it at login?"; +export const SERVICE_QUESTION = "Run the Subshell daemon in the background and start it at login?"; /** The command that does later what the question offers now — named on every path that skips it. */ const SERVICE_HINT = "subshell service install"; @@ -289,7 +289,7 @@ export async function runSetup(opts: SetupOptions, deps: SetupDeps): Promise 1 && looksLikeEntryScript(argv[1] as string)) { throw new UpdateRefused( NODE_RESULT_NOT_COMPILED, - "the installed service runs this agent from a source checkout, not a compiled binary; update the checkout instead", + "the installed service runs this daemon from a source checkout, not a compiled binary; update the checkout instead", ); } binary = argv[0] as string; @@ -347,7 +347,7 @@ export async function resolveNodeBinaryPath( if (prefix.args.length > 0) { throw new UpdateRefused( NODE_RESULT_NOT_COMPILED, - "this agent is running from a source checkout, not a compiled binary; update the checkout instead", + "this daemon is running from a source checkout, not a compiled binary; update the checkout instead", ); } binary = prefix.command; @@ -363,7 +363,7 @@ export async function resolveNodeBinaryPath( * `?update_token=nut_…` into the URL it sends (spec §5.3), and every refusal * below names the URL it was working on — which is right, since "which address * could not be reached" is the whole diagnosis. But those sentences travel: - * the executor logs one into `/logs/agent.log`, which any node + * the executor logs one into `/logs/daemon.log`, which any node * owner or `edit` grantee reads over HTTP, and a token in a log file outlives * the ten minutes that were supposed to bound it. * @@ -525,7 +525,7 @@ async function verifySignedManifest( if (source.manifest === null) { throw new UpdateRefused( NODE_RESULT_MANIFEST_UNVERIFIED, - "the update carried no signed release manifest, so this agent will not install bytes it cannot tie to the publisher", + "the update carried no signed release manifest, so the daemon will not install bytes it cannot tie to the publisher", ); } const target = hostReleaseTarget(process.platform, process.arch); @@ -867,7 +867,7 @@ export async function revertAfterRefusal( // renames, or a hand that tidied up). There is nothing to put back, so say // so rather than deleting the marker and pretending the update succeeded. log( - `the control plane refused subshell ${pending.to} and there is no ${pending.previousBinary} to restore; reinstall the agent by hand`, + `the control plane refused subshell ${pending.to} and there is no ${pending.previousBinary} to restore; reinstall the daemon by hand`, ); return null; } @@ -970,7 +970,7 @@ export async function rollbackUpdate( if (!(await probe(previous))) { throw new UpdateRefused( NODE_RESULT_NOT_COMPILED, - `${previous} did not answer \`version\`, so it cannot be installed back; the running agent was left in place: install a fresh binary with \`subshell update --from \``, + `${previous} did not answer \`version\`, so it cannot be installed back; the running daemon was left in place: install a fresh binary with \`subshell update --from \``, ); } const marker = diff --git a/apps/node/web/src/components/__tests__/log-retention-card.test.tsx b/apps/node/web/src/components/__tests__/log-retention-card.test.tsx index 101111af7..90af5e111 100644 --- a/apps/node/web/src/components/__tests__/log-retention-card.test.tsx +++ b/apps/node/web/src/components/__tests__/log-retention-card.test.tsx @@ -155,7 +155,7 @@ describe("LogRetentionCard", () => { }); test("a refusal prints the sentence the machine answered with", async () => { - stub(STORED, { status: 409, message: "SUBSHELL_LOG_RETENTION_DAYS is set in this agent's environment" }); + stub(STORED, { status: 409, message: "SUBSHELL_LOG_RETENTION_DAYS is set in the daemon's environment" }); renderCard(); await screen.findByLabelText("Days"); fireEvent.change(screen.getByLabelText("Days"), { target: { value: "9" } }); diff --git a/apps/server/web/src/components/nodes/__tests__/node-table.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-table.test.tsx index aa1f25091..b76e41c75 100644 --- a/apps/server/web/src/components/nodes/__tests__/node-table.test.tsx +++ b/apps/server/web/src/components/nodes/__tests__/node-table.test.tsx @@ -61,7 +61,7 @@ describe("NodeTable", () => { node({ id: "b2", name: "mac mini", os: "linux", arch: "x64", status: "offline" }), ]); // Headers in the operator's order. - for (const h of ["Name", "Host", "Platform", "Status", "Agent", "Seen"]) { + for (const h of ["Name", "Host", "Platform", "Status", "Version", "Seen"]) { expect(screen.getByRole("columnheader", { name: h })).toBeTruthy(); } expect(screen.getByText("AI PC")).toBeTruthy(); diff --git a/apps/server/web/src/components/nodes/__tests__/node-update-card.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-update-card.test.tsx index b7e756c81..f44e3e7ba 100644 --- a/apps/server/web/src/components/nodes/__tests__/node-update-card.test.tsx +++ b/apps/server/web/src/components/nodes/__tests__/node-update-card.test.tsx @@ -223,6 +223,6 @@ describe("NodeUpdateCard", () => { mount(node({ status: "offline", held: { reason: "below-floor", agentVersion: "0.0.1" } })); const btn = screen.getByRole("button", { name: "Update to latest" }) as HTMLButtonElement; expect(btn.disabled).toBe(false); - expect(screen.getByText(/the server refuses its agent/)).toBeTruthy(); + expect(screen.getByText(/will not accept this machine's Subshell daemon version/)).toBeTruthy(); }); }); diff --git a/apps/server/web/src/components/nodes/node-table.tsx b/apps/server/web/src/components/nodes/node-table.tsx index 140d712d3..1cd357944 100644 --- a/apps/server/web/src/components/nodes/node-table.tsx +++ b/apps/server/web/src/components/nodes/node-table.tsx @@ -99,7 +99,7 @@ export function NodeTable({ Host Platform Status - Agent + Version Seen Actions diff --git a/apps/server/web/src/components/nodes/node-update-card.tsx b/apps/server/web/src/components/nodes/node-update-card.tsx index 175183dcf..ac848a369 100644 --- a/apps/server/web/src/components/nodes/node-update-card.tsx +++ b/apps/server/web/src/components/nodes/node-update-card.tsx @@ -85,7 +85,7 @@ export function NodeUpdateCard({ node }: { node: NodeDetail }): JSX.Element { Updates - Replace this machine's Subshell node with the newest release this server can offer. The agent restarts itself + Replace this machine's Subshell node with the newest release this server can offer. The daemon restarts itself into the new binary when the install finishes. @@ -112,8 +112,7 @@ export function NodeUpdateCard({ node }: { node: NodeDetail }): JSX.Element { // the press: the button looks alive on a row labeled offline, and // without this line that reads as a rendering defect.

- This node is connected, but the server refuses its agent. Update is the one command this connection still - accepts. + The server will not accept this machine's Subshell daemon version. You can still update it from here.

)} {accepted && ( diff --git a/apps/server/web/src/routes/__tests__/nodes-detail.test.tsx b/apps/server/web/src/routes/__tests__/nodes-detail.test.tsx index ef90fd4bb..89f0563cb 100644 --- a/apps/server/web/src/routes/__tests__/nodes-detail.test.tsx +++ b/apps/server/web/src/routes/__tests__/nodes-detail.test.tsx @@ -383,7 +383,7 @@ describe("NodeDetailPage admin shape (ruling 2026-10-09)", () => { renderDetail("node1"); const update = await screen.findByRole("button", { name: /Update to latest/ }); expect(update.hasAttribute("disabled")).toBe(false); - expect(screen.getByText(/the server refuses its agent/)).toBeDefined(); + expect(screen.getByText(/will not accept this machine's Subshell daemon version/)).toBeDefined(); // Detection is not `update`: a held link drops it, so Re-check says so // instead of trading a press for a 409. const recheck = screen.getByRole("button", { name: /Re-check/ }); @@ -402,7 +402,7 @@ describe("NodeDetailPage admin shape (ruling 2026-10-09)", () => { const update = await screen.findByRole("button", { name: /Update to latest/ }); expect(update.hasAttribute("disabled")).toBe(true); expect(screen.getByRole("button", { name: /Re-check/ }).hasAttribute("disabled")).toBe(true); - expect(screen.queryByText(/the server refuses its agent/)).toBeNull(); + expect(screen.queryByText(/will not accept this machine's Subshell daemon version/)).toBeNull(); } finally { restore(); } diff --git a/docs/security.md b/docs/security.md index 9c0d565e7..7bdc56e6e 100644 --- a/docs/security.md +++ b/docs/security.md @@ -671,7 +671,7 @@ realpath-resolved form against the root, and a present-but-unresolvable path (a broken symlink) is refused because it cannot be proven confined. `SUBSHELL_FS_ROOT` never confines a **remote node**; that root belongs to this -host and means nothing on another machine. A node's boundary is its agent user's +host and means nothing on another machine. A node's boundary is its daemon user's filesystem permissions, nothing more. **Uploads are working-directory-scoped.** Dropped or pasted files land in @@ -968,8 +968,8 @@ consequences are: respawns it. This adds no trust and no reach: the plane already runs arbitrary commands on that machine under that OS user, and "exit" is the narrowest thing it could be asked to do. The - agent refuses unless the service manager reports this very process (an - unsupervised agent would be stopped rather than restarted, so it will not + daemon refuses unless the service manager reports this very process (an + unsupervised daemon would be stopped rather than restarted, so it will not exit into nothing), and refuses when the definition would take the node's running panes down with it, unless the caller passes `force`. Cookie only, owner or `edit`, `local` refused (the control plane manages itself through @@ -989,14 +989,14 @@ consequences are: unreachable. `force` is refused outright on `start` and `install`, which cannot end a pane: a flag accepted where it does nothing teaches a caller that it is noise. -- **An enrolled node's agent log is readable over HTTP** (2026-09-12). `GET - /api/nodes/:id/logs` serves a byte range of a file the agent now writes +- **An enrolled node's daemon log is readable over HTTP** (2026-09-12). `GET + /api/nodes/:id/logs` serves a byte range of a file the node's daemon writes itself (0600, capped at 200 KB, replaced when full), because its console output goes to a journal under systemd and a file under launchd, and neither is readable from a browser. Most nodes are headless, so this is the only way to read one at all. Same accounting as the server's own log: the set of people who may read it does not change (0600 on disk, owner or `edit` on the - wire); what widens is the set of PLACES they can read it from. An agent logs + wire); what widens is the set of PLACES they can read it from. The daemon logs launch failures, command refusals and connection errors. It does not log pane content, and it does not log argv, which carries a subshell's bearer token, and is the one thing that would turn a log read into a credential read. @@ -1349,7 +1349,7 @@ posture: - **`SUBSHELL_RELEASE_URL` was `SUBSHELL_NODE_RELEASE_URL` until 2026-09-15**, and the rename is not cosmetic: the same list now answers for the server's own `update` and both desktop apps' too (§11.12), so the setting stopped - being the node agent's. There is no alias: this project has no installed + being the node daemon's. There is no alias: this project has no installed base to keep compatible, and an alias is a second thing to read that can disagree with the first. - **Empty disables it**, and that is the supported air-gapped configuration: @@ -1358,7 +1358,7 @@ posture: cannot connect is refused rather than served. Since 2026-09-15 it disables strictly more: `subshell-server update` and `POST /api/nodes/:id/update` refuse with the reason and name `--from` or a - hand install. The Nodes dialog's "no agent binary" warning is kept for + hand install. The Nodes dialog's "no node binary" warning is kept for precisely that case, where it is still exactly true. - **Only a release this plane can TALK TO is offered** (tightened 2026-09-15). The old rule was "newest above `MIN_NODE_VERSION`", which could hand a @@ -1496,7 +1496,7 @@ every act the dashboard performs (service stop, maintenance, repoint, binary update, the debug-logging toggle at `dashboard/routes.ts:349`, and the self-update rollback `POST /api/self/update/rollback` at `routes.ts:474`) is an act that same user can type at the keyboard, the newest two under the -same guards, and under the same "the audit-free agent log is the record" +same guards, and under the same "the audit-free daemon log is the record" accounting as the rest. The dashboard grants nothing new; it *removes the terminal* from the path. @@ -1547,7 +1547,7 @@ The rest is the standing accounting, unchanged by this surface: diverge and reappear on the next `ready`. - **There is no audit row.** The plane audits every mutation with a named actor; this surface has no actor beyond the machine's own user, and its - record is the agent log file (§10), which already holds launches and + record is the daemon log file (§10), which already holds launches and refusals and never holds argv or pane content. - **`maintenance on` here kills.** The node's own CLI semantics (flag first, then stop every subshell, re-probe each kill) are carried @@ -2104,7 +2104,7 @@ no block, and the page never sends a path), and every recursive delete is refused before the first mutation if it would contain the installed `~/.local/bin/subshell-server`. A reset removes accounts, sessions, keys, the signing keypair, pane logs and config, and clears this app's own choices; it -does NOT reach enrolled remote nodes or a same-machine `subshell` node agent, +does NOT reach enrolled remote nodes or a same-machine `subshell` node daemon, which keep their credentials and processes; the confirmation screen says so verbatim, because a person wiping the signing keypair is making a fleet-wide statement. From 8efd29f39f5cef552bb9a6ae05c325a7597b5ff4 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Sat, 10 Oct 2026 20:25:23 -0700 Subject: [PATCH 03/14] test(cli-e2e): the reset scenario seeds and names daemon.log The bun suite stubs every service seam, so the renamed log file and the reworded reset line land invisible to it; the compiled scenario is the thing that drives the real binary. It seeded logs/agent.log and grepped 'left the agent', both of which this branch renames. Co-Authored-By: Claude --- scripts/cli-e2e/headless-server-and-node.sh | 4 ++-- scripts/cli-e2e/node-reset-uninstall.sh | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/cli-e2e/headless-server-and-node.sh b/scripts/cli-e2e/headless-server-and-node.sh index 957656b10..c4bbf25db 100755 --- a/scripts/cli-e2e/headless-server-and-node.sh +++ b/scripts/cli-e2e/headless-server-and-node.sh @@ -126,14 +126,14 @@ echo "$OUT" | grep -qi "subshell run" && fail "setup still recommends the foregr ok "setup enrolled and named the nodes page" echo "== 9. run the agent and confirm it comes online" -"$NODE" run > "$W/agent.log" 2>&1 & +"$NODE" run > "$W/daemon.log" 2>&1 & NODEPID=$! ONLINE=no for i in $(seq 1 40); do if curl -sf -b "$JAR" "$BASE/api/nodes" | grep -q '"status":"online"'; then ONLINE=yes; break; fi sleep 0.5 done -[ "$ONLINE" = yes ] || { tail -20 "$W/agent.log"; fail "node never came online"; } +[ "$ONLINE" = yes ] || { tail -20 "$W/daemon.log"; fail "node never came online"; } ok "node is online on the control plane" echo "== 10. node status" diff --git a/scripts/cli-e2e/node-reset-uninstall.sh b/scripts/cli-e2e/node-reset-uninstall.sh index 190062f8d..4d153412c 100755 --- a/scripts/cli-e2e/node-reset-uninstall.sh +++ b/scripts/cli-e2e/node-reset-uninstall.sh @@ -34,7 +34,7 @@ seed_state() { printf '%s' '{"nodeId":"e2e-node-1","name":"e2e box"}' > "$SUBSHELL_CONFIG_HOME/config.json" printf '%s' '{}' > "$SUBSHELL_CONFIG_HOME/data/identity.json" printf '%s' 'pane bytes' > "$SUBSHELL_CONFIG_HOME/data/subshells/abc.log" - printf '%s' 'agent bytes' > "$SUBSHELL_CONFIG_HOME/logs/agent.log" + printf '%s' 'daemon bytes' > "$SUBSHELL_CONFIG_HOME/logs/daemon.log" printf '%s' '{"pid":4194305,"startedAt":"2026-01-01T00:00:00Z","nodeId":"e2e-node-1","lastTickAt":"2026-01-01T00:00:00Z"}' > "$SUBSHELL_CONFIG_HOME/daemon.lock" } @@ -76,9 +76,9 @@ fi [ ! -f "$SUBSHELL_CONFIG_HOME/config.json" ] || fail "config.json survived reset" [ ! -d "$SUBSHELL_CONFIG_HOME/data" ] || fail "the data dir survived reset" [ -x "$SRV" ] || fail "reset removed the binary it promised to keep" -[ -f "$SUBSHELL_CONFIG_HOME/logs/agent.log" ] || fail "reset took the agent log it promised to leave" -grep -q -- "left the agent" "$W/reset.log" || fail "the run did not name the log it keeps: $(cat "$W/reset.log")" -ok "config, lock and data gone; binary and agent log stay" +[ -f "$SUBSHELL_CONFIG_HOME/logs/daemon.log" ] || fail "reset took the daemon log it promised to leave" +grep -q -- "left the daemon" "$W/reset.log" || fail "the run did not name the log it keeps: $(cat "$W/reset.log")" +ok "config, lock and data gone; binary and daemon log stay" echo "== 4. uninstall without --reset-data: the binary goes, the bytes stay" seed_state From 1b5885465deb2280123559b9cc3dd6efc1a5a2ae Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Sat, 10 Oct 2026 20:31:07 -0700 Subject: [PATCH 04/14] fix(web): 'node you can't see' reveals nothing more on hover The rail header (and the home tiles) carried the invisible node's full id as tooltip text, a kept-over reveal from the retired node pill whose point was telling machines apart. An operator, shown the uuid, asked the obvious question: if you can't see it, why is it shown? An id with no page behind it names nothing to act on; title now equals label, and both consumers' equal-title branch renders no tooltip. The cold arm keeps its reveal (a short id becoming the full one) because there the machine IS the reader's, merely not loaded yet. Co-Authored-By: Claude --- .changeset/invisible-node-reveals-nothing.md | 5 +++ .../app-sidebar-node-groups.test.tsx | 34 +++++++++---------- .../components/sidebar/SubshellNodeGroup.tsx | 9 ++--- .../__tests__/subshell-node-groups.test.ts | 10 ++++-- .../web/src/lib/subshell-node-groups.ts | 24 ++++++++----- 5 files changed, 49 insertions(+), 33 deletions(-) create mode 100644 .changeset/invisible-node-reveals-nothing.md diff --git a/.changeset/invisible-node-reveals-nothing.md b/.changeset/invisible-node-reveals-nothing.md new file mode 100644 index 000000000..dded7b86c --- /dev/null +++ b/.changeset/invisible-node-reveals-nothing.md @@ -0,0 +1,5 @@ +--- +"@internal/server-web": patch +--- + +A sidebar group headed "node you can't see" no longer spells that machine's full id on hover. The id was a leftover reveal from the retired node pill, meant for telling machines apart; for a node the registry has already refused to name it discloses nothing a reader can act on (ruling 2026-10-10). A cold registry still reveals the full id under a short one, because there the machine is the reader's own, merely not loaded yet. diff --git a/apps/server/web/src/components/__tests__/app-sidebar-node-groups.test.tsx b/apps/server/web/src/components/__tests__/app-sidebar-node-groups.test.tsx index cba723e4f..4b6d23b15 100644 --- a/apps/server/web/src/components/__tests__/app-sidebar-node-groups.test.tsx +++ b/apps/server/web/src/components/__tests__/app-sidebar-node-groups.test.tsx @@ -286,35 +286,35 @@ describe("the rail's subshell list, grouped by node", () => { ); }); - it("puts the full node id on the header's hover text when the registry cannot name it", async () => { + it("labels the registry-unanswerable node 'node you can't see' with no id anywhere", async () => { // The stub answers WITH local/n1, so force the unresolved case: a - // subshell on an id nothing holds renders the label ladder's last rung, - // and the id must be recoverable on hover. + // subshell on an id nothing holds renders the label ladder's last rung — + // and the id itself is gone from the header and its hover text (ruling + // 2026-10-10: an id that leads to no page names nothing to act on). await withRail([subshell({ id: "a", nodeId: "gone-node-xyz" })], async () => { await waitFor(() => expect(groupHeaders()).toHaveLength(1)); - expect(groupHeader("gone-node-xyz").textContent).toContain("node you can't see"); - // Same popup shape as the cold-failure case: trigger marker on the - // button, no leftover native title anywhere in the header. const header = groupHeader("gone-node-xyz"); + expect(header.textContent).toContain("node you can't see"); + expect(header.textContent).not.toContain("gone-node-xyz"); + // No native title anywhere in the header; the trigger marker may stay, + // the equal-title branch below renders no content for it. expect(header.getAttribute("title")).toBeNull(); expect(header.querySelector("span")?.getAttribute("title")).toBeNull(); - expect(header.hasAttribute("data-base-ui-tooltip-trigger")).toBe(true); }); }); - it("opens the header reveal on focus, showing the full id", async () => { + it("gives an INVISIBLE header no popup — the id it used to reveal leads nowhere", async () => { await withRail([subshell({ id: "a", nodeId: "gone-node-xyz" })], async () => { await waitFor(() => expect(groupHeaders()).toHaveLength(1)); - // Focus the BUTTON — what a keyboard user actually focuses. This is - // real behavior now because the trigger merged onto it via `render`. + // Focus the BUTTON — what a keyboard user actually focuses. Past the + // open delay the popup must still be absent: title equals label, so + // the header has nothing the heading does not already say. fireEvent.focus(groupHeader("gone-node-xyz")); - // The full id exists in the DOM nowhere else on the rail while shut — - // finding it IS the popup. - expect(await screen.findByText("gone-node-xyz")).toBeTruthy(); - // Leave it SHUT: a tooltip still open when the test ends has its close - // dispatch land during the NEXT test's mount, outside any acting scope - // (the React act warning this line silences). Blur cancels it. - fireEvent.blur(groupHeader("gone-node-xyz")); + await act(async () => { + await new Promise((r) => setTimeout(r, 450)); + }); + expect(screen.queryByText("gone-node-xyz")).toBeNull(); + expect(document.querySelector("[class*='text-body']")).toBeNull(); }); }); diff --git a/apps/server/web/src/components/sidebar/SubshellNodeGroup.tsx b/apps/server/web/src/components/sidebar/SubshellNodeGroup.tsx index 2b3af1826..7fa9120c6 100644 --- a/apps/server/web/src/components/sidebar/SubshellNodeGroup.tsx +++ b/apps/server/web/src/components/sidebar/SubshellNodeGroup.tsx @@ -23,10 +23,11 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp * follows: `aria-controls` has to resolve to a real element, and `hidden` * takes the links out of the tab order so a shut group is not a keyboard * trap of invisible stops. - * - **The label carries a hover reveal.** When the registry has not resolved - * the node the label is a short id or "node you can't see", and the full id on - * hover is the only thing that says WHICH machine — the reveal the cards' - * retired `nodePill` used to give, for the same reason. It is a `ui/tooltip` + * - **The label carries a hover reveal while the registry is cold**: a short + * id becomes the full one on hover. It does NOT do that for "node you + * can't see": the ladder's third arm makes title equal the label, and the + * equal-title branch below renders no tooltip — an id with no page behind + * it names nothing the reader can act on (ruling 2026-10-10). It is a `ui/tooltip` * popup rather than a native `title` since 2026-09-24 (the row tooltips' * zoom reason, same rail), and it appears ONLY when the title would say * something the label does not — a resolved node's hover used to repeat its diff --git a/apps/server/web/src/lib/__tests__/subshell-node-groups.test.ts b/apps/server/web/src/lib/__tests__/subshell-node-groups.test.ts index 52f93eff4..6c4fbde14 100644 --- a/apps/server/web/src/lib/__tests__/subshell-node-groups.test.ts +++ b/apps/server/web/src/lib/__tests__/subshell-node-groups.test.ts @@ -95,12 +95,16 @@ describe("groupSubshellsByNode — labels", () => { expect(groups[0]?.label).toBe("local"); }); - it("says 'node you can't see', with the id on hover, once the registry ANSWERED without the id", () => { + it("says 'node you can't see' and reveals NOTHING more once the registry ANSWERED without the id", () => { // Not "deleted node": the list is share-filtered, so a revoked grant - // lands here too, and "unknown" claims only what is known. + // lands here too, and "unknown" claims only what is known. And the id + // leaves the hover text too: title equals label, which every consumer + // renders as no tooltip — the id of a node you cannot see leads to no + // page and names nothing the reader can act on (ruling 2026-10-10). const groups = groupSubshellsByNode([subshell({ nodeId: "gone" })], [node()], { unanswered: false }); expect(groups[0]?.label).toBe("node you can't see"); - expect(groups[0]?.title).toBe("gone"); + expect(groups[0]?.title).toBe("node you can't see"); + expect(groups[0]?.title).not.toContain("gone"); }); it("makes the hover title the NAME once the registry resolves it", () => { diff --git a/apps/server/web/src/lib/subshell-node-groups.ts b/apps/server/web/src/lib/subshell-node-groups.ts index ae7147d49..3d4b65e7f 100644 --- a/apps/server/web/src/lib/subshell-node-groups.ts +++ b/apps/server/web/src/lib/subshell-node-groups.ts @@ -20,9 +20,10 @@ export interface SubshellNodeGroup { label: string; /** * The header's hover text: the NAME when the registry resolved it, the - * full node id otherwise — the reveal the cards' retired `nodePill` used - * to give (`title={subshell.nodeId}`), and the only way to tell which - * machine a "node you can't see" or a short-id header actually names. + * full node id while a cold registry has not answered yet, and the label + * itself for a node that stays invisible — every consumer hides a tooltip + * whose title equals the label, and the operator ruled (2026-10-10) that + * the id of a node you cannot see is disclosure with nothing to act on. */ title: string; /** The rows, in the order they arrived (i.e. the caller's status sort), capped by `limit` */ @@ -57,14 +58,19 @@ export const FALLBACK_NODE_ID = "local"; * cannot), and a "deleted node" verdict above `local` after a flaky * `/api/nodes` would be a lie about the one machine that cannot be * deleted; - * - the registry ANSWERED without the id → "node you can't see". Not - * "deleted node": deletion is one cause, but the list is share-filtered, so - * a revoked grant (an admin narrowing `local`, a node share pulled under a - * live subshell) lands here too. Since 2026-10-09 the pane list is + * - the registry ANSWERED without the id → "node you can't see", with the + * label as its own hover text (which the consumers render as no tooltip). + * Not "deleted node": deletion is one cause, but the list is share-filtered, + * so a revoked grant (an admin narrowing `local`, a node share pulled under + * a live subshell) lands here too. Since 2026-10-09 the pane list is * share-filtered for EVERY role, and this is the whole meaning of the * header: the machine exists on someone else's screen, or existed, and this * one cannot see it. "unknown node" claimed that too but also read as "the - * machine is gone", which is why the phrase changed. + * machine is gone", which is why the phrase changed. The full id used to + * ride here as hover text — the retired pill's reveal — and it went: an id + * that leads to no page names nothing the reader can act on + * (ruling 2026-10-10). The cold arm above still reveals the full id, + * because there the machine IS the reader's own, merely not loaded yet. * * Exported beside {@link groupSubshellsByNode} because the Wave C diagnostics * HUD names a pane's machine too, and two ladders for one question is how the @@ -82,7 +88,7 @@ export function nodeLabelFor( const known = nodes?.find((n) => n.id === nodeId); if (known) return { label: known.name, title: known.name }; if (unanswered) return { label: nodeId.slice(0, 8), title: nodeId }; - return { label: "node you can't see", title: nodeId }; + return { label: "node you can't see", title: "node you can't see" }; } /** From 03d2f86103254e8f6886a830534fa54a6c1f9890 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Sat, 10 Oct 2026 20:33:04 -0700 Subject: [PATCH 05/14] =?UTF-8?q?fix(copy):=20the=20review=20round's=20fin?= =?UTF-8?q?dings=20=E2=80=94=20transfer=20lines,=20re-register=20refusal,?= =?UTF-8?q?=20OpenAPI=20prose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four minor, four nit, all quote-verified: the transfer refusals named the daemon 'the agent on node X' (the double meaning this PR exists to kill, sharpest where a harness reads the sentence about itself); the re-register refusal told the wizard's reader a row 'cannot re-register as an agent'; node-assistant.md still named agent.log as the first rung; the 4409 line said 'another subshell' for a daemon; the rollback lines kept the verb 'refused' the new STYLE section bans in status prose; 'agent node' in shipped OpenAPI descriptions reads as the AI sense. The readable-path docblock now names the one accepted switchover cost (a held poll offset the startup burst outgrows between polls skips bytes until a reload), and the Rust ladder test now also pins legacy-before-manager ordering. Co-Authored-By: Claude --- apps/client/desktop/docs/node-assistant.md | 4 +++- apps/client/desktop/src-tauri/src/control.rs | 4 +++- apps/node/agent/src/daemon.ts | 2 +- apps/node/agent/src/log-file.ts | 6 ++++++ apps/node/agent/src/update.ts | 4 ++-- apps/server/api/src/api/models.ts | 4 ++-- apps/server/api/src/api/nodes/reregister-node.route.ts | 2 +- apps/server/api/src/api/transfers/post-transfer.route.ts | 8 +++++--- apps/server/api/src/services/transfers.service.ts | 2 +- packages/mcp-core/src/__tests__/tools.test.ts | 2 +- 10 files changed, 25 insertions(+), 13 deletions(-) diff --git a/apps/client/desktop/docs/node-assistant.md b/apps/client/desktop/docs/node-assistant.md index 9a73e41a5..4fe55be2b 100644 --- a/apps/client/desktop/docs/node-assistant.md +++ b/apps/client/desktop/docs/node-assistant.md @@ -479,7 +479,9 @@ by any of that: `journalctl` sentence IS the remedy, rendered on the facts row and on the Service screen's offline card. - **`node-log` resolves the node's OWN capped file first**, on every - platform (2026-09-18): `~/.config/subshell/logs/agent.log`, the same + platform (2026-09-18; named `daemon.log` since the 2026-10 copy ruling, with + the pre-rename `agent.log` as the second rung until the daemon writes the + new one): `~/.config/subshell/logs/daemon.log`, the same JSON-lines file the plane's node log view serves, so the path the facts list names and the log a browser reads cannot be two different documents. That is the order `apps/server/desktop`'s `desktop_logs` reads the server's log in, diff --git a/apps/client/desktop/src-tauri/src/control.rs b/apps/client/desktop/src-tauri/src/control.rs index 968f61172..d0f9204f3 100644 --- a/apps/client/desktop/src-tauri/src/control.rs +++ b/apps/client/desktop/src-tauri/src/control.rs @@ -3452,7 +3452,9 @@ mod path_tests { let (path, hint) = node_log_paths_from( Some(PathBuf::from("/home/u/.config/subshell")), Some(PathBuf::from("/home/u")), - |p| p.ends_with("logs/agent.log"), // only the old name has content + // BOTH the old name and the manager's copy have content: the rung + // order the ruling keeps is daemon, then pre-rename, then manager. + |p| p.ends_with("logs/agent.log") || p.ends_with("Library/Logs/subshell.log"), ); assert_eq!( path.as_deref(), diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index f02054405..344b11d2f 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -1110,7 +1110,7 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom if (shuttingDown) stop(0); // graceful: the socket closed cleanly on our request if (close.code === NODE_CLOSE_SUPERSEDED) { log( - `another subshell is already registered as node '${config.nodeId}' (close 4409); exiting, stop the duplicate daemon first`, + `another daemon is already registered as node '${config.nodeId}' (close 4409); exiting, stop the duplicate first`, ); stop(1); } diff --git a/apps/node/agent/src/log-file.ts b/apps/node/agent/src/log-file.ts index f26f2b3d9..00069107c 100644 --- a/apps/node/agent/src/log-file.ts +++ b/apps/node/agent/src/log-file.ts @@ -61,6 +61,12 @@ export function legacyAgentLogPath(): string { * Every reader — the plane's `agent_log_read` command, the loopback dashboard, * `status --json` — takes this instead of {@link daemonLogPath}, so an upgraded * node shows its history from the first poll rather than after its first line. + * + * One accepted cost of the no-migration design: a poller holding an offset into + * the old file across the switchover re-reads cleanly only while the new file + * is SHORTER than that offset (the truncated flag); if the daemon's startup + * burst outgrew the held offset between two polls, that one view skips bytes + * until a reload restarts it from 0. */ export function readableDaemonLogPath(): string { const current = daemonLogPath(); diff --git a/apps/node/agent/src/update.ts b/apps/node/agent/src/update.ts index c976d3b5a..f7100efc9 100644 --- a/apps/node/agent/src/update.ts +++ b/apps/node/agent/src/update.ts @@ -867,7 +867,7 @@ export async function revertAfterRefusal( // renames, or a hand that tidied up). There is nothing to put back, so say // so rather than deleting the marker and pretending the update succeeded. log( - `the control plane refused subshell ${pending.to} and there is no ${pending.previousBinary} to restore; reinstall the daemon by hand`, + `the server would not accept subshell ${pending.to}, and there is no ${pending.previousBinary} to restore; reinstall the daemon by hand`, ); return null; } @@ -894,7 +894,7 @@ export async function revertAfterRefusal( await writeMarker(failedMarkerPath(dataDir), failure); await rm(pendingMarkerPath(dataDir), { force: true }).catch(() => {}); log( - `the control plane refused subshell ${pending.to}, and ${pending.previousBinary} did not answer \`version\`; left ${pending.binary} in place and kept the copy`, + `the server would not accept subshell ${pending.to}, and ${pending.previousBinary} did not answer \`version\`; left ${pending.binary} in place and kept the copy`, ); return failure; } diff --git a/apps/server/api/src/api/models.ts b/apps/server/api/src/api/models.ts index f1e81236f..b01f62fd4 100644 --- a/apps/server/api/src/api/models.ts +++ b/apps/server/api/src/api/models.ts @@ -66,7 +66,7 @@ export const SubshellSchema = t.Object({ }), nodeOffline: t.Boolean({ description: - "True when the subshell's agent node has no live connection; the subshell may still be running there (spec §5.6); always false for local subshells", + "True when the subshell's machine node has no live connection; the subshell may still be running there (spec §5.6); always false for local subshells", }), shareCount: t.Number({ description: @@ -169,7 +169,7 @@ export const WorkspacePaneSchema = t.Object({ }), subshellNodeOffline: t.Boolean({ description: - "True = the subshell's agent node has no live connection (spec §5.6); it may still be RUNNING there, its state is just unobservable from here. Always false for subshells on 'local'", + "True = the subshell's machine node has no live connection (spec §5.6); it may still be RUNNING there, its state is just unobservable from here. Always false for subshells on 'local'", }), }); diff --git a/apps/server/api/src/api/nodes/reregister-node.route.ts b/apps/server/api/src/api/nodes/reregister-node.route.ts index 024e556d5..74f922ebc 100644 --- a/apps/server/api/src/api/nodes/reregister-node.route.ts +++ b/apps/server/api/src/api/nodes/reregister-node.route.ts @@ -37,7 +37,7 @@ export const reregisterNodeRoute = new Elysia() 400, apiErrorBody({ code: BackendErrorCodes.BAD_REQUEST, - message: "The server node cannot re-register as an agent", + message: "The server's own node entry cannot re-register; only a machine node can", }), ); } diff --git a/apps/server/api/src/api/transfers/post-transfer.route.ts b/apps/server/api/src/api/transfers/post-transfer.route.ts index f2214df16..e73cf2ba7 100644 --- a/apps/server/api/src/api/transfers/post-transfer.route.ts +++ b/apps/server/api/src/api/transfers/post-transfer.route.ts @@ -39,7 +39,9 @@ import { runTransfer, type TransferEndpoint } from "@/services/transfers.service /** One endpoint as the request names it. */ const EndpointSchema = t.Object({ - nodeId: t.String({ description: "Agent node id; the caller must own it (local and foreign rows are refused)" }), + nodeId: t.String({ + description: "Machine node id (kind `agent`); the caller must own it (`local` and foreign rows are refused)", + }), path: t.String({ description: "Absolute directory ON that node; the node's operator allowlist is the authority" }), }); @@ -102,7 +104,7 @@ async function gateEndpoint(which: "from" | "to", ep: TransferEndpoint, userId: // cannot tell a held-now agent from a dead one that dialed back on the // old version before it stopped dialing at all; either way updating is // the action, so the remedy names itself without a false present tense. - message: `the agent on ${row.name} spoke protocol v${row.protocolVersion} as last reported; update the node (the Nodes page asks a human)`, + message: `the daemon on ${row.name} spoke protocol v${row.protocolVersion} as last reported; update the node (the Nodes page asks a human)`, doNotLog: true, }); } @@ -200,7 +202,7 @@ export const postTransferRoute = new Elysia() operationId: "createTransfer", tags: ["transfers"], description: - "Relay a directory tree between two agent nodes the caller owns (whole-tree copy, or diff-sync on per-file SHA-256); extraction is additive and nothing at the destination is ever deleted", + "Relay a directory tree between two machine nodes the caller owns (whole-tree copy, or diff-sync on per-file SHA-256); extraction is additive and nothing at the destination is ever deleted", }, }, ); diff --git a/apps/server/api/src/services/transfers.service.ts b/apps/server/api/src/services/transfers.service.ts index 975030d0a..253d4780d 100644 --- a/apps/server/api/src/services/transfers.service.ts +++ b/apps/server/api/src/services/transfers.service.ts @@ -410,7 +410,7 @@ function transferFailed(err: NodeRpcError, nodeId: string, verb = "command"): ne // sentence about a verb nobody named. throwApiError({ code: BackendErrorCodes.NODE_AGENT_TOO_OLD, - message: `the agent on node ${nodeId} predates archive transfer; update the node`, + message: `the daemon on node ${nodeId} predates archive transfer; update the node`, doNotLog: true, }); } diff --git a/packages/mcp-core/src/__tests__/tools.test.ts b/packages/mcp-core/src/__tests__/tools.test.ts index 47cbe78de..c9c72d0e9 100644 --- a/packages/mcp-core/src/__tests__/tools.test.ts +++ b/packages/mcp-core/src/__tests__/tools.test.ts @@ -842,7 +842,7 @@ describe("mcp tools: the 2026-09-25 agent surface", () => { // remedy is a human updating the node, and the sentence must say so. expect( describeToolError( - new ApiError(409, "the agent on node n predates archive transfer; update the node", "NODE_AGENT_TOO_OLD"), + new ApiError(409, "the daemon on node n predates archive transfer; update the node", "NODE_AGENT_TOO_OLD"), ).message, ).toContain("update the node"); }); From 2e5368287818072780316d192c3ba9254c1af240 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Sat, 10 Oct 2026 20:38:07 -0700 Subject: [PATCH 06/14] =?UTF-8?q?fix(copy):=20the=20second=20round's=20fin?= =?UTF-8?q?dings=20=E2=80=94=20the=20rollback=20line,=20the=20MCP=20wrap,?= =?UTF-8?q?=20the=20schema=20twins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All quote-verified. The successful-rollback log line still said 'the control plane refused' where its two failure siblings became 'would not accept'; the MCP wrap around the transfer-too-old error said 'the agent on the target node' three times around the (already fixed) interpolated server sentence; the transfer tool's own z.string description was the schema twin of the OpenAPI line round one renamed; three comments still promised the hover reveal the tooltip ruling removed; the 4406 tail asked for 'a newer subshell'; the restart fallback note kept 'refused'; the cli-e2e step title kept the old word beside its renamed log file; and the desktop package.json line this PR edited still shipped an em dash. The rollback line now has a test pin (daemon.test.ts, 4406 revert path): 'would not accept 9.9.9' present, 'the control plane refused' absent. The 4406 fixture strings now quote the server's real phrasing ('this node is'). Co-Authored-By: Claude --- apps/client/desktop/package.json | 2 +- apps/node/agent/src/__tests__/daemon.test.ts | 15 +++++++++++---- apps/node/agent/src/daemon.ts | 2 +- apps/node/agent/src/update.ts | 4 ++-- .../src/components/sidebar/SubshellNodeGroup.tsx | 4 +++- .../web/src/components/subshell-manager-table.tsx | 2 +- apps/server/web/src/routes/index.tsx | 2 +- packages/mcp-core/src/tools.ts | 2 +- packages/mcp-core/src/transfer-tools.ts | 2 +- scripts/cli-e2e/headless-server-and-node.sh | 2 +- 10 files changed, 23 insertions(+), 14 deletions(-) diff --git a/apps/client/desktop/package.json b/apps/client/desktop/package.json index 5244dc011..74106f2d8 100644 --- a/apps/client/desktop/package.json +++ b/apps/client/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@internal/desktop-client", - "description": "Subshell Client \u2014 a Tauri shell that registers this machine as a subshell node and manages its node daemon", + "description": "Subshell Client: a Tauri shell that registers this machine as a subshell node and manages its node daemon", "version": "1.2.6", "license": "Apache-2.0", "type": "module", diff --git a/apps/node/agent/src/__tests__/daemon.test.ts b/apps/node/agent/src/__tests__/daemon.test.ts index 1147176e1..b76698100 100644 --- a/apps/node/agent/src/__tests__/daemon.test.ts +++ b/apps/node/agent/src/__tests__/daemon.test.ts @@ -2186,16 +2186,16 @@ describe("4406 refusal message", () => { // vN", which named the wrong gate for a version-floor refusal and told them // neither the required nor the found version. test("relays the server's reason verbatim", () => { - expect(updateRequiredMessage("subshell 0.3.0 or newer required (this agent is 0.2.1)")).toContain( - "subshell 0.3.0 or newer required (this agent is 0.2.1)", + expect(updateRequiredMessage("subshell 0.3.0 or newer required (this node is 0.2.1)")).toContain( + "subshell 0.3.0 or newer required (this node is 0.2.1)", ); - expect(updateRequiredMessage("protocol v4 required (this agent speaks v3)")).toContain("this agent speaks v3"); + expect(updateRequiredMessage("protocol v4 required (this node speaks v3)")).toContain("this node speaks v3"); }); test("still says something actionable when the server sent no reason", () => { const line = updateRequiredMessage(undefined); expect(line).toContain("4406"); - expect(line).toContain("newer subshell"); + expect(line).toContain("newer Subshell daemon"); expect(line).not.toContain("undefined"); expect(updateRequiredMessage("")).toBe(line); }); @@ -2413,6 +2413,7 @@ describe("the update transaction, settled by the daemon (spec 2026-09-15 §5.1/ }); test("close 4406 with a pending marker ROLLS BACK, records the failure, and exits 1", async () => { + const { lines, restore } = captureLogs(); const h = await startDaemon(); await waitForReady(h); const { binary, previous } = stageTransaction(h.config.dataDir); @@ -2433,6 +2434,12 @@ describe("the update transaction, settled by the daemon (spec 2026-09-15 §5.1/ }; expect(failed.to).toBe("9.9.9"); expect(failed.reason).toContain("protocol v11"); + // The rollback's own record reads from the reader's seat (ruling + // 2026-10-10): the server WILL NOT ACCEPT the version, not "the control + // plane refused" it. + expect(lines.some((l) => l.includes("would not accept 9.9.9"))).toBe(true); + expect(lines.some((l) => l.includes("the control plane refused"))).toBe(false); + restore(); }); test("close 4406 WITHOUT a marker touches nothing — it is the ordinary too-old refusal", async () => { diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index 344b11d2f..58afe3a59 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -327,7 +327,7 @@ const HAS_MCP = true; export function updateRequiredMessage(reason: string | undefined): string { return reason ? `the server will not accept this machine's Subshell daemon version (close 4406): ${reason}; exiting` - : `the server will not accept this machine's Subshell daemon version (close 4406); a newer subshell is required; exiting`; + : `the server will not accept this machine's Subshell daemon version (close 4406); a newer Subshell daemon is required; exiting`; } function readyEvent( diff --git a/apps/node/agent/src/update.ts b/apps/node/agent/src/update.ts index f7100efc9..397197c76 100644 --- a/apps/node/agent/src/update.ts +++ b/apps/node/agent/src/update.ts @@ -825,7 +825,7 @@ export async function applyUpdate(input: ApplyUpdateInput): Promise {}); - log(`rolled back to subshell ${pending.from}: the control plane refused ${pending.to} (${reason})`); + log(`rolled back to subshell ${pending.from}: the server would not accept ${pending.to} (${reason})`); return failure; } diff --git a/apps/server/web/src/components/sidebar/SubshellNodeGroup.tsx b/apps/server/web/src/components/sidebar/SubshellNodeGroup.tsx index 7fa9120c6..c2ae0e490 100644 --- a/apps/server/web/src/components/sidebar/SubshellNodeGroup.tsx +++ b/apps/server/web/src/components/sidebar/SubshellNodeGroup.tsx @@ -48,7 +48,9 @@ export function SubshellNodeGroup({ nodeId: string; /** The node's NAME (or the honest fallback when the registry cannot say one) */ label: string; - /** The header's hover text — the name when known, the full node id otherwise */ + /** The header's hover text — the name when known, the full id only while a cold + * registry has not answered; equal to the label for an invisible node, which + * the equal-title branch below renders as no tooltip at all. */ title: string; /** How many subshells this node has, BEFORE the per-group cap */ count: number; diff --git a/apps/server/web/src/components/subshell-manager-table.tsx b/apps/server/web/src/components/subshell-manager-table.tsx index 4a65af66d..e56e976e6 100644 --- a/apps/server/web/src/components/subshell-manager-table.tsx +++ b/apps/server/web/src/components/subshell-manager-table.tsx @@ -132,7 +132,7 @@ export function SubshellManagerTable({ (sections ? sections : [{ key: "*", label: "", title: "", subshells }]).map((sec) => ( {/* The section band — same label and same hover reveal - (full node id only when the name did not resolve) as + (full node id only while a cold registry has not answered) as the tile heading for the same section, so switching views changes the shape, not the words. */} {/* The band's own token call, settled by the operator after diff --git a/apps/server/web/src/routes/index.tsx b/apps/server/web/src/routes/index.tsx index 141e8445a..cbf947800 100644 --- a/apps/server/web/src/routes/index.tsx +++ b/apps/server/web/src/routes/index.tsx @@ -339,7 +339,7 @@ function SubshellsPage() { * lib/subshell-node-groups.ts). Renders nothing when the group is empty. The * heading is the node's NAME (a rename moves it), never uppercased: it is a * proper noun the operator chose, not a status band. Its hover reveal is the - * full node id whenever the registry could not resolve a name — a `ui/tooltip` + * full node id only while a cold registry has not answered — a `ui/tooltip` * popup, not a native `title` (same 2026-09-24 zoom reason as the rail rows), * and absent entirely when the title would only repeat the heading. */ diff --git a/packages/mcp-core/src/tools.ts b/packages/mcp-core/src/tools.ts index 1dd5df0a6..fd5f34a79 100644 --- a/packages/mcp-core/src/tools.ts +++ b/packages/mcp-core/src/tools.ts @@ -64,7 +64,7 @@ export function describeToolError(err: unknown): Error { // list_nodes rows carry no version, so pointing at one asked the agent // to check a fact the tool does not report. return new Error( - `subshell: the agent on the target node predates this command (${err.message}); ask a human to update that node's agent, or choose an agent node whose panes already answer this verb`, + `subshell: the daemon on the target node predates this command (${err.message}); ask a human to update that node's daemon, or choose a machine node whose panes already answer this verb`, ); } if (err.code === "EXEC_PANE_BUSY") { diff --git a/packages/mcp-core/src/transfer-tools.ts b/packages/mcp-core/src/transfer-tools.ts index e4326dd24..8a65342c1 100644 --- a/packages/mcp-core/src/transfer-tools.ts +++ b/packages/mcp-core/src/transfer-tools.ts @@ -93,7 +93,7 @@ export async function transferFiles( /** One endpoint object: a machine an agent can name and a path on it. */ const EndpointArgSchema = z.object({ - node: z.string().describe("Agent node id or display name from list_nodes; the caller must own it"), + node: z.string().describe("Machine node id (kind `agent`) or display name from list_nodes; the caller must own it"), path: z .string() .describe("Absolute directory ON that node; the node's operator allowlist gates it (empty list = unrestricted)"), diff --git a/scripts/cli-e2e/headless-server-and-node.sh b/scripts/cli-e2e/headless-server-and-node.sh index c4bbf25db..d9436ec61 100755 --- a/scripts/cli-e2e/headless-server-and-node.sh +++ b/scripts/cli-e2e/headless-server-and-node.sh @@ -125,7 +125,7 @@ echo "$OUT" | grep -q "/nodes" || fail "setup printed no next step naming the no echo "$OUT" | grep -qi "subshell run" && fail "setup still recommends the foreground run" ok "setup enrolled and named the nodes page" -echo "== 9. run the agent and confirm it comes online" +echo "== 9. run the daemon and confirm it comes online" "$NODE" run > "$W/daemon.log" 2>&1 & NODEPID=$! ONLINE=no From 36a9573900dc461f2820ffe38348aba105951ac6 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Sat, 10 Oct 2026 20:38:48 -0700 Subject: [PATCH 07/14] fix(changeset): the two SPA entries name @internal/server @internal/server-web is on changesets' ignore list; the SPA ships embedded in the server binary, and every prior SPA-only changeset (#356's stale- detection entry among them) names @internal/server. An ignored key here would ride NO release. Co-Authored-By: Claude --- .changeset/agent-means-the-ai-agent.md | 3 +-- .changeset/invisible-node-reveals-nothing.md | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.changeset/agent-means-the-ai-agent.md b/.changeset/agent-means-the-ai-agent.md index f67ff5729..5abcc5b19 100644 --- a/.changeset/agent-means-the-ai-agent.md +++ b/.changeset/agent-means-the-ai-agent.md @@ -1,8 +1,7 @@ --- -"@internal/server-web": patch +"@internal/server": patch "@internal/node": patch "@internal/desktop-client": patch -"@internal/server": patch --- The word "agent" now means the AI agent a subshell pane drives, and nothing else. The node's background program is the **daemon** (or node daemon), everywhere a person reads about it: the Nodes table's `Agent` column is **Version**, the update card says the daemon restarts itself, a refused version reads "The server will not accept this machine's Subshell daemon version. You can still update it from here.", `subshell setup` asks "Run the Subshell daemon in the background and start it at login?", and the daemon's own refusal lines, probe warnings, update errors and the desktop app's descriptions say daemon too. Status sentences were rewritten from the reader's seat: what is true of their machine, then what they can still do, with no protocol verbs. The rule and its worked example live in `apps/docs/STYLE.md`. diff --git a/.changeset/invisible-node-reveals-nothing.md b/.changeset/invisible-node-reveals-nothing.md index dded7b86c..f70f51a10 100644 --- a/.changeset/invisible-node-reveals-nothing.md +++ b/.changeset/invisible-node-reveals-nothing.md @@ -1,5 +1,5 @@ --- -"@internal/server-web": patch +"@internal/server": patch --- A sidebar group headed "node you can't see" no longer spells that machine's full id on hover. The id was a leftover reveal from the retired node pill, meant for telling machines apart; for a node the registry has already refused to name it discloses nothing a reader can act on (ruling 2026-10-10). A cold registry still reveals the full id under a short one, because there the machine is the reader's own, merely not loaded yet. From 706bd493b989c312c302b200ac8430519e4c79b1 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Sat, 10 Oct 2026 20:42:25 -0700 Subject: [PATCH 08/14] =?UTF-8?q?fix(copy):=20the=20third=20round's=20find?= =?UTF-8?q?ings=20=E2=80=94=20the=20no-reason=20fallback=20and=20the=20das?= =?UTF-8?q?hboard=20verbs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 4406 revert fallback recorded 'the control plane refused it' into update-failed.json, which the loopback dashboard prints raw and which the rewritten rollback line interpolated — 'no reason was given' now, with a test that closes 4406 WITHOUT a reason and pins both the record and the log line. The dashboard's three error fallbacks ('The update was refused' et al) became plain outcomes, matching the update.ts note round two fixed. The log-file test restores the preload's config home instead of deleting the variable, so a test added below it cannot resolve the developer's real ~/.config/subshell. Co-Authored-By: Claude --- apps/node/agent/src/__tests__/daemon.test.ts | 21 +++++++++++++++++++ .../node/agent/src/__tests__/log-file.test.ts | 7 ++++++- apps/node/agent/src/daemon.ts | 2 +- .../web/src/components/log-retention-card.tsx | 2 +- apps/node/web/src/components/updates-card.tsx | 4 ++-- 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/node/agent/src/__tests__/daemon.test.ts b/apps/node/agent/src/__tests__/daemon.test.ts index b76698100..9b8577ee2 100644 --- a/apps/node/agent/src/__tests__/daemon.test.ts +++ b/apps/node/agent/src/__tests__/daemon.test.ts @@ -2442,6 +2442,27 @@ describe("the update transaction, settled by the daemon (spec 2026-09-15 §5.1/ restore(); }); + test("close 4406 with a marker and an EMPTY reason records the plain fallback", async () => { + const { lines, restore } = captureLogs(); + const h = await startDaemon(); + await waitForReady(h); + const { binary, previous } = stageTransaction(h.config.dataDir); + h.plane.socket?.close(4406, ""); + const deadline = Date.now() + 2000; + while (h.exits.length === 0 && Date.now() < deadline) await sleep(5); + expect(h.exits).toEqual([1]); + expect(readFileSync(binary, "utf8")).toBe(OLD_STUB); + expect(existsSync(previous)).toBe(false); + const failed = JSON.parse(readFileSync(join(h.config.dataDir, "update-failed.json"), "utf8")) as { + reason: string; + }; + // The dashboard prints this reason raw; the no-reason fallback says what + // is true and hides no protocol verb behind the sentence. + expect(failed.reason).toBe("no reason was given"); + expect(lines.some((l) => l.includes("would not accept 9.9.9 (no reason was given)"))).toBe(true); + restore(); + }); + test("close 4406 WITHOUT a marker touches nothing — it is the ordinary too-old refusal", async () => { // A plane refusing an agent nobody just updated is the commonest 4406 by // far. Inventing a swap there would move files for an update that never diff --git a/apps/node/agent/src/__tests__/log-file.test.ts b/apps/node/agent/src/__tests__/log-file.test.ts index 7ad909cf0..40e1263e1 100644 --- a/apps/node/agent/src/__tests__/log-file.test.ts +++ b/apps/node/agent/src/__tests__/log-file.test.ts @@ -111,6 +111,7 @@ describe("reading a slice of it", () => { describe("readableDaemonLogPath", () => { it("prefers daemon.log, falls back to the pre-rename agent.log, and never errors on neither", () => { const home = mkdtempSync(join(tmpdir(), "subshell-daemonlog-")); + const prior = process.env.SUBSHELL_CONFIG_HOME; process.env.SUBSHELL_CONFIG_HOME = home; try { // No file at all: the answer is the current name (an empty read, not a @@ -123,7 +124,11 @@ describe("readableDaemonLogPath", () => { writeFileSync(join(home, "logs", "daemon.log"), "new"); expect(readableDaemonLogPath()).toBe(join(home, "logs", "daemon.log")); } finally { - delete process.env.SUBSHELL_CONFIG_HOME; + // The package preload installs a sandbox home; dropping the variable + // would hand any test added after this one the developer's REAL + // ~/.config/subshell. + if (prior === undefined) delete process.env.SUBSHELL_CONFIG_HOME; + else process.env.SUBSHELL_CONFIG_HOME = prior; } }); }); diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index 58afe3a59..3cc3beb5d 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -1125,7 +1125,7 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // node is too old" case, and swapping files there would be inventing // a rollback for an update that never happened. updateSettled = true; - const reverted = await revertAfterRefusal(config.dataDir, close.reason || "the control plane refused it").catch( + const reverted = await revertAfterRefusal(config.dataDir, close.reason || "no reason was given").catch( (err: unknown) => { log(`could not roll the update back: ${err instanceof Error ? err.message : String(err)}`); return null; diff --git a/apps/node/web/src/components/log-retention-card.tsx b/apps/node/web/src/components/log-retention-card.tsx index 47bbecb19..689cd89df 100644 --- a/apps/node/web/src/components/log-retention-card.tsx +++ b/apps/node/web/src/components/log-retention-card.tsx @@ -101,7 +101,7 @@ export function LogRetentionCard(): React.ReactNode { ); void queryClient.invalidateQueries({ queryKey: RETENTION_KEY }); }, - onError: (err) => setFailure(errMessage(err, "The retention change was refused.")), + onError: (err) => setFailure(errMessage(err, "The retention change did not apply.")), }); if (state.isError) { diff --git a/apps/node/web/src/components/updates-card.tsx b/apps/node/web/src/components/updates-card.tsx index af5eac585..2ac8d940d 100644 --- a/apps/node/web/src/components/updates-card.tsx +++ b/apps/node/web/src/components/updates-card.tsx @@ -163,12 +163,12 @@ export function UpdatesCard(): React.ReactNode { {update.isError && (

- {errMessage(update.error, "The update was refused.")} + {errMessage(update.error, "The update did not run.")}

)} {rollback.isError && (

- {errMessage(rollback.error, "Rollback was refused.")} + {errMessage(rollback.error, "The rollback did not run.")}

)} {result &&

{result}

} From 3550f48f220b79e14c39416f671bde2ae9e5721e Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Sat, 10 Oct 2026 20:46:21 -0700 Subject: [PATCH 09/14] =?UTF-8?q?fix(copy):=20the=20fourth=20round's=20fin?= =?UTF-8?q?dings=20=E2=80=94=20transfer=5Fnodes'=20own=20description?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP tool's shipped description told harnesses (and the humans reading them) that transfers run 'between two AGENT nodes', and one HTTP refusal in the same route family said it plainly - while the refusal it quotes about itself now says 'the daemon'. Both re-worded to machine nodes; the route's own docblock follows its text. Co-Authored-By: Claude --- apps/server/api/src/api/transfers/post-transfer.route.ts | 4 ++-- packages/mcp-core/src/server.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/api/src/api/transfers/post-transfer.route.ts b/apps/server/api/src/api/transfers/post-transfer.route.ts index e73cf2ba7..e6d1834c6 100644 --- a/apps/server/api/src/api/transfers/post-transfer.route.ts +++ b/apps/server/api/src/api/transfers/post-transfer.route.ts @@ -11,7 +11,7 @@ import { runTransfer, type TransferEndpoint } from "@/services/transfers.service /** * `POST /api/transfers` (spec 2026-10-01 §5): copy (or diff-sync) a directory - * tree between two AGENT nodes, relayed by the plane through the encrypted + * tree between two machine nodes, relayed by the plane through the encrypted * node link. The gate stack, in order and each for its own reason: * * - `requirePerm("transfers", "write")` - the new token key, with NO legacy @@ -79,7 +79,7 @@ async function gateEndpoint(which: "from" | "to", ep: TransferEndpoint, userId: if (row.kind === "local") { throwApiError({ code: BackendErrorCodes.BAD_REQUEST, - message: "transfers run between agent nodes; the control-plane host is not a transfer endpoint", + message: "transfers run between machine nodes; the control-plane host is not a transfer endpoint", doNotLog: true, }); } diff --git a/packages/mcp-core/src/server.ts b/packages/mcp-core/src/server.ts index adc21314d..71ef8215f 100644 --- a/packages/mcp-core/src/server.ts +++ b/packages/mcp-core/src/server.ts @@ -340,7 +340,7 @@ export function registerTools(server: McpServer, deps: { api: ToolApi; own: Iden { title: "Transfer files between nodes", description: - "Copy (or diff-sync with sync:true) a directory tree between two AGENT nodes the caller owns, relayed as one streamed archive through the plane - no tar or rsync on either machine, no per-file round trips. Both endpoints must be agent nodes (the control-plane host and the browser are not transfer endpoints); each node's operator allowlist gates its directory, and an empty allowlist means unrestricted. Extraction is ADDITIVE: matching files at the destination are overwritten and nothing is deleted. The call is synchronous and capped (4 GiB uncompressed per archive); a large transfer's wall clock is dominated by round trips to the two nodes, not by their bandwidth, so budget your turn accordingly; an interrupted transfer leaves no partial tree - re-running it is the retry. A refusal naming 'update the node' means one endpoint's agent predates this feature, and only a human can update it. A permissions refusal on YOUR OWN call means your pane's token was minted before this feature existed: a human must restart your pane to renew it - never restart your own pane.", + "Copy (or diff-sync with sync:true) a directory tree between two MACHINE nodes the caller owns, relayed as one streamed archive through the plane - no tar or rsync on either machine, no per-file round trips. Both endpoints must be machine nodes (the control-plane host and the browser are not transfer endpoints); each node's operator allowlist gates its directory, and an empty allowlist means unrestricted. Extraction is ADDITIVE: matching files at the destination are overwritten and nothing is deleted. The call is synchronous and capped (4 GiB uncompressed per archive); a large transfer's wall clock is dominated by round trips to the two nodes, not by their bandwidth, so budget your turn accordingly; an interrupted transfer leaves no partial tree - re-running it is the retry. A refusal naming 'update the node' means one endpoint's daemon predates this feature, and only a human can update it. A permissions refusal on YOUR OWN call means your pane's token was minted before this feature existed: a human must restart your pane to renew it - never restart your own pane.", inputSchema: TransferFilesToolSchema, }, guard( From f3b40a16fdc4ab6dce0403156307e349eb2956f7 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Sat, 10 Oct 2026 20:50:54 -0700 Subject: [PATCH 10/14] =?UTF-8?q?fix(copy):=20the=20fifth=20round's=20find?= =?UTF-8?q?ings=20=E2=80=94=20the=20broken=20pin,=20the=20docs=20twins,=20?= =?UTF-8?q?and=20three=20strays?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transfer-route test pinned toContain('agent nodes'), a shorter fragment than the message the last commit re-worded: it failed the moment that shipped (9/9 now, on 'machine nodes'). The public MCP tools page duplicated the server description's two sentences verbatim, so it moved with them. Three OpenAPI prose sites join the swept class ('an agent node' -> 'a machine node', 'an enrolled agent' -> 'an enrolled machine node', per the models.ts and reregister precedent this PR set). The Subshell Server app's reset confirmation - the app no sweep had visited - says 'a node agent on this machine keep running'; that is the node daemon. And the dashboard's maxBytes schema line says agent log beside a file this PR renamed. Co-Authored-By: Claude --- apps/docs/content/docs/mcp/tools.mdx | 4 ++-- apps/node/agent/src/dashboard/routes.ts | 2 +- apps/server/api/src/api/nodes/node-view.ts | 4 ++-- apps/server/api/src/api/nodes/rename-node.route.ts | 2 +- .../api/src/api/transfers/__tests__/transfer-route.test.ts | 4 ++-- apps/server/desktop/ui/src/screens/reset-dialog.tsx | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/docs/content/docs/mcp/tools.mdx b/apps/docs/content/docs/mcp/tools.mdx index 90d17b71f..52c4fe676 100644 --- a/apps/docs/content/docs/mcp/tools.mdx +++ b/apps/docs/content/docs/mcp/tools.mdx @@ -122,9 +122,9 @@ Text and Enter are ordered input operations, not an atomic transaction. An exite Returns `{ sync, archiveBytes, files, bytes, changed }`. The tree rides one streamed archive through the control plane; neither machine needs `tar` or `rsync`. -Both endpoints must be agent nodes the caller owns, online, and out of maintenance; the control-plane host and the browser are not transfer endpoints. Each node's operator directory list gates its side, and an empty list means unrestricted. Extraction is additive: matching files at the destination are overwritten and nothing is deleted, so re-running a finished `sync` converges and copies nothing. +Both endpoints must be machine nodes the caller owns, online, and out of maintenance; the control-plane host and the browser are not transfer endpoints. Each node's operator directory list gates its side, and an empty list means unrestricted. Extraction is additive: matching files at the destination are overwritten and nothing is deleted, so re-running a finished `sync` converges and copies nothing. -The call is synchronous and capped at 4 GiB uncompressed per archive. An interrupted transfer leaves no partial destination tree; re-running it is the retry. A refusal naming `update the node` means one endpoint's agent predates this feature; a permissions refusal on your own call means your pane's token predates the feature and a human must restart it. +The call is synchronous and capped at 4 GiB uncompressed per archive. An interrupted transfer leaves no partial destination tree; re-running it is the retry. A refusal naming `update the node` means one endpoint's daemon predates this feature; a permissions refusal on your own call means your pane's token predates the feature and a human must restart it. ## exec_in_terminal diff --git a/apps/node/agent/src/dashboard/routes.ts b/apps/node/agent/src/dashboard/routes.ts index 0619ca0f7..b5c728759 100644 --- a/apps/node/agent/src/dashboard/routes.ts +++ b/apps/node/agent/src/dashboard/routes.ts @@ -337,7 +337,7 @@ export function buildRoutes( query: t.Object({ fromByte: t.Optional(t.Numeric({ description: "Byte offset to read from (0 = start; clamped at EOF)" })), maxBytes: t.Optional( - t.Numeric({ description: "Max bytes to return (capped at the agent log's own limit)" }), + t.Numeric({ description: "Max bytes to return (capped at the daemon log's own cap)" }), ), }), }, diff --git a/apps/server/api/src/api/nodes/node-view.ts b/apps/server/api/src/api/nodes/node-view.ts index 8ff0f8d55..0dfdac980 100644 --- a/apps/server/api/src/api/nodes/node-view.ts +++ b/apps/server/api/src/api/nodes/node-view.ts @@ -127,7 +127,7 @@ export const NodeViewSchema = t.Object({ }), canLaunch: t.Boolean({ description: - "Whether the caller may start a subshell here. Any share grants it on an agent node; on `local` it is the GRANTED access alone, so switching off launching on the server applies to admins too, which is why this node can be visible and unlaunchable at once", + "Whether the caller may start a subshell here. Any share grants it on a machine node; on `local` it is the GRANTED access alone, so switching off launching on the server applies to admins too, which is why this node can be visible and unlaunchable at once", }), capabilities: t.Array(t.String({ description: "Capability string" }), { description: "Capability strings from `ready` (empty when none reported)", @@ -269,7 +269,7 @@ export const GetNodeResponseSchema = t.Object({ runtime: t.Optional( t.Object(NodeRuntimeSchema.properties, { description: - "How the node runs, present only while the node is online, only for config-capable viewers, and only on agent nodes", + "How the node runs, present only while the node is online, only for config-capable viewers, and only on machine nodes", }), ), runningSubshells: t.Optional( diff --git a/apps/server/api/src/api/nodes/rename-node.route.ts b/apps/server/api/src/api/nodes/rename-node.route.ts index 70e25e1e5..110911b2a 100644 --- a/apps/server/api/src/api/nodes/rename-node.route.ts +++ b/apps/server/api/src/api/nodes/rename-node.route.ts @@ -108,7 +108,7 @@ export const renameNodeRoute = new Elysia() operationId: "renameNode", tags: ["nodes"], description: - "Rename a node. Owner-only for an enrolled agent; the control-plane host's own row is managed by an admin", + "Rename a node. Owner-only for an enrolled machine node; the control-plane host's own row is managed by an admin", }, }, ); diff --git a/apps/server/api/src/api/transfers/__tests__/transfer-route.test.ts b/apps/server/api/src/api/transfers/__tests__/transfer-route.test.ts index 730c9bce8..1d77ab6bd 100644 --- a/apps/server/api/src/api/transfers/__tests__/transfer-route.test.ts +++ b/apps/server/api/src/api/transfers/__tests__/transfer-route.test.ts @@ -147,7 +147,7 @@ describe("POST /api/transfers gates", () => { }), ); expect(asFrom.status).toBe(400); - expect(((await asFrom.json()) as { message: string }).message).toContain("agent nodes"); + expect(((await asFrom.json()) as { message: string }).message).toContain("machine nodes"); // Reversed pair: the `to` side names `local`, so the `from` must be a // live agent for the call to reach that gate at all (from gates first). fakePair(mine, localId, new Uint8Array(1)); @@ -158,7 +158,7 @@ describe("POST /api/transfers gates", () => { }), ); expect(asTo.status).toBe(400); - expect(((await asTo.json()) as { message: string }).message).toContain("agent nodes"); + expect(((await asTo.json()) as { message: string }).message).toContain("machine nodes"); }); it("409s maintenance ahead of offline, and offline for a dead socket", async () => { diff --git a/apps/server/desktop/ui/src/screens/reset-dialog.tsx b/apps/server/desktop/ui/src/screens/reset-dialog.tsx index 84ff7eb37..a726a277a 100644 --- a/apps/server/desktop/ui/src/screens/reset-dialog.tsx +++ b/apps/server/desktop/ui/src/screens/reset-dialog.tsx @@ -110,7 +110,7 @@ export function ResetDialog(props: { )}
{ - "Enrolled remote nodes and a node agent on this machine keep running and are NOT touched. Only the locations listed above are deleted, permanently; the installed server binary stays." + "Enrolled remote nodes and a node daemon on this machine keep running and are NOT touched. Only the locations listed above are deleted, permanently; the installed server binary stays." }