From fd695e70b9b12ba321e5e8ec1d365f2790aa7799 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Tue, 11 Aug 2026 18:55:07 +0200 Subject: [PATCH 1/8] feat(cli): attach to physical fleet nodes over SSH --- .../2026-08/traj_jzmkf8ctib43/summary.md | 33 ++++ .../2026-08/traj_jzmkf8ctib43/trajectory.json | 63 ++++++++ CHANGELOG.md | 1 + .../cli/src/cli/commands/local-agent.test.ts | 31 +++- packages/cli/src/cli/commands/local-agent.ts | 30 +++- .../src/cli/lib/attach-remote-node.test.ts | 77 ++++++++++ .../cli/src/cli/lib/attach-remote-node.ts | 145 ++++++++++++++++++ 7 files changed, 378 insertions(+), 2 deletions(-) create mode 100644 .agentworkforce/trajectories/completed/2026-08/traj_jzmkf8ctib43/summary.md create mode 100644 .agentworkforce/trajectories/completed/2026-08/traj_jzmkf8ctib43/trajectory.json create mode 100644 packages/cli/src/cli/lib/attach-remote-node.test.ts create mode 100644 packages/cli/src/cli/lib/attach-remote-node.ts diff --git a/.agentworkforce/trajectories/completed/2026-08/traj_jzmkf8ctib43/summary.md b/.agentworkforce/trajectories/completed/2026-08/traj_jzmkf8ctib43/summary.md new file mode 100644 index 000000000..77ffc10de --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-08/traj_jzmkf8ctib43/summary.md @@ -0,0 +1,33 @@ +# Trajectory: Implement physical fleet cross-node attach using barry Chief as proof + +> **Status:** ✅ Completed +> **Task:** relay#1449 +> **Confidence:** 96% +> **Started:** August 11, 2026 at 06:30 PM +> **Completed:** August 11, 2026 at 06:45 PM + +--- + +## Summary + +Added SSH-backed --node physical-fleet attach, protected broker credentials, covered input validation and CLI routing, and proved live view/drive against Chief on Barry. + +**Approach:** Reproduced the live gap, proved the existing terminal protocol through a secure forward, then encapsulated target-local attach behind an injection-safe SSH transport and verified the exact built CLI. + +--- + +## Key Decisions + +### Use SSH remote execution for the physical-node P0 behind --node +- **Chose:** Use SSH remote execution for the physical-node P0 behind --node +- **Reasoning:** Live view and drive both succeeded against Barry through SSH while the broker stayed loopback-only. Running the existing attach command on the target keeps broker credentials off the wire, preserves all three attach modes, requires no new terminal protocol, and works with the currently installed remote CLI. Cloud/Daytona ticket routing can remain a later transport behind the same flag. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Use SSH remote execution for the physical-node P0 behind --node: Use SSH remote execution for the physical-node P0 behind --node +- Physical-node cross-node attach is viable without a new PTY transport: local --node delegates over SSH to the target's existing attach command, leaving the loopback broker and API key on-host. Barry live proof passed for view and drive (NODE_DRIVE_OK); 226 attach tests, lint, format, and full workspace/CLI builds passed. diff --git a/.agentworkforce/trajectories/completed/2026-08/traj_jzmkf8ctib43/trajectory.json b/.agentworkforce/trajectories/completed/2026-08/traj_jzmkf8ctib43/trajectory.json new file mode 100644 index 000000000..a08d432a6 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-08/traj_jzmkf8ctib43/trajectory.json @@ -0,0 +1,63 @@ +{ + "id": "traj_jzmkf8ctib43", + "version": 1, + "task": { + "title": "Implement physical fleet cross-node attach using barry Chief as proof", + "source": { + "system": "plain", + "id": "relay#1449" + } + }, + "status": "completed", + "startedAt": "2026-08-11T16:30:06.251Z", + "completedAt": "2026-08-11T16:45:11.456Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-08-11T16:35:31.280Z" + } + ], + "chapters": [ + { + "id": "chap_56dogkyrumvx", + "title": "Work", + "agentName": "default", + "startedAt": "2026-08-11T16:35:31.280Z", + "endedAt": "2026-08-11T16:45:11.456Z", + "events": [ + { + "ts": 1786466131280, + "type": "decision", + "content": "Use SSH remote execution for the physical-node P0 behind --node: Use SSH remote execution for the physical-node P0 behind --node", + "raw": { + "question": "Use SSH remote execution for the physical-node P0 behind --node", + "chosen": "Use SSH remote execution for the physical-node P0 behind --node", + "alternatives": [], + "reasoning": "Live view and drive both succeeded against Barry through SSH while the broker stayed loopback-only. Running the existing attach command on the target keeps broker credentials off the wire, preserves all three attach modes, requires no new terminal protocol, and works with the currently installed remote CLI. Cloud/Daytona ticket routing can remain a later transport behind the same flag." + }, + "significance": "high" + }, + { + "ts": 1786466710854, + "type": "reflection", + "content": "Physical-node cross-node attach is viable without a new PTY transport: local --node delegates over SSH to the target's existing attach command, leaving the loopback broker and API key on-host. Barry live proof passed for view and drive (NODE_DRIVE_OK); 226 attach tests, lint, format, and full workspace/CLI builds passed.", + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Added SSH-backed --node physical-fleet attach, protected broker credentials, covered input validation and CLI routing, and proved live view/drive against Chief on Barry.", + "approach": "Reproduced the live gap, proved the existing terminal protocol through a secure forward, then encapsulated target-local attach behind an injection-safe SSH transport and verified the exact built CLI.", + "confidence": 0.96 + }, + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relay", + "tags": [], + "_trace": { + "startRef": "542c4df425326b42b24c53ac0ca7359feb88c0d4", + "endRef": "542c4df425326b42b24c53ac0ca7359feb88c0d4" + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 091e95b17..a3e2254f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `agent-relay node agent attach --node ` now attaches to agents on SSH-reachable physical fleet nodes without exporting the remote broker or its API key. - Spawned agents now stamp a `Session-Id:` git trailer on commits when the dispatcher supplies a session reference, enabling auditors to trace each commit back to the session that produced it. - `agent-relay node agent attach` now distinguishes between "agent does not exist" and "agent is running on a different fleet node": when a 404 resolves to a workspace-registered agent with a fleet placement, the error names the node (`agent 'X' is registered on node 'finn-mini'; cross-node attach is not yet supported`) instead of the indistinguishable "no agent named 'X'". diff --git a/packages/cli/src/cli/commands/local-agent.test.ts b/packages/cli/src/cli/commands/local-agent.test.ts index f40620b17..cf3db0212 100644 --- a/packages/cli/src/cli/commands/local-agent.test.ts +++ b/packages/cli/src/cli/commands/local-agent.test.ts @@ -26,12 +26,14 @@ function harness(overrides: Partial = {}) { setInboundDeliveryMode: vi.fn(async (_name: string, mode: string) => ({ mode, flushed: 0 })), }; const attach = vi.fn(async () => 0); + const attachRemote = vi.fn(async () => 0); const log = vi.fn(); const error = vi.fn(); const exit = vi.fn(); const deps: Partial = { connect: vi.fn(async () => client as never), attach, + attachRemote, cwd: () => '/tmp/project', log, error, @@ -42,7 +44,7 @@ function harness(overrides: Partial = {}) { program.exitOverride(); const group = program.command('local'); registerLocalAgentCommands(group, deps); - return { program, client, attach, log, error, exit }; + return { program, client, attach, attachRemote, log, error, exit }; } describe('local agent subtree', () => { @@ -70,6 +72,33 @@ describe('local agent subtree', () => { ); }); + it('attach --node runs the existing attach command on an SSH-reachable physical node', async () => { + const { program, attach, attachRemote } = harness(); + await program.parseAsync( + ['local', 'agent', 'attach', 'lead', '--node', 'barry', '--mode', 'drive', '--reasoning'], + { from: 'user' } + ); + expect(attach).not.toHaveBeenCalled(); + expect(attachRemote).toHaveBeenCalledWith( + 'lead', + 'drive', + 'barry', + expect.objectContaining({ reasoning: true }) + ); + }); + + it('attach --node rejects raw broker credentials because they must stay on the target', async () => { + const { program, attach, attachRemote, error, exit } = harness(); + await program.parseAsync( + ['local', 'agent', 'attach', 'lead', '--node', 'barry', '--api-key', 'do-not-forward'], + { from: 'user' } + ); + expect(attach).not.toHaveBeenCalled(); + expect(attachRemote).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith(expect.stringContaining('--node cannot be combined')); + expect(exit).toHaveBeenCalledWith(1); + }); + it('attach rejects an unknown mode', async () => { const { program, attach, error, exit } = harness(); await program.parseAsync(['local', 'agent', 'attach', 'lead', '--mode', 'bogus'], { from: 'user' }); diff --git a/packages/cli/src/cli/commands/local-agent.ts b/packages/cli/src/cli/commands/local-agent.ts index 94182ae23..c34aa645e 100644 --- a/packages/cli/src/cli/commands/local-agent.ts +++ b/packages/cli/src/cli/commands/local-agent.ts @@ -10,6 +10,7 @@ import { createBrokerClient } from '../lib/attach-broker.js'; import { attachDrive } from '../lib/attach-drive.js'; import { attachNative, isNativeHarness, type NativeAttachOptions } from '../lib/attach-native.js'; import { attachPassthrough } from '../lib/attach-passthrough.js'; +import { attachRemoteNode, type RemoteNodeAttachOptions } from '../lib/attach-remote-node.js'; import { attachView } from '../lib/attach-view.js'; import { defaultStateDir, @@ -80,6 +81,12 @@ export interface LocalAgentDependencies { connect: (cwd: string) => Promise; connectLocal: (cwd: string, options: LocalAgentMessageBrokerOptions) => Promise; attach: (name: string, mode: AttachMode, options: NativeAttachOptions) => Promise; + attachRemote: ( + name: string, + mode: AttachMode, + node: string, + options: RemoteNodeAttachOptions + ) => Promise; cwd: () => string; readConnectionFile: (stateDir: string) => unknown; getDefaultStateDir: () => string; @@ -100,6 +107,7 @@ function withDefaults(overrides: Partial = {}): LocalAge env: process.env, fetch: globalThis.fetch, attach: runAttach, + attachRemote: attachRemoteNode, log: (...args: unknown[]) => console.log(...args), error: (...args: unknown[]) => console.error(...args), exit: defaultExit, @@ -482,9 +490,13 @@ export function registerLocalAgentCommands( .description('Attach to a running agent interactively (drive | view | passthrough)') .argument('', 'Agent name') .option('--mode ', 'drive | view | passthrough', 'view') + .option('--node ', 'SSH-reachable physical fleet node name or host') .option('--broker-url ', 'Broker base URL (overrides RELAY_BROKER_URL and connection.json)') .option('--api-key ', 'Broker API key (overrides RELAY_BROKER_API_KEY and connection.json)') - .option('--state-dir ', 'Directory containing connection.json (default: .agentworkforce/relay/)') + .option( + '--state-dir ', + 'Directory containing connection.json (with --node: path on target; default: ~/.agentworkforce/relay/-node/state)' + ) .option('--json', 'Emit normalized agent events as NDJSON') .option('--reasoning', 'Include agent reasoning events') .option('--diagnostics', 'Include native harness diagnostics') @@ -495,6 +507,22 @@ export function registerLocalAgentCommands( deps.exit(1); return; } + const node = options.node as string | undefined; + if (node) { + if (options.brokerUrl || options.apiKey) { + deps.error('Error: --node cannot be combined with --broker-url or --api-key.'); + deps.exit(1); + return; + } + const code = await deps.attachRemote(name, mode, node, { + stateDir: options.stateDir as string | undefined, + json: options.json as boolean | undefined, + reasoning: options.reasoning as boolean | undefined, + diagnostics: options.diagnostics as boolean | undefined, + }); + if (code !== 0) deps.exit(code); + return; + } const code = await deps.attach(name, mode, { brokerUrl: options.brokerUrl as string | undefined, apiKey: options.apiKey as string | undefined, diff --git a/packages/cli/src/cli/lib/attach-remote-node.test.ts b/packages/cli/src/cli/lib/attach-remote-node.test.ts new file mode 100644 index 000000000..b11e9c0da --- /dev/null +++ b/packages/cli/src/cli/lib/attach-remote-node.test.ts @@ -0,0 +1,77 @@ +import { EventEmitter } from 'node:events'; + +import { describe, expect, it, vi } from 'vitest'; + +import { attachRemoteNode, buildRemoteNodeAttachCommand, quoteRemoteArg } from './attach-remote-node.js'; + +describe('buildRemoteNodeAttachCommand', () => { + it('uses the standard fleet-node state directory without moving the broker credential off-host', () => { + expect(buildRemoteNodeAttachCommand('chief-barry', 'drive', 'barry', {})).toEqual({ + host: 'barry', + command: + 'relay_state="$HOME"/.agentworkforce/relay/\'barry-node\'/state; if [ ! -f "$relay_state/connection.json" ]; then set -- "$HOME"/.agentworkforce/relay/*-node/state/connection.json; if [ "$#" -eq 1 ] && [ -f "$1" ]; then relay_state=${1%/connection.json}; else printf \'%s\\n\' \'Error: could not uniquely find the fleet broker state directory; pass --state-dir.\' >&2; exit 78; fi; fi; exec agent-relay node agent attach \'chief-barry\' --mode \'drive\' --state-dir "$relay_state"', + }); + }); + + it('falls back to the only fleet broker state directory when the SSH alias differs', () => { + const target = buildRemoteNodeAttachCommand('chief-barry', 'view', 'barry-vpn', {}); + expect(target?.command).toContain('relay_state="$HOME"/.agentworkforce/relay/\'barry-vpn-node\'/state'); + expect(target?.command).toContain('set -- "$HOME"/.agentworkforce/relay/*-node/state/connection.json'); + expect(target?.command).toContain('could not uniquely find'); + }); + + it('quotes agent names and explicit remote state paths as shell data', () => { + const target = buildRemoteNodeAttachCommand("lead'; touch /tmp/nope; '", 'view', 'ops@barry', { + stateDir: '/Users/ops/relay state', + json: true, + diagnostics: true, + }); + expect(target?.host).toBe('ops@barry'); + expect(target?.command).toContain(quoteRemoteArg("lead'; touch /tmp/nope; '")); + expect(target?.command).toContain("--state-dir '/Users/ops/relay state'"); + expect(target?.command).toMatch(/--json --diagnostics$/); + }); + + it('rejects option-like or shell-bearing node names', () => { + expect(buildRemoteNodeAttachCommand('lead', 'view', '-oProxyCommand=bad', {})).toBeNull(); + expect(buildRemoteNodeAttachCommand('lead', 'view', 'barry;touch /tmp/nope', {})).toBeNull(); + }); +}); + +describe('attachRemoteNode', () => { + it('preserves the remote attach exit code and allocates a TTY', async () => { + const child = new EventEmitter(); + const spawn = vi.fn(() => child as never); + const result = attachRemoteNode('lead', 'passthrough', 'barry', {}, { spawn, error: vi.fn() }); + child.emit('exit', 7, null); + await expect(result).resolves.toBe(7); + expect(spawn).toHaveBeenCalledWith( + 'ssh', + expect.arrayContaining(['-tt', 'barry']), + expect.objectContaining({ stdio: 'inherit' }) + ); + }); + + it('distinguishes an unreachable SSH node', async () => { + const child = new EventEmitter(); + const spawn = vi.fn(() => child as never); + const error = vi.fn(); + const result = attachRemoteNode('lead', 'view', 'barry', {}, { spawn, error }); + child.emit('exit', 255, null); + await expect(result).resolves.toBe(255); + expect(error).toHaveBeenCalledWith("Error: node 'barry' is not reachable over SSH."); + }); + + it('disables the SSH TTY for machine-readable JSON output', async () => { + const child = new EventEmitter(); + const spawn = vi.fn(() => child as never); + const result = attachRemoteNode('lead', 'view', 'barry', { json: true }, { spawn, error: vi.fn() }); + child.emit('exit', 0, null); + await expect(result).resolves.toBe(0); + expect(spawn).toHaveBeenCalledWith( + 'ssh', + expect.arrayContaining(['-T', 'barry']), + expect.objectContaining({ stdio: 'inherit' }) + ); + }); +}); diff --git a/packages/cli/src/cli/lib/attach-remote-node.ts b/packages/cli/src/cli/lib/attach-remote-node.ts new file mode 100644 index 000000000..e2053f555 --- /dev/null +++ b/packages/cli/src/cli/lib/attach-remote-node.ts @@ -0,0 +1,145 @@ +import { spawn as spawnChildProcess, type ChildProcess, type SpawnOptions } from 'node:child_process'; + +import type { NativeAttachOptions } from './attach-native.js'; + +export type RemoteAttachMode = 'drive' | 'view' | 'passthrough'; + +export type RemoteNodeAttachOptions = Pick< + NativeAttachOptions, + 'stateDir' | 'json' | 'reasoning' | 'diagnostics' +>; + +export interface RemoteNodeAttachDependencies { + spawn(command: string, args: readonly string[], options: SpawnOptions): ChildProcess; + error(message: string): void; +} + +function defaultDependencies(): RemoteNodeAttachDependencies { + return { + spawn: (command, args, options) => spawnChildProcess(command, [...args], options), + error: (message) => console.error(message), + }; +} + +/** Quote one argument for the target host's POSIX login shell. */ +export function quoteRemoteArg(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function validateSshNode(node: string): string | null { + const trimmed = node.trim(); + if (!trimmed || trimmed.startsWith('-') || !/^[A-Za-z0-9_.@-]+$/.test(trimmed)) return null; + return trimmed; +} + +function defaultRemoteStateDir(node: string): string { + const host = node.slice(node.lastIndexOf('@') + 1); + return `"$HOME"/.agentworkforce/relay/${quoteRemoteArg(`${host}-node`)}/state`; +} + +function explicitRemoteStateDir(override: string): string { + const trimmed = override.trim(); + if (trimmed === '~') return '"$HOME"'; + if (trimmed.startsWith('~/')) return `"$HOME"/${quoteRemoteArg(trimmed.slice(2))}`; + return quoteRemoteArg(trimmed); +} + +function remoteStateSelection( + node: string, + override: string | undefined +): { setup: string[]; argument: string } { + const trimmed = override?.trim(); + if (trimmed) return { setup: [], argument: explicitRemoteStateDir(trimmed) }; + + const expected = defaultRemoteStateDir(node); + const discoveryError = quoteRemoteArg( + 'Error: could not uniquely find the fleet broker state directory; pass --state-dir.' + ); + return { + setup: [ + `relay_state=${expected}`, + 'if [ ! -f "$relay_state/connection.json" ]; then ' + + 'set -- "$HOME"/.agentworkforce/relay/*-node/state/connection.json; ' + + 'if [ "$#" -eq 1 ] && [ -f "$1" ]; then ' + + 'relay_state=${1%/connection.json}; ' + + `else printf '%s\\n' ${discoveryError} >&2; exit 78; fi; fi`, + ], + argument: '"$relay_state"', + }; +} + +export function buildRemoteNodeAttachCommand( + agentName: string, + mode: RemoteAttachMode, + node: string, + options: RemoteNodeAttachOptions +): { host: string; command: string } | null { + const host = validateSshNode(node); + if (!host) return null; + const state = remoteStateSelection(host, options.stateDir); + + const args = [ + 'agent-relay', + 'node', + 'agent', + 'attach', + quoteRemoteArg(agentName), + '--mode', + quoteRemoteArg(mode), + '--state-dir', + state.argument, + ]; + if (options.json) args.push('--json'); + if (options.reasoning) args.push('--reasoning'); + if (options.diagnostics) args.push('--diagnostics'); + return { host, command: [...state.setup, `exec ${args.join(' ')}`].join('; ') }; +} + +/** + * Attach through an SSH-reachable physical fleet node without exporting the + * broker listener or copying its API key off the host. The existing remote + * attach command owns the terminal protocol and mode semantics. + */ +export async function attachRemoteNode( + agentName: string, + mode: RemoteAttachMode, + node: string, + options: RemoteNodeAttachOptions, + overrides: Partial = {} +): Promise { + const deps = { ...defaultDependencies(), ...overrides }; + const target = buildRemoteNodeAttachCommand(agentName, mode, node, options); + if (!target) { + deps.error(`Error: invalid SSH fleet node ${JSON.stringify(node)}.`); + return 1; + } + + return await new Promise((resolve) => { + let settled = false; + const finish = (code: number) => { + if (settled) return; + settled = true; + resolve(code); + }; + // JSON mode is a machine-readable stream: no remote PTY, login banner, or + // stderr/stdout merging. Interactive modes require a forced TTY even when + // this local CLI's stdin is itself attached indirectly. + const ttyFlag = options.json ? '-T' : '-tt'; + const child = deps.spawn('ssh', [ttyFlag, target.host, target.command], { stdio: 'inherit' }); + child.once('error', (error) => { + deps.error(`Error: could not start SSH attach to node '${target.host}': ${error.message}`); + finish(1); + }); + child.once('exit', (code, signal) => { + if (signal) { + deps.error(`Error: SSH attach to node '${target.host}' ended by signal ${signal}.`); + finish(1); + return; + } + if (code === 255) { + deps.error(`Error: node '${target.host}' is not reachable over SSH.`); + } + finish(code ?? 1); + }); + }); +} From e3039bc33db8bcbcd4c9bba71e26dd89d97c0e6b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 11 Aug 2026 16:56:42 +0000 Subject: [PATCH 2/8] style: auto-format with Prettier --- .../completed/2026-08/traj_jzmkf8ctib43/summary.md | 4 +++- .../completed/2026-08/traj_jzmkf8ctib43/trajectory.json | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.agentworkforce/trajectories/completed/2026-08/traj_jzmkf8ctib43/summary.md b/.agentworkforce/trajectories/completed/2026-08/traj_jzmkf8ctib43/summary.md index 77ffc10de..125023c82 100644 --- a/.agentworkforce/trajectories/completed/2026-08/traj_jzmkf8ctib43/summary.md +++ b/.agentworkforce/trajectories/completed/2026-08/traj_jzmkf8ctib43/summary.md @@ -19,6 +19,7 @@ Added SSH-backed --node physical-fleet attach, protected broker credentials, cov ## Key Decisions ### Use SSH remote execution for the physical-node P0 behind --node + - **Chose:** Use SSH remote execution for the physical-node P0 behind --node - **Reasoning:** Live view and drive both succeeded against Barry through SSH while the broker stayed loopback-only. Running the existing attach command on the target keeps broker credentials off the wire, preserves all three attach modes, requires no new terminal protocol, and works with the currently installed remote CLI. Cloud/Daytona ticket routing can remain a later transport behind the same flag. @@ -27,7 +28,8 @@ Added SSH-backed --node physical-fleet attach, protected broker credentials, cov ## Chapters ### 1. Work -*Agent: default* + +_Agent: default_ - Use SSH remote execution for the physical-node P0 behind --node: Use SSH remote execution for the physical-node P0 behind --node - Physical-node cross-node attach is viable without a new PTY transport: local --node delegates over SSH to the target's existing attach command, leaving the loopback broker and API key on-host. Barry live proof passed for view and drive (NODE_DRIVE_OK); 226 attach tests, lint, format, and full workspace/CLI builds passed. diff --git a/.agentworkforce/trajectories/completed/2026-08/traj_jzmkf8ctib43/trajectory.json b/.agentworkforce/trajectories/completed/2026-08/traj_jzmkf8ctib43/trajectory.json index a08d432a6..ebffc5b47 100644 --- a/.agentworkforce/trajectories/completed/2026-08/traj_jzmkf8ctib43/trajectory.json +++ b/.agentworkforce/trajectories/completed/2026-08/traj_jzmkf8ctib43/trajectory.json @@ -60,4 +60,4 @@ "startRef": "542c4df425326b42b24c53ac0ca7359feb88c0d4", "endRef": "542c4df425326b42b24c53ac0ca7359feb88c0d4" } -} \ No newline at end of file +} From 14ce3eae4a1134d08fca94de3c92224d43b3b9e0 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Tue, 11 Aug 2026 19:08:49 +0200 Subject: [PATCH 3/8] fix(cli): preserve fleet-native attach semantics --- CHANGELOG.md | 2 +- .../cli/src/cli/commands/local-agent.test.ts | 17 ++++++++++++----- packages/cli/src/cli/commands/local-agent.ts | 12 ++++++------ .../src/cli/lib/attach-remote-node.test.ts | 19 ++++++++++++++----- .../cli/src/cli/lib/attach-remote-node.ts | 17 ++++++++++++++++- 5 files changed, 49 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3e2254f6..0c4227804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `agent-relay node agent attach --node ` now attaches to agents on SSH-reachable physical fleet nodes without exporting the remote broker or its API key. +- `agent-relay node agent attach --ssh-host ` now provides an explicit SSH fallback for physical fleet nodes without exporting the remote broker or its API key; `--node` remains reserved for canonical fleet-native attach. - Spawned agents now stamp a `Session-Id:` git trailer on commits when the dispatcher supplies a session reference, enabling auditors to trace each commit back to the session that produced it. - `agent-relay node agent attach` now distinguishes between "agent does not exist" and "agent is running on a different fleet node": when a 404 resolves to a workspace-registered agent with a fleet placement, the error names the node (`agent 'X' is registered on node 'finn-mini'; cross-node attach is not yet supported`) instead of the indistinguishable "no agent named 'X'". diff --git a/packages/cli/src/cli/commands/local-agent.test.ts b/packages/cli/src/cli/commands/local-agent.test.ts index cf3db0212..c73b36d8f 100644 --- a/packages/cli/src/cli/commands/local-agent.test.ts +++ b/packages/cli/src/cli/commands/local-agent.test.ts @@ -72,10 +72,10 @@ describe('local agent subtree', () => { ); }); - it('attach --node runs the existing attach command on an SSH-reachable physical node', async () => { + it('attach --ssh-host runs the existing attach command on an SSH-reachable physical node', async () => { const { program, attach, attachRemote } = harness(); await program.parseAsync( - ['local', 'agent', 'attach', 'lead', '--node', 'barry', '--mode', 'drive', '--reasoning'], + ['local', 'agent', 'attach', 'lead', '--ssh-host', 'barry', '--mode', 'drive', '--reasoning'], { from: 'user' } ); expect(attach).not.toHaveBeenCalled(); @@ -87,15 +87,22 @@ describe('local agent subtree', () => { ); }); - it('attach --node rejects raw broker credentials because they must stay on the target', async () => { + it('attach --ssh-host delegates an empty value to remote host validation', async () => { + const { program, attach, attachRemote } = harness(); + await program.parseAsync(['local', 'agent', 'attach', 'lead', '--ssh-host', ''], { from: 'user' }); + expect(attach).not.toHaveBeenCalled(); + expect(attachRemote).toHaveBeenCalledWith('lead', 'view', '', expect.objectContaining({})); + }); + + it('attach --ssh-host rejects raw broker credentials because they must stay on the target', async () => { const { program, attach, attachRemote, error, exit } = harness(); await program.parseAsync( - ['local', 'agent', 'attach', 'lead', '--node', 'barry', '--api-key', 'do-not-forward'], + ['local', 'agent', 'attach', 'lead', '--ssh-host', 'barry', '--api-key', 'do-not-forward'], { from: 'user' } ); expect(attach).not.toHaveBeenCalled(); expect(attachRemote).not.toHaveBeenCalled(); - expect(error).toHaveBeenCalledWith(expect.stringContaining('--node cannot be combined')); + expect(error).toHaveBeenCalledWith(expect.stringContaining('--ssh-host cannot be combined')); expect(exit).toHaveBeenCalledWith(1); }); diff --git a/packages/cli/src/cli/commands/local-agent.ts b/packages/cli/src/cli/commands/local-agent.ts index c34aa645e..b64b5d15e 100644 --- a/packages/cli/src/cli/commands/local-agent.ts +++ b/packages/cli/src/cli/commands/local-agent.ts @@ -490,12 +490,12 @@ export function registerLocalAgentCommands( .description('Attach to a running agent interactively (drive | view | passthrough)') .argument('', 'Agent name') .option('--mode ', 'drive | view | passthrough', 'view') - .option('--node ', 'SSH-reachable physical fleet node name or host') + .option('--ssh-host ', 'SSH host fallback for a physical fleet node') .option('--broker-url ', 'Broker base URL (overrides RELAY_BROKER_URL and connection.json)') .option('--api-key ', 'Broker API key (overrides RELAY_BROKER_API_KEY and connection.json)') .option( '--state-dir ', - 'Directory containing connection.json (with --node: path on target; default: ~/.agentworkforce/relay/-node/state)' + 'Directory containing connection.json (with --ssh-host: path on target; auto-discovered when omitted)' ) .option('--json', 'Emit normalized agent events as NDJSON') .option('--reasoning', 'Include agent reasoning events') @@ -507,14 +507,14 @@ export function registerLocalAgentCommands( deps.exit(1); return; } - const node = options.node as string | undefined; - if (node) { + const sshHost = options.sshHost as string | undefined; + if (sshHost !== undefined) { if (options.brokerUrl || options.apiKey) { - deps.error('Error: --node cannot be combined with --broker-url or --api-key.'); + deps.error('Error: --ssh-host cannot be combined with --broker-url or --api-key.'); deps.exit(1); return; } - const code = await deps.attachRemote(name, mode, node, { + const code = await deps.attachRemote(name, mode, sshHost, { stateDir: options.stateDir as string | undefined, json: options.json as boolean | undefined, reasoning: options.reasoning as boolean | undefined, diff --git a/packages/cli/src/cli/lib/attach-remote-node.test.ts b/packages/cli/src/cli/lib/attach-remote-node.test.ts index b11e9c0da..1b4e1be3f 100644 --- a/packages/cli/src/cli/lib/attach-remote-node.test.ts +++ b/packages/cli/src/cli/lib/attach-remote-node.test.ts @@ -6,11 +6,11 @@ import { attachRemoteNode, buildRemoteNodeAttachCommand, quoteRemoteArg } from ' describe('buildRemoteNodeAttachCommand', () => { it('uses the standard fleet-node state directory without moving the broker credential off-host', () => { - expect(buildRemoteNodeAttachCommand('chief-barry', 'drive', 'barry', {})).toEqual({ - host: 'barry', - command: - 'relay_state="$HOME"/.agentworkforce/relay/\'barry-node\'/state; if [ ! -f "$relay_state/connection.json" ]; then set -- "$HOME"/.agentworkforce/relay/*-node/state/connection.json; if [ "$#" -eq 1 ] && [ -f "$1" ]; then relay_state=${1%/connection.json}; else printf \'%s\\n\' \'Error: could not uniquely find the fleet broker state directory; pass --state-dir.\' >&2; exit 78; fi; fi; exec agent-relay node agent attach \'chief-barry\' --mode \'drive\' --state-dir "$relay_state"', - }); + const target = buildRemoteNodeAttachCommand('chief-barry', 'drive', 'barry', {}); + expect(target?.host).toBe('barry'); + expect(target?.command).toContain('relay_state="$HOME"/.agentworkforce/relay/\'barry-node\'/state'); + expect(target?.command).toContain("exec agent-relay node agent attach 'chief-barry'"); + expect(target?.command).toContain('--state-dir "$relay_state"'); }); it('falls back to the only fleet broker state directory when the SSH alias differs', () => { @@ -20,6 +20,15 @@ describe('buildRemoteNodeAttachCommand', () => { expect(target?.command).toContain('could not uniquely find'); }); + it('discovers an ordinary project-local broker from the target agent process', () => { + const target = buildRemoteNodeAttachCommand('project-worker', 'view', 'build-host', {}); + expect(target?.command).toContain('agent-relay-broker pty --agent-name'); + expect(target?.command).toContain('target="$attach_agent"'); + expect(target?.command).toContain('/proc/$broker_pid/cwd'); + expect(target?.command).toContain('lsof -a -p "$broker_pid" -d cwd'); + expect(target?.command).toContain('relay_state="$broker_root/.agentworkforce/relay"'); + }); + it('quotes agent names and explicit remote state paths as shell data', () => { const target = buildRemoteNodeAttachCommand("lead'; touch /tmp/nope; '", 'view', 'ops@barry', { stateDir: '/Users/ops/relay state', diff --git a/packages/cli/src/cli/lib/attach-remote-node.ts b/packages/cli/src/cli/lib/attach-remote-node.ts index e2053f555..b9ea174e4 100644 --- a/packages/cli/src/cli/lib/attach-remote-node.ts +++ b/packages/cli/src/cli/lib/attach-remote-node.ts @@ -45,6 +45,7 @@ function explicitRemoteStateDir(override: string): string { } function remoteStateSelection( + agentName: string, node: string, override: string | undefined ): { setup: string[]; argument: string } { @@ -58,6 +59,20 @@ function remoteStateSelection( return { setup: [ `relay_state=${expected}`, + `attach_agent=${quoteRemoteArg(agentName)}`, + 'if [ ! -f "$relay_state/connection.json" ]; then ' + + `broker_pid=$(ps axww -o ppid= -o command= 2>/dev/null | awk -v target="$attach_agent" '${ + 'BEGIN { marker = "agent-relay-broker pty --agent-name " target } ' + + '{ pos = index($0, marker); if (pos > 0) { after = substr($0, pos + length(marker), 1); ' + + 'if (after == "" || after == " ") { print $1; exit } } }' + }'); ` + + 'broker_root=""; ' + + 'if [ -n "$broker_pid" ] && [ -e "/proc/$broker_pid/cwd" ]; then ' + + 'broker_root=$(readlink "/proc/$broker_pid/cwd" 2>/dev/null || true); ' + + 'elif [ -n "$broker_pid" ] && command -v lsof >/dev/null 2>&1; then ' + + 'broker_root=$(lsof -a -p "$broker_pid" -d cwd -Fn 2>/dev/null | sed -n "s/^n//p" | head -n 1); fi; ' + + 'if [ -n "$broker_root" ] && [ -f "$broker_root/.agentworkforce/relay/connection.json" ]; then ' + + 'relay_state="$broker_root/.agentworkforce/relay"; fi; fi', 'if [ ! -f "$relay_state/connection.json" ]; then ' + 'set -- "$HOME"/.agentworkforce/relay/*-node/state/connection.json; ' + 'if [ "$#" -eq 1 ] && [ -f "$1" ]; then ' + @@ -76,7 +91,7 @@ export function buildRemoteNodeAttachCommand( ): { host: string; command: string } | null { const host = validateSshNode(node); if (!host) return null; - const state = remoteStateSelection(host, options.stateDir); + const state = remoteStateSelection(agentName, host, options.stateDir); const args = [ 'agent-relay', From 3b74d2e60a14aae40ee4aa9988254bcca9f4b8a2 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Tue, 11 Aug 2026 19:10:23 +0200 Subject: [PATCH 4/8] refactor(cli): share attach mode type --- packages/cli/src/cli/commands/local-agent.ts | 3 ++- packages/cli/src/cli/lib/attach-mode.ts | 1 + packages/cli/src/cli/lib/attach-remote-node.ts | 7 +++---- 3 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/cli/lib/attach-mode.ts diff --git a/packages/cli/src/cli/commands/local-agent.ts b/packages/cli/src/cli/commands/local-agent.ts index b64b5d15e..c0d1a8d92 100644 --- a/packages/cli/src/cli/commands/local-agent.ts +++ b/packages/cli/src/cli/commands/local-agent.ts @@ -8,6 +8,7 @@ import { stripAnsiFast } from '@agent-relay/utils'; import { classifyTask, composeTeam, buildDirectorPrompt } from '../../auto/index.js'; import { createBrokerClient } from '../lib/attach-broker.js'; import { attachDrive } from '../lib/attach-drive.js'; +import type { AttachMode } from '../lib/attach-mode.js'; import { attachNative, isNativeHarness, type NativeAttachOptions } from '../lib/attach-native.js'; import { attachPassthrough } from '../lib/attach-passthrough.js'; import { attachRemoteNode, type RemoteNodeAttachOptions } from '../lib/attach-remote-node.js'; @@ -56,7 +57,7 @@ function resolveAutoSpawn( }; } -export type AttachMode = 'drive' | 'view' | 'passthrough'; +export type { AttachMode } from '../lib/attach-mode.js'; export type LocalAgentMessageBrokerOptions = BrokerConnectionOptions; /** Dispatch `local agent attach --mode` to the drive/view/passthrough session runners. */ diff --git a/packages/cli/src/cli/lib/attach-mode.ts b/packages/cli/src/cli/lib/attach-mode.ts new file mode 100644 index 000000000..b1107eece --- /dev/null +++ b/packages/cli/src/cli/lib/attach-mode.ts @@ -0,0 +1 @@ +export type AttachMode = 'drive' | 'view' | 'passthrough'; diff --git a/packages/cli/src/cli/lib/attach-remote-node.ts b/packages/cli/src/cli/lib/attach-remote-node.ts index b9ea174e4..b25aebb79 100644 --- a/packages/cli/src/cli/lib/attach-remote-node.ts +++ b/packages/cli/src/cli/lib/attach-remote-node.ts @@ -1,8 +1,7 @@ import { spawn as spawnChildProcess, type ChildProcess, type SpawnOptions } from 'node:child_process'; import type { NativeAttachOptions } from './attach-native.js'; - -export type RemoteAttachMode = 'drive' | 'view' | 'passthrough'; +import type { AttachMode } from './attach-mode.js'; export type RemoteNodeAttachOptions = Pick< NativeAttachOptions, @@ -85,7 +84,7 @@ function remoteStateSelection( export function buildRemoteNodeAttachCommand( agentName: string, - mode: RemoteAttachMode, + mode: AttachMode, node: string, options: RemoteNodeAttachOptions ): { host: string; command: string } | null { @@ -117,7 +116,7 @@ export function buildRemoteNodeAttachCommand( */ export async function attachRemoteNode( agentName: string, - mode: RemoteAttachMode, + mode: AttachMode, node: string, options: RemoteNodeAttachOptions, overrides: Partial = {} From 7fda94fbe57e92390bfa1931161fe4ad5fd6ff18 Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Tue, 11 Aug 2026 13:22:37 -0400 Subject: [PATCH 5/8] fix(cli): harden SSH attach argument handling --- .../cli/src/cli/commands/local-agent.test.ts | 13 +++-- packages/cli/src/cli/commands/local-agent.ts | 2 +- .../src/cli/lib/attach-remote-node.test.ts | 39 +++++++++---- .../cli/src/cli/lib/attach-remote-node.ts | 58 ++++++++++--------- 4 files changed, 68 insertions(+), 44 deletions(-) diff --git a/packages/cli/src/cli/commands/local-agent.test.ts b/packages/cli/src/cli/commands/local-agent.test.ts index c73b36d8f..6d4c2abdc 100644 --- a/packages/cli/src/cli/commands/local-agent.test.ts +++ b/packages/cli/src/cli/commands/local-agent.test.ts @@ -94,12 +94,15 @@ describe('local agent subtree', () => { expect(attachRemote).toHaveBeenCalledWith('lead', 'view', '', expect.objectContaining({})); }); - it('attach --ssh-host rejects raw broker credentials because they must stay on the target', async () => { + it.each([ + ['--api-key', 'do-not-forward'], + ['--api-key', ''], + ['--broker-url', ''], + ])('attach --ssh-host rejects conflicting %s values even when empty', async (flag, value) => { const { program, attach, attachRemote, error, exit } = harness(); - await program.parseAsync( - ['local', 'agent', 'attach', 'lead', '--ssh-host', 'barry', '--api-key', 'do-not-forward'], - { from: 'user' } - ); + await program.parseAsync(['local', 'agent', 'attach', 'lead', '--ssh-host', 'barry', flag, value], { + from: 'user', + }); expect(attach).not.toHaveBeenCalled(); expect(attachRemote).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith(expect.stringContaining('--ssh-host cannot be combined')); diff --git a/packages/cli/src/cli/commands/local-agent.ts b/packages/cli/src/cli/commands/local-agent.ts index c0d1a8d92..dca3abfd8 100644 --- a/packages/cli/src/cli/commands/local-agent.ts +++ b/packages/cli/src/cli/commands/local-agent.ts @@ -510,7 +510,7 @@ export function registerLocalAgentCommands( } const sshHost = options.sshHost as string | undefined; if (sshHost !== undefined) { - if (options.brokerUrl || options.apiKey) { + if (options.brokerUrl !== undefined || options.apiKey !== undefined) { deps.error('Error: --ssh-host cannot be combined with --broker-url or --api-key.'); deps.exit(1); return; diff --git a/packages/cli/src/cli/lib/attach-remote-node.test.ts b/packages/cli/src/cli/lib/attach-remote-node.test.ts index 1b4e1be3f..8942a491d 100644 --- a/packages/cli/src/cli/lib/attach-remote-node.test.ts +++ b/packages/cli/src/cli/lib/attach-remote-node.test.ts @@ -9,8 +9,9 @@ describe('buildRemoteNodeAttachCommand', () => { const target = buildRemoteNodeAttachCommand('chief-barry', 'drive', 'barry', {}); expect(target?.host).toBe('barry'); expect(target?.command).toContain('relay_state="$HOME"/.agentworkforce/relay/\'barry-node\'/state'); - expect(target?.command).toContain("exec agent-relay node agent attach 'chief-barry'"); + expect(target?.command).toContain("exec agent-relay node agent attach --mode 'drive'"); expect(target?.command).toContain('--state-dir "$relay_state"'); + expect(target?.command).toMatch(/-- 'chief-barry'$/); }); it('falls back to the only fleet broker state directory when the SSH alias differs', () => { @@ -23,22 +24,32 @@ describe('buildRemoteNodeAttachCommand', () => { it('discovers an ordinary project-local broker from the target agent process', () => { const target = buildRemoteNodeAttachCommand('project-worker', 'view', 'build-host', {}); expect(target?.command).toContain('agent-relay-broker pty --agent-name'); - expect(target?.command).toContain('target="$attach_agent"'); expect(target?.command).toContain('/proc/$broker_pid/cwd'); expect(target?.command).toContain('lsof -a -p "$broker_pid" -d cwd'); - expect(target?.command).toContain('relay_state="$broker_root/.agentworkforce/relay"'); + expect(target?.command).toContain('broker_state_ambiguous=0'); + expect(target?.command).toContain('if [ "$broker_state_ambiguous" -ne 0 ]'); + expect(target?.command).toContain('relay_state="$broker_state"'); }); - it('quotes agent names and explicit remote state paths as shell data', () => { - const target = buildRemoteNodeAttachCommand("lead'; touch /tmp/nope; '", 'view', 'ops@barry', { - stateDir: '/Users/ops/relay state', + it('quotes option-like agent names and exact remote state paths as shell data', () => { + const agentName = "-lead'; touch /tmp/nope; '\\agent"; + const stateDir = " /Users/ops/relay state'; touch /tmp/nope; ' "; + const target = buildRemoteNodeAttachCommand(agentName, 'view', 'ops@barry', { + stateDir, json: true, diagnostics: true, }); expect(target?.host).toBe('ops@barry'); - expect(target?.command).toContain(quoteRemoteArg("lead'; touch /tmp/nope; '")); - expect(target?.command).toContain("--state-dir '/Users/ops/relay state'"); - expect(target?.command).toMatch(/--json --diagnostics$/); + expect(target?.command).toContain(`--state-dir ${quoteRemoteArg(stateDir)}`); + expect(target?.command).toContain('--json --diagnostics -- '); + expect(target?.command.endsWith(`-- ${quoteRemoteArg(agentName)}`)).toBe(true); + }); + + it('passes discovery names through the environment without awk escape decoding', () => { + const target = buildRemoteNodeAttachCommand('back\\slash', 'view', 'barry', {}); + expect(target?.command).toContain('ATTACH_AGENT="$attach_agent" awk'); + expect(target?.command).toContain('ENVIRON["ATTACH_AGENT"]'); + expect(target?.command).not.toContain('awk -v target='); }); it('rejects option-like or shell-bearing node names', () => { @@ -79,8 +90,16 @@ describe('attachRemoteNode', () => { await expect(result).resolves.toBe(0); expect(spawn).toHaveBeenCalledWith( 'ssh', - expect.arrayContaining(['-T', 'barry']), + ['-T', '-n', 'barry', expect.any(String)], expect.objectContaining({ stdio: 'inherit' }) ); }); + + it('maps child signals to conventional shell exit codes', async () => { + const child = new EventEmitter(); + const spawn = vi.fn(() => child as never); + const result = attachRemoteNode('lead', 'view', 'barry', {}, { spawn, error: vi.fn() }); + child.emit('exit', null, 'SIGTERM'); + await expect(result).resolves.toBe(143); + }); }); diff --git a/packages/cli/src/cli/lib/attach-remote-node.ts b/packages/cli/src/cli/lib/attach-remote-node.ts index b25aebb79..0de379b2e 100644 --- a/packages/cli/src/cli/lib/attach-remote-node.ts +++ b/packages/cli/src/cli/lib/attach-remote-node.ts @@ -1,4 +1,5 @@ import { spawn as spawnChildProcess, type ChildProcess, type SpawnOptions } from 'node:child_process'; +import { constants as osConstants } from 'node:os'; import type { NativeAttachOptions } from './attach-native.js'; import type { AttachMode } from './attach-mode.js'; @@ -37,10 +38,9 @@ function defaultRemoteStateDir(node: string): string { } function explicitRemoteStateDir(override: string): string { - const trimmed = override.trim(); - if (trimmed === '~') return '"$HOME"'; - if (trimmed.startsWith('~/')) return `"$HOME"/${quoteRemoteArg(trimmed.slice(2))}`; - return quoteRemoteArg(trimmed); + if (override === '~') return '"$HOME"'; + if (override.startsWith('~/')) return `"$HOME"/${quoteRemoteArg(override.slice(2))}`; + return quoteRemoteArg(override); } function remoteStateSelection( @@ -48,30 +48,34 @@ function remoteStateSelection( node: string, override: string | undefined ): { setup: string[]; argument: string } { - const trimmed = override?.trim(); - if (trimmed) return { setup: [], argument: explicitRemoteStateDir(trimmed) }; + if (override?.trim()) return { setup: [], argument: explicitRemoteStateDir(override) }; const expected = defaultRemoteStateDir(node); const discoveryError = quoteRemoteArg( - 'Error: could not uniquely find the fleet broker state directory; pass --state-dir.' + 'Error: could not uniquely find the remote broker state directory; pass --state-dir.' ); return { setup: [ `relay_state=${expected}`, `attach_agent=${quoteRemoteArg(agentName)}`, 'if [ ! -f "$relay_state/connection.json" ]; then ' + - `broker_pid=$(ps axww -o ppid= -o command= 2>/dev/null | awk -v target="$attach_agent" '${ - 'BEGIN { marker = "agent-relay-broker pty --agent-name " target } ' + + `broker_pids=$(ps axww -o ppid= -o command= 2>/dev/null | ATTACH_AGENT="$attach_agent" awk '${ + 'BEGIN { marker = "agent-relay-broker pty --agent-name " ENVIRON["ATTACH_AGENT"] } ' + '{ pos = index($0, marker); if (pos > 0) { after = substr($0, pos + length(marker), 1); ' + - 'if (after == "" || after == " ") { print $1; exit } } }' + 'if (after == "" || after == " ") print $1 } }' }'); ` + - 'broker_root=""; ' + - 'if [ -n "$broker_pid" ] && [ -e "/proc/$broker_pid/cwd" ]; then ' + + 'broker_state=""; broker_state_ambiguous=0; ' + + 'for broker_pid in $broker_pids; do broker_root=""; ' + + 'if [ -e "/proc/$broker_pid/cwd" ]; then ' + 'broker_root=$(readlink "/proc/$broker_pid/cwd" 2>/dev/null || true); ' + - 'elif [ -n "$broker_pid" ] && command -v lsof >/dev/null 2>&1; then ' + + 'elif command -v lsof >/dev/null 2>&1; then ' + 'broker_root=$(lsof -a -p "$broker_pid" -d cwd -Fn 2>/dev/null | sed -n "s/^n//p" | head -n 1); fi; ' + - 'if [ -n "$broker_root" ] && [ -f "$broker_root/.agentworkforce/relay/connection.json" ]; then ' + - 'relay_state="$broker_root/.agentworkforce/relay"; fi; fi', + 'candidate="$broker_root/.agentworkforce/relay"; ' + + 'if [ -n "$broker_root" ] && [ -f "$candidate/connection.json" ]; then ' + + 'if [ -z "$broker_state" ]; then broker_state="$candidate"; ' + + 'elif [ "$broker_state" != "$candidate" ]; then broker_state_ambiguous=1; fi; fi; done; ' + + `if [ "$broker_state_ambiguous" -ne 0 ]; then printf '%s\\n' ${discoveryError} >&2; exit 78; ` + + 'elif [ -n "$broker_state" ]; then relay_state="$broker_state"; fi; fi', 'if [ ! -f "$relay_state/connection.json" ]; then ' + 'set -- "$HOME"/.agentworkforce/relay/*-node/state/connection.json; ' + 'if [ "$#" -eq 1 ] && [ -f "$1" ]; then ' + @@ -92,20 +96,14 @@ export function buildRemoteNodeAttachCommand( if (!host) return null; const state = remoteStateSelection(agentName, host, options.stateDir); - const args = [ - 'agent-relay', - 'node', - 'agent', - 'attach', - quoteRemoteArg(agentName), - '--mode', - quoteRemoteArg(mode), - '--state-dir', - state.argument, - ]; + const args = ['agent-relay', 'node', 'agent', 'attach', '--mode', quoteRemoteArg(mode)]; + args.push('--state-dir', state.argument); if (options.json) args.push('--json'); if (options.reasoning) args.push('--reasoning'); if (options.diagnostics) args.push('--diagnostics'); + // Keep option-like agent names as positional data without preventing the + // flags above from being parsed by Commander. + args.push('--', quoteRemoteArg(agentName)); return { host, command: [...state.setup, `exec ${args.join(' ')}`].join('; ') }; } @@ -139,7 +137,10 @@ export async function attachRemoteNode( // stderr/stdout merging. Interactive modes require a forced TTY even when // this local CLI's stdin is itself attached indirectly. const ttyFlag = options.json ? '-T' : '-tt'; - const child = deps.spawn('ssh', [ttyFlag, target.host, target.command], { stdio: 'inherit' }); + const sshArgs = options.json + ? [ttyFlag, '-n', target.host, target.command] + : [ttyFlag, target.host, target.command]; + const child = deps.spawn('ssh', sshArgs, { stdio: 'inherit' }); child.once('error', (error) => { deps.error(`Error: could not start SSH attach to node '${target.host}': ${error.message}`); finish(1); @@ -147,7 +148,8 @@ export async function attachRemoteNode( child.once('exit', (code, signal) => { if (signal) { deps.error(`Error: SSH attach to node '${target.host}' ended by signal ${signal}.`); - finish(1); + const signalNumber = osConstants.signals[signal]; + finish(signalNumber === undefined ? 1 : 128 + signalNumber); return; } if (code === 255) { From 505c66065e6b76192c883ef540c3b2b1a87a1fe0 Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Tue, 11 Aug 2026 13:24:17 -0400 Subject: [PATCH 6/8] test(cli): preserve whitespace-only remote state paths --- packages/cli/src/cli/lib/attach-remote-node.test.ts | 7 +++++++ packages/cli/src/cli/lib/attach-remote-node.ts | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cli/lib/attach-remote-node.test.ts b/packages/cli/src/cli/lib/attach-remote-node.test.ts index 8942a491d..870d5b795 100644 --- a/packages/cli/src/cli/lib/attach-remote-node.test.ts +++ b/packages/cli/src/cli/lib/attach-remote-node.test.ts @@ -52,6 +52,13 @@ describe('buildRemoteNodeAttachCommand', () => { expect(target?.command).not.toContain('awk -v target='); }); + it('preserves a whitespace-only explicit state directory like local attach', () => { + const target = buildRemoteNodeAttachCommand('lead', 'view', 'barry', { stateDir: ' ' }); + expect(target?.command).toBe( + "exec agent-relay node agent attach --mode 'view' --state-dir ' ' -- 'lead'" + ); + }); + it('rejects option-like or shell-bearing node names', () => { expect(buildRemoteNodeAttachCommand('lead', 'view', '-oProxyCommand=bad', {})).toBeNull(); expect(buildRemoteNodeAttachCommand('lead', 'view', 'barry;touch /tmp/nope', {})).toBeNull(); diff --git a/packages/cli/src/cli/lib/attach-remote-node.ts b/packages/cli/src/cli/lib/attach-remote-node.ts index 0de379b2e..e53c2ea18 100644 --- a/packages/cli/src/cli/lib/attach-remote-node.ts +++ b/packages/cli/src/cli/lib/attach-remote-node.ts @@ -48,7 +48,9 @@ function remoteStateSelection( node: string, override: string | undefined ): { setup: string[]; argument: string } { - if (override?.trim()) return { setup: [], argument: explicitRemoteStateDir(override) }; + if (override !== undefined && override !== '') { + return { setup: [], argument: explicitRemoteStateDir(override) }; + } const expected = defaultRemoteStateDir(node); const discoveryError = quoteRemoteArg( From a54dc2215595ed17eee1b3241ad91a123d66f413 Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Tue, 11 Aug 2026 13:29:39 -0400 Subject: [PATCH 7/8] fix(cli): filter remote broker process discovery --- packages/cli/src/cli/lib/attach-remote-node.test.ts | 2 ++ packages/cli/src/cli/lib/attach-remote-node.ts | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/cli/lib/attach-remote-node.test.ts b/packages/cli/src/cli/lib/attach-remote-node.test.ts index 870d5b795..3ea56cbc8 100644 --- a/packages/cli/src/cli/lib/attach-remote-node.test.ts +++ b/packages/cli/src/cli/lib/attach-remote-node.test.ts @@ -24,6 +24,8 @@ describe('buildRemoteNodeAttachCommand', () => { it('discovers an ordinary project-local broker from the target agent process', () => { const target = buildRemoteNodeAttachCommand('project-worker', 'view', 'build-host', {}); expect(target?.command).toContain('agent-relay-broker pty --agent-name'); + expect(target?.command).toContain('ps axww -o ppid= -o ucomm= -o command='); + expect(target?.command).toContain('$2 ~ /^agent-relay-brok/'); expect(target?.command).toContain('/proc/$broker_pid/cwd'); expect(target?.command).toContain('lsof -a -p "$broker_pid" -d cwd'); expect(target?.command).toContain('broker_state_ambiguous=0'); diff --git a/packages/cli/src/cli/lib/attach-remote-node.ts b/packages/cli/src/cli/lib/attach-remote-node.ts index e53c2ea18..c913c6b8e 100644 --- a/packages/cli/src/cli/lib/attach-remote-node.ts +++ b/packages/cli/src/cli/lib/attach-remote-node.ts @@ -61,9 +61,11 @@ function remoteStateSelection( `relay_state=${expected}`, `attach_agent=${quoteRemoteArg(agentName)}`, 'if [ ! -f "$relay_state/connection.json" ]; then ' + - `broker_pids=$(ps axww -o ppid= -o command= 2>/dev/null | ATTACH_AGENT="$attach_agent" awk '${ + // The matched `pty` process is the worker wrapper; its parent is the + // owning broker whose cwd contains the project-local state directory. + `broker_pids=$(ps axww -o ppid= -o ucomm= -o command= 2>/dev/null | ATTACH_AGENT="$attach_agent" awk '${ 'BEGIN { marker = "agent-relay-broker pty --agent-name " ENVIRON["ATTACH_AGENT"] } ' + - '{ pos = index($0, marker); if (pos > 0) { after = substr($0, pos + length(marker), 1); ' + + '$2 ~ /^agent-relay-brok/ { pos = index($0, marker); if (pos > 0) { after = substr($0, pos + length(marker), 1); ' + 'if (after == "" || after == " ") print $1 } }' }'); ` + 'broker_state=""; broker_state_ambiguous=0; ' + From 986b90e7720357d5339b79eccb66fe1c2c39f32a Mon Sep 17 00:00:00 2001 From: Barry Cape Date: Tue, 11 Aug 2026 13:43:30 -0400 Subject: [PATCH 8/8] fix(cli): preserve stdin for JSON remote control --- packages/cli/src/cli/lib/attach-remote-node.test.ts | 13 +++++++++++++ packages/cli/src/cli/lib/attach-remote-node.ts | 8 +++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/cli/lib/attach-remote-node.test.ts b/packages/cli/src/cli/lib/attach-remote-node.test.ts index 3ea56cbc8..89e083d13 100644 --- a/packages/cli/src/cli/lib/attach-remote-node.test.ts +++ b/packages/cli/src/cli/lib/attach-remote-node.test.ts @@ -104,6 +104,19 @@ describe('attachRemoteNode', () => { ); }); + it.each(['drive', 'passthrough'] as const)('keeps stdin connected for JSON %s mode', async (mode) => { + const child = new EventEmitter(); + const spawn = vi.fn(() => child as never); + const result = attachRemoteNode('lead', mode, 'barry', { json: true }, { spawn, error: vi.fn() }); + child.emit('exit', 0, null); + await expect(result).resolves.toBe(0); + expect(spawn).toHaveBeenCalledWith( + 'ssh', + ['-T', 'barry', expect.any(String)], + expect.objectContaining({ stdio: 'inherit' }) + ); + }); + it('maps child signals to conventional shell exit codes', async () => { const child = new EventEmitter(); const spawn = vi.fn(() => child as never); diff --git a/packages/cli/src/cli/lib/attach-remote-node.ts b/packages/cli/src/cli/lib/attach-remote-node.ts index c913c6b8e..a101c504f 100644 --- a/packages/cli/src/cli/lib/attach-remote-node.ts +++ b/packages/cli/src/cli/lib/attach-remote-node.ts @@ -138,11 +138,13 @@ export async function attachRemoteNode( resolve(code); }; // JSON mode is a machine-readable stream: no remote PTY, login banner, or - // stderr/stdout merging. Interactive modes require a forced TTY even when - // this local CLI's stdin is itself attached indirectly. + // stderr/stdout merging. Only view mode can disconnect stdin; drive and + // passthrough still carry user input even when their output is JSON. + // Interactive modes require a forced TTY even when this local CLI's stdin + // is itself attached indirectly. const ttyFlag = options.json ? '-T' : '-tt'; const sshArgs = options.json - ? [ttyFlag, '-n', target.host, target.command] + ? [ttyFlag, ...(mode === 'view' ? ['-n'] : []), target.host, target.command] : [ttyFlag, target.host, target.command]; const child = deps.spawn('ssh', sshArgs, { stdio: 'inherit' }); child.once('error', (error) => {