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..125023c82 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-08/traj_jzmkf8ctib43/summary.md @@ -0,0 +1,35 @@ +# 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..ebffc5b47 --- /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" + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 091e95b17..0c4227804 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 --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 f40620b17..6d4c2abdc 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,43 @@ describe('local agent subtree', () => { ); }); + 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', '--ssh-host', 'barry', '--mode', 'drive', '--reasoning'], + { from: 'user' } + ); + expect(attach).not.toHaveBeenCalled(); + expect(attachRemote).toHaveBeenCalledWith( + 'lead', + 'drive', + 'barry', + expect.objectContaining({ reasoning: true }) + ); + }); + + 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.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', flag, value], { + from: 'user', + }); + expect(attach).not.toHaveBeenCalled(); + expect(attachRemote).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith(expect.stringContaining('--ssh-host 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..dca3abfd8 100644 --- a/packages/cli/src/cli/commands/local-agent.ts +++ b/packages/cli/src/cli/commands/local-agent.ts @@ -8,8 +8,10 @@ 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'; import { attachView } from '../lib/attach-view.js'; import { defaultStateDir, @@ -55,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. */ @@ -80,6 +82,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 +108,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 +491,13 @@ export function registerLocalAgentCommands( .description('Attach to a running agent interactively (drive | view | passthrough)') .argument('', 'Agent name') .option('--mode ', 'drive | view | passthrough', 'view') + .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 (default: .agentworkforce/relay/)') + .option( + '--state-dir ', + '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') .option('--diagnostics', 'Include native harness diagnostics') @@ -495,6 +508,22 @@ export function registerLocalAgentCommands( deps.exit(1); return; } + const sshHost = options.sshHost as string | undefined; + if (sshHost !== undefined) { + 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; + } + 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, + 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-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.test.ts b/packages/cli/src/cli/lib/attach-remote-node.test.ts new file mode 100644 index 000000000..89e083d13 --- /dev/null +++ b/packages/cli/src/cli/lib/attach-remote-node.test.ts @@ -0,0 +1,127 @@ +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', () => { + 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 --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', () => { + 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('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'); + expect(target?.command).toContain('if [ "$broker_state_ambiguous" -ne 0 ]'); + expect(target?.command).toContain('relay_state="$broker_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(`--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('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(); + }); +}); + +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', + ['-T', '-n', 'barry', expect.any(String)], + expect.objectContaining({ stdio: 'inherit' }) + ); + }); + + 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); + 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 new file mode 100644 index 000000000..a101c504f --- /dev/null +++ b/packages/cli/src/cli/lib/attach-remote-node.ts @@ -0,0 +1,167 @@ +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'; + +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 { + if (override === '~') return '"$HOME"'; + if (override.startsWith('~/')) return `"$HOME"/${quoteRemoteArg(override.slice(2))}`; + return quoteRemoteArg(override); +} + +function remoteStateSelection( + agentName: string, + node: string, + override: string | undefined +): { setup: string[]; argument: string } { + if (override !== undefined && override !== '') { + return { setup: [], argument: explicitRemoteStateDir(override) }; + } + + const expected = defaultRemoteStateDir(node); + const discoveryError = quoteRemoteArg( + '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 ' + + // 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"] } ' + + '$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; ' + + '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 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; ' + + '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 ' + + 'relay_state=${1%/connection.json}; ' + + `else printf '%s\\n' ${discoveryError} >&2; exit 78; fi; fi`, + ], + argument: '"$relay_state"', + }; +} + +export function buildRemoteNodeAttachCommand( + agentName: string, + mode: AttachMode, + node: string, + options: RemoteNodeAttachOptions +): { host: string; command: string } | null { + const host = validateSshNode(node); + if (!host) return null; + const state = remoteStateSelection(agentName, host, options.stateDir); + + 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('; ') }; +} + +/** + * 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: AttachMode, + 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. 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, ...(mode === 'view' ? ['-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); + }); + child.once('exit', (code, signal) => { + if (signal) { + deps.error(`Error: SSH attach to node '${target.host}' ended by signal ${signal}.`); + const signalNumber = osConstants.signals[signal]; + finish(signalNumber === undefined ? 1 : 128 + signalNumber); + return; + } + if (code === 255) { + deps.error(`Error: node '${target.host}' is not reachable over SSH.`); + } + finish(code ?? 1); + }); + }); +}