diff --git a/CHANGELOG.md b/CHANGELOG.md index ffb58cda4..2248bee43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `relay node agent list --pretty` now provides a compact agent view with each agent's name, CLI/model, state, and relative last activity time. +- `agent-relay fleet spawn|release` can create, target, and release agents across live Fleet nodes directly from the terminal. +- `agent-relay fleet nodes --all` includes offline and direct fleet-history records when they are needed for diagnostics. +- `agent-relay message dm send --mode steer` can wake an idle remote agent immediately from the terminal. -## [Unreleased] +### Changed + +- `agent-relay fleet nodes` now shows only live fleet providers by default instead of mixing unavailable nodes with direct-delivery history. +- CLI and MCP workspace selection is now pinned to the current project, so later agents and processes resume one collaboration session until a new workspace is explicitly created or selected. +- Enrolled Fleet nodes now retain their node identity when a pinned project session is restarted. + +### Fixed + +- MCP workspace creation and selection now preserve completed remote or in-memory changes with a warning when local persistence fails, preventing duplicate workspaces and false failed switches. +- Workspace creation now rejects invalid names before provisioning a remote workspace. +- Fleet node restarts now reject stored enrollment fallbacks that do not match the project-pinned node identity. ## [11.0.2] - 2026-07-22 diff --git a/packages/cli/README.md b/packages/cli/README.md index f9b2f6448..08d643015 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -47,6 +47,37 @@ agent-relay node agent release For AI SDK native harnesses, attach renders structured activity, text, tools, approvals, files, usage, and lifecycle events. Add `--json` for NDJSON, `--reasoning` for reasoning events, or `--diagnostics` for sidecar diagnostics. Native harness `drive` is line-oriented and acknowledged; native harness `passthrough` is unsupported because no terminal stream exists. PTY attach behavior is unchanged. +## Remote fleet agents + +The `fleet` command group lists and controls agents across all live nodes in +the active project workspace: + +```bash +agent-relay fleet nodes +agent-relay fleet nodes --name sf-mini --capability spawn:codex + +# Exact-node placement uses the same agent-scoped Fleet action as the MCP tool. +agent-relay fleet spawn codex \ + --name api-worker \ + --task "Use https://agentrelay.com/skill, ACK over Relay, then wait for details." \ + --node sf-mini + +# Omit --node for automatic eligible-node placement. +agent-relay fleet spawn codex --name api-worker --task "Review the current diff." + +agent-relay message dm send api-worker "Detailed task instructions" +# Wake an idle worker immediately instead of queueing for its next tool boundary. +agent-relay message dm send api-worker "Please check Relay now." --mode steer +agent-relay message inbox check --limit 20 +agent-relay fleet release api-worker --reason "Work accepted" +``` + +Commands use the workspace session pinned to the current project. Targeted +spawn and messaging operations also need an agent identity: pass `--token` or +set `RELAY_AGENT_TOKEN` to the token returned by +`agent-relay agent register `. Automatic placement and release need +only the workspace key. + To run as a Cloud-managed node, first redeem a one-time enrollment token, then start the node: ```bash diff --git a/packages/cli/src/cli/agent-relay-mcp.startup.test.ts b/packages/cli/src/cli/agent-relay-mcp.startup.test.ts index 1673dda51..67b50d388 100644 --- a/packages/cli/src/cli/agent-relay-mcp.startup.test.ts +++ b/packages/cli/src/cli/agent-relay-mcp.startup.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; type LoadOptions = { connectThrows?: boolean; forceEntrypoint?: boolean; + persistedWorkspaceKey?: string; }; type RelayBehavior = { @@ -28,6 +29,13 @@ async function loadAgentRelayMcpModule(options: LoadOptions = {}) { const telemetryTrack = vi.fn(); const telemetryInit = vi.fn(); const telemetryShutdown = vi.fn(async () => undefined); + const persistWorkspaceSession = vi.fn(); + const resolveWorkspaceSessionKey = vi.fn(() => options.persistedWorkspaceKey); + const validateWorkspaceSessionName = vi.fn((name: string) => { + const trimmed = name.trim(); + if (!trimmed) throw new Error('Workspace name is required.'); + return trimmed; + }); const relayInstances: Array<{ config: Record; registerOrRotate: ReturnType; @@ -248,6 +256,11 @@ async function loadAgentRelayMcpModule(options: LoadOptions = {}) { shutdown: telemetryShutdown, track: telemetryTrack, })); + vi.doMock('./lib/workspace-session.js', () => ({ + persistWorkspaceSession, + resolveWorkspaceSessionKey, + validateWorkspaceSessionName, + })); const mod = await import('./agent-relay-mcp.js'); if (options.forceEntrypoint) { @@ -263,6 +276,9 @@ async function loadAgentRelayMcpModule(options: LoadOptions = {}) { telemetryTrack, telemetryInit, telemetryShutdown, + persistWorkspaceSession, + resolveWorkspaceSessionKey, + validateWorkspaceSessionName, RelayCast, FakeTransport, }, @@ -314,6 +330,73 @@ describe('agent-relay-mcp startup helpers', () => { skipBootstrap: true, }); }); + + it('resumes the persisted project workspace when no workspace env is set', async () => { + const { mod, mocks } = await loadAgentRelayMcpModule({ + persistedWorkspaceKey: 'rk_live_persisted', + }); + vi.stubEnv('RELAY_WORKSPACE_KEY', ''); + vi.stubEnv('AGENT_RELAY_WORKSPACE_KEY', ''); + vi.stubEnv('RELAY_API_KEY', ''); + vi.stubEnv('RELAY_AGENT_TOKEN', ''); + vi.stubEnv('RELAY_AGENT_NAME', ''); + vi.stubEnv('RELAY_CLAW_NAME', ''); + + expect(mod.optionsFromEnv()).toMatchObject({ + workspaceKey: 'rk_live_persisted', + agentName: 'orchestrator', + }); + expect(mocks.resolveWorkspaceSessionKey).toHaveBeenCalledTimes(1); + }); + + it('does not pair a persisted workspace with an unbound ambient agent token', async () => { + const { mod } = await loadAgentRelayMcpModule({ + persistedWorkspaceKey: 'rk_live_persisted', + }); + vi.stubEnv('RELAY_WORKSPACE_KEY', ''); + vi.stubEnv('AGENT_RELAY_WORKSPACE_KEY', ''); + vi.stubEnv('RELAY_API_KEY', ''); + vi.stubEnv('RELAY_AGENT_TOKEN', 'at_live_stale_workspace'); + vi.stubEnv('RELAY_AGENT_NAME', ''); + vi.stubEnv('RELAY_CLAW_NAME', ''); + + expect(mod.optionsFromEnv()).toMatchObject({ + workspaceKey: 'rk_live_persisted', + agentToken: undefined, + agentName: 'orchestrator', + }); + }); + + it('keeps an agent token paired with an explicitly configured agent-relay workspace key', async () => { + const { mod } = await loadAgentRelayMcpModule({ + persistedWorkspaceKey: 'rk_live_unrelated_persisted', + }); + vi.stubEnv('RELAY_WORKSPACE_KEY', ''); + vi.stubEnv('AGENT_RELAY_WORKSPACE_KEY', 'rk_live_agent_env'); + vi.stubEnv('RELAY_API_KEY', ''); + vi.stubEnv('RELAY_AGENT_TOKEN', 'at_live_agent_env'); + + expect(mod.optionsFromEnv()).toMatchObject({ + workspaceKey: 'rk_live_agent_env', + agentToken: 'at_live_agent_env', + }); + }); + + it('trims workspace env values and falls through whitespace-only primary candidates', async () => { + const { mod, mocks } = await loadAgentRelayMcpModule({ + persistedWorkspaceKey: 'rk_live_unrelated_persisted', + }); + vi.stubEnv('RELAY_WORKSPACE_KEY', ' '); + vi.stubEnv('AGENT_RELAY_WORKSPACE_KEY', ' rk_live_agent_env '); + vi.stubEnv('RELAY_API_KEY', 'rk_live_legacy'); + vi.stubEnv('RELAY_AGENT_TOKEN', ' at_live_agent_env '); + + expect(mod.optionsFromEnv()).toMatchObject({ + workspaceKey: 'rk_live_agent_env', + agentToken: 'at_live_agent_env', + }); + expect(mocks.resolveWorkspaceSessionKey).not.toHaveBeenCalled(); + }); }); describe('createAgentRelayMcpServer', () => { @@ -357,6 +440,10 @@ describe('createAgentRelayMcpServer', () => { workspaceKey: 'rk_live_created', workspaceName: 'Test Workspace', }); + expect(mocks.persistWorkspaceSession).toHaveBeenCalledWith({ + name: 'Test Workspace', + workspaceKey: 'rk_live_created', + }); const registerResult = await server.tools.get('register_agent')?.handler({ name: 'WorkerA', @@ -422,6 +509,64 @@ describe('createAgentRelayMcpServer', () => { expect(promptResult.messages[0].content.text).not.toContain('workspace.create'); }); + it('returns a created workspace key when local session persistence fails', async () => { + const { mod, mocks } = await loadAgentRelayMcpModule(); + mocks.persistWorkspaceSession.mockImplementationOnce(() => { + throw new Error('project directory is read-only'); + }); + + mod.createAgentRelayMcpServer({ baseUrl: 'https://relay.example.com/' }); + const server = mocks.serverInstances[0]; + const result = await server.tools.get('create_workspace')?.handler({ name: 'Durable Workspace' }); + + expect(result.structuredContent).toEqual({ + workspaceKey: 'rk_live_created', + workspaceName: 'Test Workspace', + warning: + 'Workspace created, but its session could not be persisted locally: project directory is read-only. ' + + 'Keep the returned workspace key and retry persistence before starting another session.', + }); + expect(mocks.RelayCast.createWorkspace).toHaveBeenCalledTimes(1); + + await server.tools.get('register_agent')?.handler({ name: 'WorkerAfterWarning' }); + expect(mocks.relayInstances.some((instance) => instance.config.apiKey === 'rk_live_created')).toBe(true); + }); + + it('rejects a blank workspace name before provisioning a remote workspace', async () => { + const { mod, mocks } = await loadAgentRelayMcpModule(); + mod.createAgentRelayMcpServer({ baseUrl: 'https://relay.example.com/' }); + const server = mocks.serverInstances[0]; + + await expect(server.tools.get('create_workspace')?.handler({ name: ' ' })).rejects.toThrow( + 'Workspace name is required.' + ); + expect(mocks.RelayCast.createWorkspace).not.toHaveBeenCalled(); + expect(mocks.persistWorkspaceSession).not.toHaveBeenCalled(); + }); + + it('keeps a selected workspace usable when local session persistence fails', async () => { + const { mod, mocks } = await loadAgentRelayMcpModule(); + mocks.persistWorkspaceSession.mockImplementationOnce(() => { + throw new Error('project directory is read-only'); + }); + + mod.createAgentRelayMcpServer({ baseUrl: 'https://relay.example.com/' }); + const server = mocks.serverInstances[0]; + const result = await server.tools + .get('set_workspace_key') + ?.handler({ workspace_key: 'rk_live_selected' }); + + expect(result.structuredContent).toEqual({ + message: + 'Workspace key set. Call "register_agent" to join this workspace. ' + + 'The workspace is active for this process, but its session could not be persisted locally: ' + + 'project directory is read-only. Retry persistence before restarting this MCP server.', + }); + + await server.tools.get('register_agent')?.handler({ name: 'WorkerAfterSetWarning' }); + expect(mocks.relayInstances.some((instance) => instance.config.apiKey === 'rk_live_selected')).toBe(true); + }); + it('registers submit_result when a spawned-agent result callback is configured', async () => { vi.stubEnv('AGENT_RELAY_RESULT_URL', 'http://127.0.0.1:3889/api/agent-result'); vi.stubEnv('AGENT_RELAY_RESULT_TOKEN', 'arr_test'); @@ -714,7 +859,10 @@ describe('createAgentRelayMcpServer', () => { const result = await setWorkspaceKeyTool?.handler({ workspace_key: 'rk_live_existing' }); expect(result.structuredContent).toEqual({ - message: 'Workspace key set.', + message: 'Workspace key set and persisted for this project.', + }); + expect(mocks.persistWorkspaceSession).toHaveBeenCalledWith({ + workspaceKey: 'rk_live_existing', }); await server.tools.get('check_inbox')?.handler({}); diff --git a/packages/cli/src/cli/agent-relay-mcp.test.ts b/packages/cli/src/cli/agent-relay-mcp.test.ts index f35dd4d04..65cbe617c 100644 --- a/packages/cli/src/cli/agent-relay-mcp.test.ts +++ b/packages/cli/src/cli/agent-relay-mcp.test.ts @@ -209,12 +209,14 @@ describe('optionsFromEnv', () => { it('ignores unresolved template environment placeholders', () => { const previous = { workspaceKey: process.env.RELAY_WORKSPACE_KEY, + agentRelayWorkspaceKey: process.env.AGENT_RELAY_WORKSPACE_KEY, apiKey: process.env.RELAY_API_KEY, agentName: process.env.RELAY_AGENT_NAME, clawName: process.env.RELAY_CLAW_NAME, agentToken: process.env.RELAY_AGENT_TOKEN, }; process.env.RELAY_WORKSPACE_KEY = '${RELAY_WORKSPACE_KEY}'; + delete process.env.AGENT_RELAY_WORKSPACE_KEY; delete process.env.RELAY_API_KEY; process.env.RELAY_AGENT_NAME = '${RELAY_AGENT_NAME}'; process.env.RELAY_CLAW_NAME = 'ClawFallback'; @@ -229,6 +231,11 @@ describe('optionsFromEnv', () => { } finally { if (previous.workspaceKey === undefined) delete process.env.RELAY_WORKSPACE_KEY; else process.env.RELAY_WORKSPACE_KEY = previous.workspaceKey; + if (previous.agentRelayWorkspaceKey === undefined) { + delete process.env.AGENT_RELAY_WORKSPACE_KEY; + } else { + process.env.AGENT_RELAY_WORKSPACE_KEY = previous.agentRelayWorkspaceKey; + } if (previous.apiKey === undefined) delete process.env.RELAY_API_KEY; else process.env.RELAY_API_KEY = previous.apiKey; if (previous.agentName === undefined) delete process.env.RELAY_AGENT_NAME; diff --git a/packages/cli/src/cli/agent-relay-mcp.ts b/packages/cli/src/cli/agent-relay-mcp.ts index 8e32e8140..3f095dc04 100644 --- a/packages/cli/src/cli/agent-relay-mcp.ts +++ b/packages/cli/src/cli/agent-relay-mcp.ts @@ -32,6 +32,11 @@ import { enableInboxPiggyback } from './mcp/telemetry.js'; import { registerAgentRelayActionTools } from './mcp/action-tools.js'; import { registerMessagingTools } from './mcp/messaging-tools.js'; import { identityOverrideInputShape, messageResult } from './mcp/tool-shapes.js'; +import { + persistWorkspaceSession, + resolveWorkspaceSessionKey, + validateWorkspaceSessionName, +} from './lib/workspace-session.js'; import type { AgentClientLike, AgentRelayMcpServerOptions, @@ -59,12 +64,13 @@ function withExitAfterTaskInstruction(task: string): string { const DEFAULT_SYSTEM_PROMPT = `You are an AI agent in a collaborative workspace powered by Agent Relay. You can communicate with other agents using these MCP tools: ## Getting Started -1. If no workspace is configured, call "create_workspace" -2. If someone shared an existing workspace key with you, call "set_workspace_key" -3. When a workspace key is provided at startup, this MCP server auto-registers the session as RELAY_AGENT_NAME (or "orchestrator" by default). Otherwise call "register_agent" with your agent name to join the workspace -4. Use "list_channels" to see available channels -5. Use "join_channel" to join channels of interest -6. Use "check_inbox" to see unread messages and mentions +1. The current project workspace is resumed automatically when one was selected before +2. Call "create_workspace" only when you explicitly want to start a new workspace session +3. If someone shared an existing workspace key with you, call "set_workspace_key" +4. When a workspace key is available at startup, this MCP server auto-registers the session as RELAY_AGENT_NAME (or "orchestrator" by default). Otherwise call "register_agent" with your agent name to join the workspace +5. Use "list_channels" to see available channels +6. Use "join_channel" to join channels of interest +7. Use "check_inbox" to see unread messages and mentions ## Communication - Post messages to channels with "post_message" @@ -105,9 +111,14 @@ type RegisterAgentWithRebindArgs = { /** Return env var value, or undefined if missing / an unresolved ${...} template. */ function resolveEnv(key: string): string | undefined { - const v = process.env[key]; - if (!v || /^\$\{.+\}$/.test(v)) return undefined; - return v; + const value = process.env[key]?.trim(); + if (!value || isUnresolvedEnvTemplate(value)) return undefined; + return value; +} + +/** Return whether an environment value is an unresolved `${...}` placeholder. */ +function isUnresolvedEnvTemplate(value: string): boolean { + return /^\$\{.+\}$/.test(value.trim()); } /** @@ -388,7 +399,7 @@ function registerAgentRelayTools( 'create_workspace', { title: 'Create Workspace', - description: 'Create a new Agent Relay workspace and store its workspace key in this MCP session.', + description: 'Explicitly start a new Agent Relay workspace session and persist it for this project.', inputSchema: { name: z.string().describe('Human-readable workspace name'), }, @@ -401,12 +412,13 @@ function registerAgentRelayTools( }, }, async ({ name }: any) => { - const workspace = await createWorkspace(name, baseUrl); + const requestedName = validateWorkspaceSessionName(name); + const workspace = await createWorkspace(requestedName, baseUrl); const workspaceKey = extractWorkspaceKey(workspace); if (!workspaceKey || typeof workspaceKey !== 'string') { throw new Error('Workspace created, but the response did not include a workspace key.'); } - const workspaceName = extractWorkspaceName(workspace, name); + const workspaceName = extractWorkspaceName(workspace, requestedName); setSession({ workspaceKey, @@ -414,9 +426,19 @@ function registerAgentRelayTools( agentName: null, agents: new Map(), }); + let persistenceWarning: string | undefined; + try { + persistWorkspaceSession({ name: workspaceName, workspaceKey }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + persistenceWarning = + `Workspace created, but its session could not be persisted locally: ${message}. ` + + 'Keep the returned workspace key and retry persistence before starting another session.'; + } return jsonContent({ workspaceKey, workspaceName, + ...(persistenceWarning ? { warning: persistenceWarning } : {}), }); } ); @@ -459,10 +481,23 @@ function registerAgentRelayTools( } else { setSession({ workspaceKey: key }); } + let persistenceWarning: string | undefined; + try { + persistWorkspaceSession({ workspaceKey: key }); + } catch (error) { + const persistenceError = error instanceof Error ? error.message : String(error); + persistenceWarning = + `The workspace is active for this process, but its session could not be persisted locally: ` + + `${persistenceError}. Retry persistence before restarting this MCP server.`; + } - const message = switchingWorkspace + const persistedMessage = switchingWorkspace + ? 'Workspace key set and persisted for this project. Call "register_agent" to join this workspace.' + : 'Workspace key set and persisted for this project.'; + const activeMessage = switchingWorkspace ? 'Workspace key set. Call "register_agent" to join this workspace.' : 'Workspace key set.'; + const message = persistenceWarning ? `${activeMessage} ${persistenceWarning}` : persistedMessage; return textContent(message); } ); @@ -938,7 +973,26 @@ export async function startAgentRelayMcpStdio(options: AgentRelayMcpServerOption } export function optionsFromEnv(): AgentRelayMcpServerOptions { - const workspaceKey = resolveEnv('RELAY_WORKSPACE_KEY') ?? resolveEnv('RELAY_API_KEY'); + let workspaceKey = + resolveEnv('RELAY_WORKSPACE_KEY') ?? + resolveEnv('AGENT_RELAY_WORKSPACE_KEY') ?? + resolveEnv('RELAY_API_KEY'); + let resumedPersistedWorkspace = false; + const hasUnresolvedWorkspacePlaceholder = [ + process.env.RELAY_WORKSPACE_KEY, + process.env.AGENT_RELAY_WORKSPACE_KEY, + process.env.RELAY_API_KEY, + ].some((value) => value !== undefined && isUnresolvedEnvTemplate(value)); + + if (!workspaceKey && !hasUnresolvedWorkspacePlaceholder) { + try { + workspaceKey = resolveWorkspaceSessionKey(); + resumedPersistedWorkspace = Boolean(workspaceKey); + } catch { + // A malformed or unreadable local store must not brick MCP startup. The + // session can still be selected explicitly with set_workspace_key. + } + } const agentName = resolveEnv('RELAY_AGENT_NAME') ?? resolveEnv('RELAY_CLAW_NAME') ?? @@ -946,7 +1000,11 @@ export function optionsFromEnv(): AgentRelayMcpServerOptions { return { workspaceKey, baseUrl: resolveEnv('RELAY_BASE_URL'), - agentToken: resolveEnv('RELAY_AGENT_TOKEN'), + // An agent token has no workspace identity encoded locally. Only reuse it + // when the workspace was selected alongside it through the environment; + // a persisted project/store fallback must register into that workspace + // instead of silently pairing it with a possibly stale ambient token. + agentToken: resumedPersistedWorkspace ? undefined : resolveEnv('RELAY_AGENT_TOKEN'), agentName, agentType: normalizeAgentType(resolveEnv('RELAY_AGENT_TYPE')), strictAgentName: envFlagEnabled(resolveEnv('RELAY_STRICT_AGENT_NAME')), diff --git a/packages/cli/src/cli/bootstrap.test.ts b/packages/cli/src/cli/bootstrap.test.ts index e5205c1ad..a3289c46a 100644 --- a/packages/cli/src/cli/bootstrap.test.ts +++ b/packages/cli/src/cli/bootstrap.test.ts @@ -41,6 +41,8 @@ const expectedLeafCommands = [ 'fleet enable', 'fleet inherit', 'fleet nodes', + 'fleet release', + 'fleet spawn', 'fleet status', // cloud 'cloud login', diff --git a/packages/cli/src/cli/commands/core.test.ts b/packages/cli/src/cli/commands/core.test.ts index d5871388b..c9e54732d 100644 --- a/packages/cli/src/cli/commands/core.test.ts +++ b/packages/cli/src/cli/commands/core.test.ts @@ -4,7 +4,7 @@ import os from 'node:os'; import nodePath from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { readProjectWorkspaceKey } from '../lib/project-workspace-key.js'; +import { readProjectWorkspaceKey, readProjectWorkspaceSession } from '../lib/project-workspace-key.js'; const sdkStatusClient = { getStatus: vi.fn(async () => ({ agent_count: 0, pending_delivery_count: 0 })), @@ -1448,7 +1448,7 @@ describe('registerCoreCommands', () => { expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_alias'); }); - it('up without --workspace-key does not set workspace key env vars', async () => { + it('up without --workspace-key or a pinned session does not set workspace key env vars', async () => { const env: NodeJS.ProcessEnv = {}; const relay = createRelayMock(); const { program } = createHarness({ relay, env }); @@ -1460,6 +1460,111 @@ describe('registerCoreCommands', () => { expect(env.RELAY_API_KEY).toBeUndefined(); }); + it('up resumes the workspace session pinned to the project', async () => { + const env: NodeJS.ProcessEnv = {}; + const fs = createFsMock({ + '/tmp/project/.agentworkforce/relay/workspace-key.json': JSON.stringify({ + workspaceKey: 'rk_live_pinned', + }), + }); + const relay = createRelayMock({ workspaceKey: 'rk_live_pinned' }); + const { program } = createHarness({ relay, env, fs }); + + const exitCode = await runCommand(program, ['up']); + + expect(exitCode).toBeUndefined(); + expect(env.RELAY_WORKSPACE_KEY).toBe('rk_live_pinned'); + expect(env.RELAY_API_KEY).toBe('rk_live_pinned'); + }); + + it('up treats a non-blank workspace env alias as explicit when the primary is blank', async () => { + const env: NodeJS.ProcessEnv = { + RELAY_WORKSPACE_KEY: ' ', + AGENT_RELAY_WORKSPACE_KEY: ' rk_live_alias ', + }; + const fs = createFsMock({ + '/tmp/project/.agentworkforce/relay/workspace-key.json': JSON.stringify({ + workspaceKey: 'rk_live_pinned', + }), + }); + const relay = createRelayMock({ workspaceKey: 'rk_live_alias' }); + const { program } = createHarness({ relay, env, fs }); + + const exitCode = await runCommand(program, ['up']); + + expect(exitCode).toBeUndefined(); + expect(env.RELAY_WORKSPACE_KEY).toBe('rk_live_alias'); + expect(env.RELAY_API_KEY).toBeUndefined(); + }); + + it('plain up preserves the enrolled node associated with a resumed project session', async () => { + const projectDataDir = '/tmp/project/.agentworkforce/relay'; + const projectSessionPath = `${projectDataDir}/workspace-key.json`; + const env: NodeJS.ProcessEnv = {}; + const fs = createFsMock({ + [projectSessionPath]: JSON.stringify({ + workspaceKey: 'rk_live_pinned', + enrolledNodeId: 'node_enrolled', + }), + }); + const relay = createRelayMock({ workspaceKey: 'rk_live_pinned' }); + const { program } = createHarness({ relay, env, fs }); + + nodeFs.rmSync(projectSessionPath, { force: true }); + try { + const exitCode = await runCommand(program, ['up']); + + expect(exitCode).toBeUndefined(); + expect(readProjectWorkspaceSession(projectDataDir)).toEqual({ + workspaceKey: 'rk_live_pinned', + enrolledNodeId: 'node_enrolled', + }); + } finally { + nodeFs.rmSync(projectSessionPath, { force: true }); + } + }); + + it('background up forwards a resumed enrolled-node association to the detached child', async () => { + const spawnedProcess = createSpawnedProcessMock(); + let now = 0; + const projectSessionPath = '/tmp/project/.agentworkforce/relay/workspace-key.json'; + const fs = createFsMock({ + [projectSessionPath]: JSON.stringify({ + workspaceKey: 'rk_live_pinned', + enrolledNodeId: 'node_enrolled', + }), + }); + const sleepImpl = vi.fn(async (ms: number) => { + now += ms; + fs.writeFileSync('/tmp/project/.agentworkforce/relay/connection.json', connectionFile(5151)); + }); + const killImpl = vi.fn((pid: number, signal?: NodeJS.Signals | number) => { + if ((pid === 9001 || pid === 5151) && signal === 0) return; + throw new Error('unexpected kill check'); + }); + const { program, deps } = createHarness({ + fs, + env: {}, + spawnedProcess, + killImpl, + nowImpl: vi.fn(() => now), + sleepImpl, + }); + + const exitCode = await runCommand(program, ['up', '--background']); + + expect(exitCode).toBe(0); + expect(deps.spawnProcess).toHaveBeenCalledWith('/usr/bin/node', ['/tmp/agent-relay.js', 'up'], { + detached: true, + stdio: 'ignore', + env: expect.objectContaining({ + AGENT_RELAY_ENROLLED_NODE_ID: 'node_enrolled', + RELAY_API_KEY: 'rk_live_pinned', + RELAY_WORKSPACE_KEY: 'rk_live_pinned', + }), + }); + }); + it('up configures a bundled Agent Relay MCP command when the wrapper script exists', async () => { const env: NodeJS.ProcessEnv = {}; const fs = createFsMock({ '/tmp/agent-relay-mcp.js': '' }); diff --git a/packages/cli/src/cli/commands/fleet.test.ts b/packages/cli/src/cli/commands/fleet.test.ts index bdb0437ec..52d1ef854 100644 --- a/packages/cli/src/cli/commands/fleet.test.ts +++ b/packages/cli/src/cli/commands/fleet.test.ts @@ -146,15 +146,147 @@ describe('fleet command support', () => { }); }); + it('fleet nodes hides offline and direct pseudo-nodes by default', async () => { + const listedNodes = [ + { + name: 'sf-mini', + status: 'online', + live: true, + handlersLive: true, + capabilities: [{ name: 'spawn:codex' }], + tags: [], + }, + { + name: 'legacy-live-runner', + status: 'online', + capabilities: [{ name: 'spawn:codex' }], + tags: [], + }, + { + name: 'detached-runner', + status: 'online', + live: true, + handlersLive: false, + capabilities: [{ name: 'spawn:codex' }], + tags: [], + }, + { + name: 'old-runner', + status: 'offline', + live: false, + capabilities: [{ name: 'spawn:codex' }], + tags: [], + }, + { + name: 'stale-online-runner', + status: 'online', + live: false, + capabilities: [{ name: 'spawn:codex' }], + tags: [], + }, + { + name: 'direct-123', + status: 'online', + live: true, + capabilities: [], + tags: ['implicit', 'direct'], + }, + ]; + const nodes = { list: vi.fn(async () => listedNodes) }; + const logs: string[] = []; + const warnings: string[] = []; + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + sdk: { + createAgentRelay: vi.fn() as never, + createWorkspaceRelay: vi.fn(() => ({ nodes })) as never, + createWorkspace: vi.fn() as never, + log: (message: unknown) => logs.push(String(message)), + error: vi.fn(), + exit: vi.fn() as never, + }, + log: () => undefined, + warn: (...args: unknown[]) => warnings.push(args.join(' ')), + error: () => undefined, + }); + + await program.parseAsync(['fleet', 'nodes', '--workspace-key', 'rk_live_test'], { + from: 'user', + }); + + expect(JSON.parse(logs[0]!)).toEqual({ nodes: listedNodes.slice(0, 2) }); + expect(warnings.join('\n')).toMatch(/4 offline or non-fleet records hidden/); + expect(warnings.join('\n')).toMatch(/--all/); + }); + + it('fleet nodes --all includes offline and direct history records', async () => { + const listedNodes = [ + { name: 'old-runner', status: 'offline', live: false, capabilities: [], tags: [] }, + { + name: 'detached-runner', + status: 'online', + live: true, + handlersLive: false, + capabilities: [], + tags: [], + }, + { + name: 'sf-mini', + status: 'online', + live: true, + handlersLive: true, + capabilities: [], + tags: [], + }, + { + name: 'direct-123', + status: 'offline', + live: false, + capabilities: [], + tags: ['implicit', 'direct'], + }, + ]; + const nodes = { list: vi.fn(async () => listedNodes) }; + const logs: string[] = []; + const warnings: string[] = []; + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + sdk: { + createAgentRelay: vi.fn() as never, + createWorkspaceRelay: vi.fn(() => ({ nodes })) as never, + createWorkspace: vi.fn() as never, + log: (message: unknown) => logs.push(String(message)), + error: vi.fn(), + exit: vi.fn() as never, + }, + log: () => undefined, + warn: (...args: unknown[]) => warnings.push(args.join(' ')), + error: () => undefined, + }); + + await program.parseAsync(['fleet', 'nodes', '--workspace-key', 'rk_live_test', '--all'], { + from: 'user', + }); + + expect(JSON.parse(logs[0]!)).toEqual({ + nodes: [listedNodes[2], listedNodes[0], listedNodes[1], listedNodes[3]], + }); + expect(warnings).toEqual([]); + }); + it('fleet nodes warns when the workspace key is inferred from the project broker', async () => { const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-fleet-proj-')); const saved = { project: process.env.AGENT_RELAY_PROJECT, ws: process.env.RELAY_WORKSPACE_KEY, + agentWs: process.env.AGENT_RELAY_WORKSPACE_KEY, api: process.env.RELAY_API_KEY, }; process.env.AGENT_RELAY_PROJECT = projectRoot; delete process.env.RELAY_WORKSPACE_KEY; + delete process.env.AGENT_RELAY_WORKSPACE_KEY; delete process.env.RELAY_API_KEY; writeProjectWorkspaceKey(path.join(projectRoot, '.agentworkforce/relay'), 'rk_project_broker'); @@ -182,12 +314,14 @@ describe('fleet command support', () => { if (saved.project === undefined) delete process.env.AGENT_RELAY_PROJECT; else process.env.AGENT_RELAY_PROJECT = saved.project; if (saved.ws !== undefined) process.env.RELAY_WORKSPACE_KEY = saved.ws; + if (saved.agentWs === undefined) delete process.env.AGENT_RELAY_WORKSPACE_KEY; + else process.env.AGENT_RELAY_WORKSPACE_KEY = saved.agentWs; if (saved.api !== undefined) process.env.RELAY_API_KEY = saved.api; fs.rmSync(projectRoot, { recursive: true, force: true }); } // The warning is advisory only — the roster is still fetched and printed. - expect(warnings.join('\n')).toMatch(/recorded in this directory/); + expect(warnings.join('\n')).toMatch(/workspace session pinned to this project/); expect(nodes.list).toHaveBeenCalledTimes(1); }); @@ -198,10 +332,12 @@ describe('fleet command support', () => { const saved = { project: process.env.AGENT_RELAY_PROJECT, ws: process.env.RELAY_WORKSPACE_KEY, + agentWs: process.env.AGENT_RELAY_WORKSPACE_KEY, api: process.env.RELAY_API_KEY, }; process.env.AGENT_RELAY_PROJECT = projectRoot; delete process.env.RELAY_WORKSPACE_KEY; + delete process.env.AGENT_RELAY_WORKSPACE_KEY; delete process.env.RELAY_API_KEY; writeProjectWorkspaceKey(path.join(projectRoot, '.agentworkforce/relay'), 'rk_project_broker'); @@ -229,6 +365,8 @@ describe('fleet command support', () => { if (saved.project === undefined) delete process.env.AGENT_RELAY_PROJECT; else process.env.AGENT_RELAY_PROJECT = saved.project; if (saved.ws !== undefined) process.env.RELAY_WORKSPACE_KEY = saved.ws; + if (saved.agentWs === undefined) delete process.env.AGENT_RELAY_WORKSPACE_KEY; + else process.env.AGENT_RELAY_WORKSPACE_KEY = saved.agentWs; if (saved.api !== undefined) process.env.RELAY_API_KEY = saved.api; fs.rmSync(projectRoot, { recursive: true, force: true }); } @@ -237,6 +375,237 @@ describe('fleet command support', () => { expect(nodes.list).toHaveBeenCalledTimes(1); }); + it('fleet spawn targets an exact node through the agent-scoped placement action', async () => { + const placement = { + spawn: vi.fn(async () => ({ + invocationId: 'inv_targeted', + actionName: 'spawn', + node: { name: 'sf-mini' }, + placement: { capability: 'spawn:codex', node: 'sf-mini', attempts: 1, queued: false }, + })), + }; + const createAgentRelay = vi.fn(() => ({ messaging: { placement } })); + const createFleetWorkspaceClient = vi.fn(); + const logs: string[] = []; + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + sdk: { + createAgentRelay: createAgentRelay as never, + createWorkspaceRelay: vi.fn() as never, + createWorkspace: vi.fn() as never, + log: (message: unknown) => logs.push(String(message)), + error: vi.fn(), + exit: vi.fn() as never, + }, + createFleetWorkspaceClient: createFleetWorkspaceClient as never, + log: () => undefined, + warn: () => undefined, + error: () => undefined, + }); + + await program.parseAsync( + [ + 'fleet', + 'spawn', + 'codex', + '--name', + 'api-worker', + '--task', + 'ACK and wait', + '--target-node', + 'sf-mini', + '--channel', + 'general', + '--model', + 'gpt-5', + '--session-ref', + 'session-1', + '--workspace-key', + 'rk_live_test', + '--token', + 'at_live_lead', + ], + { from: 'user' } + ); + + expect(createAgentRelay).toHaveBeenCalledWith({ + workspaceKey: 'rk_live_test', + token: 'at_live_lead', + baseUrl: undefined, + }); + expect(placement.spawn).toHaveBeenCalledWith({ + capability: 'spawn:codex', + node: 'sf-mini', + failFast: true, + input: { + name: 'api-worker', + cli: 'codex', + task: 'ACK and wait', + channels: ['general'], + model: 'gpt-5', + session_ref: 'session-1', + }, + }); + expect(createFleetWorkspaceClient).not.toHaveBeenCalled(); + expect(JSON.parse(logs[0]!)).toMatchObject({ + invocation: { invocationId: 'inv_targeted' }, + }); + }); + + it('fleet spawn uses workspace-scoped automatic placement when no node is named', async () => { + const spawn = vi.fn(async () => ({ invocation_id: 'inv_auto', status: 'accepted' })); + const createFleetWorkspaceClient = vi.fn(() => ({ agents: { spawn } })); + const logs: string[] = []; + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + sdk: { + createAgentRelay: vi.fn() as never, + createWorkspaceRelay: vi.fn() as never, + createWorkspace: vi.fn() as never, + log: (message: unknown) => logs.push(String(message)), + error: vi.fn(), + exit: vi.fn() as never, + }, + createFleetWorkspaceClient: createFleetWorkspaceClient as never, + log: () => undefined, + warn: () => undefined, + error: () => undefined, + }); + + await program.parseAsync( + [ + 'fleet', + 'spawn', + 'codex', + '--name', + 'api-worker', + '--task', + 'Review the diff', + '--channel', + 'general', + '--persona', + 'Reviewer', + '--model', + 'gpt-5', + '--workspace-key', + 'rk_live_test', + ], + { from: 'user' } + ); + + expect(createFleetWorkspaceClient).toHaveBeenCalledWith({ + workspaceKey: 'rk_live_test', + token: undefined, + baseUrl: undefined, + }); + expect(spawn).toHaveBeenCalledWith({ + name: 'api-worker', + cli: 'codex', + task: 'Review the diff', + channel: 'general', + persona: 'Reviewer', + metadata: { model: 'gpt-5' }, + }); + expect(JSON.parse(logs[0]!)).toEqual({ + invocation: { invocation_id: 'inv_auto', status: 'accepted' }, + }); + }); + + it('fleet spawn rejects targeted placement without an agent token before dispatch', async () => { + const previousToken = process.env.RELAY_AGENT_TOKEN; + delete process.env.RELAY_AGENT_TOKEN; + const createAgentRelay = vi.fn(); + const createFleetWorkspaceClient = vi.fn(); + const errors: string[] = []; + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + sdk: { + createAgentRelay: createAgentRelay as never, + createWorkspaceRelay: vi.fn() as never, + createWorkspace: vi.fn() as never, + log: vi.fn(), + error: (...args: unknown[]) => errors.push(args.join(' ')), + exit: (() => { + throw new Error('__exit__'); + }) as never, + }, + createFleetWorkspaceClient: createFleetWorkspaceClient as never, + log: () => undefined, + warn: () => undefined, + error: () => undefined, + }); + + try { + await expect( + program.parseAsync( + [ + 'fleet', + 'spawn', + 'codex', + '--name', + 'api-worker', + '--task', + 'ACK', + '--node', + 'sf-mini', + '--workspace-key', + 'rk_live_test', + ], + { from: 'user' } + ) + ).rejects.toThrow('__exit__'); + } finally { + if (previousToken === undefined) delete process.env.RELAY_AGENT_TOKEN; + else process.env.RELAY_AGENT_TOKEN = previousToken; + } + + expect(errors.join('\n')).toMatch(/requires an agent token/); + expect(createAgentRelay).not.toHaveBeenCalled(); + expect(createFleetWorkspaceClient).not.toHaveBeenCalled(); + }); + + it('fleet release delegates to the workspace lifecycle API', async () => { + const release = vi.fn(async () => ({ + name: 'api-worker', + released: true, + deleted: false, + reason: 'Work accepted', + })); + const createFleetWorkspaceClient = vi.fn(() => ({ agents: { release } })); + const logs: string[] = []; + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + sdk: { + createAgentRelay: vi.fn() as never, + createWorkspaceRelay: vi.fn() as never, + createWorkspace: vi.fn() as never, + log: (message: unknown) => logs.push(String(message)), + error: vi.fn(), + exit: vi.fn() as never, + }, + createFleetWorkspaceClient: createFleetWorkspaceClient as never, + log: () => undefined, + warn: () => undefined, + error: () => undefined, + }); + + await program.parseAsync( + ['fleet', 'release', 'api-worker', '--reason', 'Work accepted', '--workspace-key', 'rk_live_test'], + { from: 'user' } + ); + + expect(release).toHaveBeenCalledWith({ + name: 'api-worker', + reason: 'Work accepted', + deleteAgent: false, + }); + expect(JSON.parse(logs[0]!)).toMatchObject({ name: 'api-worker', released: true }); + }); + it('fleet status output redacts the node token and workspace key from the session', async () => { const logs: string[] = []; const nodes = { list: vi.fn(async () => [{ name: 'live-node', status: 'online', capabilities: [] }]) }; diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index ef196ea30..066682ea4 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -1,10 +1,17 @@ -import type { Command } from 'commander'; +import { InvalidArgumentError, type Command } from 'commander'; import { HarnessDriverClient } from '@agent-relay/harness-driver'; +import { createWorkspaceClient, type RelayWorkspaceThinClient } from '@agent-relay/sdk'; import { withDefaults, type CoreDependencies } from './core.js'; import { readBrokerConnection } from '../lib/broker-lifecycle.js'; import { redactSecrets } from '../lib/redact.js'; -import { resolveWorkspaceKeyWithSource } from '../lib/sdk-client.js'; +import { + resolveAgentToken, + resolveBaseUrl, + resolveWorkspaceKey, + resolveWorkspaceKeyWithSource, + type SdkClientOptions, +} from '../lib/sdk-client.js'; import { addSdkOptions, printJson, @@ -18,9 +25,12 @@ const SERVE_REPLACEMENT_MESSAGE = "'fleet serve' has been replaced. Run 'relay node up' (with an optional --config ); " + "for Cloud-managed nodes run 'relay cloud enroll --token ' first."; +const FLEET_CLIS = new Set(['claude', 'codex', 'gemini', 'aider', 'goose', 'grok', 'opencode']); + export interface FleetCommandDependencies { core: CoreDependencies; sdk: SdkCommandDeps; + createFleetWorkspaceClient: (options: SdkClientOptions) => RelayWorkspaceThinClient; log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void; @@ -33,6 +43,11 @@ function withFleetDefaults(overrides: Partial = {}): F return { core, sdk, + createFleetWorkspaceClient: (options) => + createWorkspaceClient({ + workspaceKey: resolveWorkspaceKey(options), + baseUrl: resolveBaseUrl(options), + }), log: (...args: unknown[]) => console.log(...args), warn: (...args: unknown[]) => console.warn(...args), error: (...args: unknown[]) => console.error(...args), @@ -67,16 +82,115 @@ export function registerFleetCommands( .description('List fleet nodes in the workspace') .option('--capability ', 'Filter by capability name') .option('--name ', 'Filter by node name') + .option('--all', 'Include offline and direct history records') ).action(async (options: Record) => { await runSdk(deps.sdk, async () => { - warnIfInferredFromProjectBroker(options, deps.warn); + warnIfInferredFromProjectSession(options, deps.warn); const relay = deps.sdk.createWorkspaceRelay(sdkOptionsFromOpts(options)); + const nodes = await relay.nodes.list({ + capability: options.capability as string | undefined, + name: options.name as string | undefined, + }); + const liveNodes = nodes.filter(isAvailableFleetNode); + const historyNodes = nodes.filter((node) => !isAvailableFleetNode(node)); + const visibleNodes = options.all === true ? [...liveNodes, ...historyNodes] : liveNodes; + const hiddenCount = historyNodes.length; + if (hiddenCount > 0 && options.all !== true) { + deps.warn( + `${hiddenCount} offline or non-fleet records hidden. ` + + 'Run `agent-relay fleet nodes --all` to include history.' + ); + } printJson(deps.sdk, { - nodes: await relay.nodes.list({ - capability: options.capability as string | undefined, - name: options.name as string | undefined, - }), + nodes: visibleNodes, + }); + }); + }); + + addSdkOptions( + group + .command('spawn') + .description('Spawn an agent on a fleet node') + .argument('', 'AI CLI to launch', parseFleetCli) + .requiredOption('--name ', 'Worker agent name') + .requiredOption('--task ', 'Initial task instructions') + .option('--node ', 'Target a specific fleet node') + .option('--target-node ', 'Alias for --node') + .option('--channel ', 'Channel for the worker to join') + .option('--persona ', 'Worker persona (automatic placement)') + .option('--model ', 'Model powering the worker') + .option('--session-ref ', 'Session reference for a resumable targeted spawn') + ).action(async (cli: string, options: Record) => { + await runSdk(deps.sdk, async () => { + warnIfInferredFromProjectSession(options, deps.warn); + const clientOptions = sdkOptionsFromOpts(options); + const name = requiredText(options.name, 'Worker name'); + const task = requiredText(options.task, 'Task'); + const targetNode = + optionalText(options.targetNode, 'Target node') ?? optionalText(options.node, 'Node'); + const channel = optionalText(options.channel, 'Channel'); + const model = optionalText(options.model, 'Model'); + const sessionRef = optionalText(options.sessionRef, 'Session reference'); + + if (targetNode) { + if (!resolveAgentToken(clientOptions)) { + throw new Error( + 'Targeted Fleet spawn requires an agent token. Pass --token or set RELAY_AGENT_TOKEN.' + ); + } + const relay = deps.sdk.createAgentRelay(clientOptions); + const invocation = await relay.messaging.placement.spawn({ + capability: `spawn:${cli}`, + node: targetNode, + failFast: true, + input: { + name, + cli, + task, + ...(channel ? { channels: [channel] } : {}), + ...(model ? { model } : {}), + ...(sessionRef ? { session_ref: sessionRef } : {}), + }, + }); + printJson(deps.sdk, { invocation }); + return; + } + + if (sessionRef) { + throw new Error('--session-ref requires --node or --target-node.'); + } + const persona = optionalText(options.persona, 'Persona'); + const workspace = deps.createFleetWorkspaceClient(clientOptions); + const invocation = await workspace.agents.spawn({ + name, + cli, + task, + ...(channel ? { channel } : {}), + ...(persona ? { persona } : {}), + ...(model ? { metadata: { model } } : {}), + }); + printJson(deps.sdk, { invocation }); + }); + }); + + addSdkOptions( + group + .command('release') + .description('Release a spawned fleet agent') + .argument('', 'Worker agent name') + .option('--reason ', 'Release reason') + .option('--delete-agent', 'Permanently delete the agent after release') + ).action(async (name: string, options: Record) => { + await runSdk(deps.sdk, async () => { + warnIfInferredFromProjectSession(options, deps.warn); + const workspace = deps.createFleetWorkspaceClient(sdkOptionsFromOpts(options)); + const reason = optionalText(options.reason, 'Reason'); + const released = await workspace.agents.release({ + name: requiredText(name, 'Worker name'), + ...(reason ? { reason } : {}), + deleteAgent: options.deleteAgent === true, }); + printJson(deps.sdk, released); }); }); @@ -128,15 +242,50 @@ export function registerFleetCommands( }); } +/** Return whether a roster entry can currently accept Fleet work. */ +function isAvailableFleetNode(node: { + live?: boolean; + status?: string; + handlersLive?: boolean; + tags?: unknown; +}): boolean { + const tags = Array.isArray(node.tags) ? node.tags : []; + const isDirectPseudoNode = tags.includes('direct'); + const isLive = node.live === undefined ? node.status === 'online' : node.live === true; + return isLive && node.handlersLive !== false && !isDirectPseudoNode; +} + +function parseFleetCli(value: string): string { + const cli = value.trim().toLowerCase(); + if (!FLEET_CLIS.has(cli)) { + throw new InvalidArgumentError( + `unsupported CLI "${value}"; expected one of: ${[...FLEET_CLIS].join(', ')}` + ); + } + return cli; +} + +function requiredText(value: unknown, label: string): string { + const text = typeof value === 'string' ? value.trim() : ''; + if (!text) { + throw new Error(`${label} is required.`); + } + return text; +} + +function optionalText(value: unknown, label: string): string | undefined { + if (value === undefined) return undefined; + return requiredText(value, label); +} + /** * Warn (on stderr, so it never pollutes the JSON on stdout) when the workspace - * key was inferred from the local broker's project record rather than named - * explicitly. That key is whatever `agent-relay up` last joined in this - * directory, which can be stale — surfacing it lets the operator override with - * `--workspace-key`/`--wk` or `RELAY_WORKSPACE_KEY` if the roster looks wrong. + * key was inferred from the project's persisted session rather than named + * explicitly. Surfacing the source lets the operator override with + * `--workspace-key`/`--wk` or `RELAY_WORKSPACE_KEY` for a one-off query. * Resolution errors are swallowed: the SDK call below reports the real failure. */ -function warnIfInferredFromProjectBroker( +function warnIfInferredFromProjectSession( options: Record, warn: (...args: unknown[]) => void ): void { @@ -148,9 +297,8 @@ function warnIfInferredFromProjectBroker( } if (source === 'project') { warn( - 'Note: using the workspace key `agent-relay up` recorded in this directory. ' + - 'If the local broker has since joined a different workspace, pass --workspace-key/--wk ' + - 'or set RELAY_WORKSPACE_KEY to override.' + 'Note: using the workspace session pinned to this project. ' + + 'Pass --workspace-key/--wk or set RELAY_WORKSPACE_KEY to override for this command.' ); } } diff --git a/packages/cli/src/cli/commands/integration-relayfile-contract.test.ts b/packages/cli/src/cli/commands/integration-relayfile-contract.test.ts index db43d8559..686ea9ac3 100644 --- a/packages/cli/src/cli/commands/integration-relayfile-contract.test.ts +++ b/packages/cli/src/cli/commands/integration-relayfile-contract.test.ts @@ -319,7 +319,7 @@ describe('relayfile control-plane hello negotiation', () => { binary, autoStart: true, startTimeoutMs: 2000, - requestTimeoutMs: 1000, + requestTimeoutMs: 5000, }); await expect(bridge.ensureCompatible()).resolves.toBeUndefined(); }); diff --git a/packages/cli/src/cli/commands/integration-subscribe.test.ts b/packages/cli/src/cli/commands/integration-subscribe.test.ts index 9d1b3d29a..ffb2a37a1 100644 --- a/packages/cli/src/cli/commands/integration-subscribe.test.ts +++ b/packages/cli/src/cli/commands/integration-subscribe.test.ts @@ -298,7 +298,9 @@ describe('integration subscribe', () => { it('fails loudly before provisioning when no workspace key is available', async () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-no-workspace-')); vi.stubEnv('AGENT_RELAY_HOME', home); + vi.stubEnv('AGENT_RELAY_PROJECT', path.join(home, 'project')); vi.stubEnv('RELAY_WORKSPACE_KEY', ''); + vi.stubEnv('AGENT_RELAY_WORKSPACE_KEY', ''); vi.stubEnv('RELAY_API_KEY', ''); const { program, relay, relayfile, error, exit } = harness({ resolveLocalRelayOptions: async () => undefined, diff --git a/packages/cli/src/cli/commands/message.ts b/packages/cli/src/cli/commands/message.ts index 7927de679..c065fb722 100644 --- a/packages/cli/src/cli/commands/message.ts +++ b/packages/cli/src/cli/commands/message.ts @@ -22,6 +22,13 @@ function parseLimit(value: string): number { return parsed; } +function parseMessageMode(value: string): 'wait' | 'steer' { + if (value === 'wait' || value === 'steer') { + return value; + } + throw new InvalidArgumentError('mode must be "wait" or "steer"'); +} + export function registerMessageCommands( program: Command, overrides: Partial = {} @@ -112,9 +119,17 @@ export function registerMessageCommands( .description('Send a direct message to an agent') .argument('', 'Recipient agent') .argument('', 'Message text') + .option('--mode ', 'Delivery mode: wait or steer', parseMessageMode) ).action(async (agent: string, text: string, o: Record) => { await runSdk(deps, async () => { - printJson(deps, await deps.createAgentRelay(opts(o)).messages.direct({ to: agent, text })); + printJson( + deps, + await deps.createAgentRelay(opts(o)).messages.direct({ + to: agent, + text, + ...(o.mode ? { mode: o.mode as 'wait' | 'steer' } : {}), + }) + ); }); }); diff --git a/packages/cli/src/cli/commands/node.test.ts b/packages/cli/src/cli/commands/node.test.ts index 186cf9eae..298a0c2fd 100644 --- a/packages/cli/src/cli/commands/node.test.ts +++ b/packages/cli/src/cli/commands/node.test.ts @@ -37,6 +37,7 @@ const enrollmentRecord = { function createNodeHarness(opts?: { env?: NodeJS.ProcessEnv; resolveEnrollment?: NodeCommandDependencies['resolveEnrollment']; + resolveProjectWorkspaceSession?: NodeCommandDependencies['resolveProjectWorkspaceSession']; }) { const env: NodeJS.ProcessEnv = opts?.env ?? {}; const exit = vi.fn((code: number) => { @@ -50,12 +51,29 @@ function createNodeHarness(opts?: { const resolveEnrollment = opts?.resolveEnrollment ?? (vi.fn(() => undefined) as unknown as NodeCommandDependencies['resolveEnrollment']); + const resolveProjectWorkspaceSession = opts?.resolveProjectWorkspaceSession ?? vi.fn(() => undefined); const program = new Command(); program.exitOverride(); - registerNodeCommands(program, { core, exit, log, error, warn, resolveEnrollment }); + registerNodeCommands(program, { + core, + exit, + log, + error, + warn, + resolveEnrollment, + resolveProjectWorkspaceSession, + }); - return { program, env, log, error, exit, resolveEnrollment }; + return { + program, + env, + log, + error, + exit, + resolveEnrollment, + resolveProjectWorkspaceSession, + }; } beforeEach(() => { @@ -98,6 +116,7 @@ describe('registerNodeCommands', () => { expect(resolveEnrollment).toHaveBeenCalledTimes(1); expect(env.RELAY_NODE_TOKEN).toBe('nt_secret'); expect(env.RELAY_NODE_ID).toBe('node_abc'); + expect(env.AGENT_RELAY_ENROLLED_NODE_ID).toBe('node_abc'); expect(env.RELAY_BASE_URL).toBe('https://relaycast.example.com'); expect(brokerMocks.runUpCommand).toHaveBeenCalledWith( expect.objectContaining({ discoverConfig: true, nodeName: 'kjglaptop' }), @@ -172,6 +191,98 @@ describe('registerNodeCommands', () => { expect(brokerMocks.runUpCommand).toHaveBeenCalledTimes(1); }); + it('falls through a blank primary workspace env var to an explicit alias', async () => { + const resolveEnrollment = vi.fn( + () => enrollmentRecord + ) as unknown as NodeCommandDependencies['resolveEnrollment']; + const resolveProjectWorkspaceSession = vi.fn(() => ({ + workspaceKey: 'rk_project_session', + enrolledNodeId: 'node_abc', + })); + const { program, env } = createNodeHarness({ + env: { + RELAY_WORKSPACE_KEY: ' ', + AGENT_RELAY_WORKSPACE_KEY: ' rk_alias ', + }, + resolveEnrollment, + resolveProjectWorkspaceSession, + }); + + await program.parseAsync(['node', 'up'], { from: 'user' }); + + expect(resolveEnrollment).not.toHaveBeenCalled(); + expect(resolveProjectWorkspaceSession).not.toHaveBeenCalled(); + expect(env.RELAY_WORKSPACE_KEY).toBe('rk_alias'); + expect(env.RELAY_NODE_TOKEN).toBeUndefined(); + }); + + it('resumes a project-pinned workspace instead of replacing it with an enrollment', async () => { + const resolveEnrollment = vi.fn( + () => enrollmentRecord + ) as unknown as NodeCommandDependencies['resolveEnrollment']; + const resolveProjectWorkspaceSession = vi.fn(() => ({ + workspaceKey: 'rk_project_session', + })); + const { program, env } = createNodeHarness({ + env: {}, + resolveEnrollment, + resolveProjectWorkspaceSession, + }); + + await program.parseAsync(['node', 'up'], { from: 'user' }); + + expect(resolveEnrollment).not.toHaveBeenCalled(); + expect(env.RELAY_WORKSPACE_KEY).toBe('rk_project_session'); + expect(env.RELAY_API_KEY).toBe('rk_project_session'); + expect(env.RELAY_NODE_TOKEN).toBeUndefined(); + expect(brokerMocks.runUpCommand).toHaveBeenCalledTimes(1); + }); + + it('preserves an enrolled identity across a consecutive project-session restart', async () => { + const firstResolveEnrollment = vi.fn( + () => enrollmentRecord + ) as unknown as NodeCommandDependencies['resolveEnrollment']; + const first = createNodeHarness({ env: {}, resolveEnrollment: firstResolveEnrollment }); + + await first.program.parseAsync(['node', 'up', '--background'], { from: 'user' }); + + expect(first.env).toMatchObject({ + AGENT_RELAY_ENROLLED_NODE_ID: 'node_abc', + RELAY_NODE_ID: 'node_abc', + RELAY_NODE_TOKEN: 'nt_secret', + }); + + const restartResolveEnrollment = vi.fn( + () => enrollmentRecord + ) as unknown as NodeCommandDependencies['resolveEnrollment']; + const restart = createNodeHarness({ + env: {}, + resolveEnrollment: restartResolveEnrollment, + resolveProjectWorkspaceSession: vi.fn(() => ({ + workspaceKey: 'rk_enrolled', + enrolledNodeId: 'node_abc', + })), + }); + + await restart.program.parseAsync(['node', 'up', '--background'], { from: 'user' }); + + expect(restartResolveEnrollment).toHaveBeenCalledWith(expect.objectContaining({ nodeId: 'node_abc' })); + expect(restart.env).toMatchObject({ + AGENT_RELAY_ENROLLED_NODE_ID: 'node_abc', + RELAY_NODE_ID: 'node_abc', + RELAY_NODE_TOKEN: 'nt_secret', + }); + expect(restart.env.RELAY_WORKSPACE_KEY).toBeUndefined(); + expect(brokerMocks.runUpCommand).toHaveBeenLastCalledWith( + expect.objectContaining({ + background: true, + brokerName: 'kjglaptop', + nodeName: 'kjglaptop', + }), + expect.anything() + ); + }); + it('does not clobber an existing RELAY_NODE_TOKEN or query the store', async () => { const resolveEnrollment = vi.fn( () => enrollmentRecord diff --git a/packages/cli/src/cli/commands/node.ts b/packages/cli/src/cli/commands/node.ts index abd29f3e9..a8bf1bc13 100644 --- a/packages/cli/src/cli/commands/node.ts +++ b/packages/cli/src/cli/commands/node.ts @@ -9,6 +9,8 @@ import { type UpCommandOptions, } from './core.js'; import { runUpCommand } from '../lib/broker-lifecycle.js'; +import { readProjectWorkspaceSession, type ProjectWorkspaceSession } from '../lib/project-workspace-key.js'; +import { promoteWorkspaceKeyEnvAlias } from '../lib/workspace-env.js'; import { registerLocalAgentCommands } from './local-agent.js'; import { registerLocalWorkflowCommands } from './local-workflow.js'; @@ -17,6 +19,7 @@ type ExitFn = (code: number) => never; export interface NodeCommandDependencies { core: CoreDependencies; resolveEnrollment: typeof resolveActiveFleetNodeEnrollment; + resolveProjectWorkspaceSession: () => ProjectWorkspaceSession | undefined; log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void; @@ -28,6 +31,7 @@ function withNodeDefaults(overrides: Partial = {}): Nod return { core, resolveEnrollment: resolveActiveFleetNodeEnrollment, + resolveProjectWorkspaceSession: () => readProjectWorkspaceSession(core.getProjectPaths().dataDir), log: (...args: unknown[]) => console.log(...args), warn: (...args: unknown[]) => console.warn(...args), error: (...args: unknown[]) => console.error(...args), @@ -68,6 +72,86 @@ export function registerNodeCommands( ); } +/** Normalize workspace env aliases and report whether `node up` received an explicit workspace. */ +function prepareExplicitWorkspaceForNodeUp( + options: UpCommandOptions, + deps: NodeCommandDependencies +): boolean { + const envWorkspaceKey = promoteWorkspaceKeyEnvAlias(deps.core.env); + return Boolean(options.workspaceKey?.trim() || envWorkspaceKey); +} + +/** Apply a project-pinned workspace without changing the persisted enrolled-node association. */ +function resumeProjectWorkspace(session: ProjectWorkspaceSession, deps: NodeCommandDependencies): void { + deps.core.env.RELAY_WORKSPACE_KEY = session.workspaceKey; + deps.core.env.RELAY_API_KEY = session.workspaceKey; +} + +/** Apply stored enrollment credentials and return the enrolled node name, when present. */ +function applyEnrollment( + record: NonNullable>, + deps: NodeCommandDependencies +): string | undefined { + const env = deps.core.env; + env.RELAY_NODE_TOKEN = record.nodeToken; + env.RELAY_NODE_ID = record.nodeId; + // Internal, non-secret association inherited by a detached child. The broker + // records it beside the project workspace so later starts can resolve the + // same durable enrollment instead of minting a replacement node identity. + env.AGENT_RELAY_ENROLLED_NODE_ID = record.nodeId; + if (!env.RELAY_BASE_URL) { + env.RELAY_BASE_URL = record.relaycastUrl; + } + deps.log( + `Using persisted Cloud enrollment for node "${record.nodeName}" (workspace ${record.relayWorkspaceId}).` + ); + return record.nodeName?.trim() || undefined; +} + +/** Resolve the enrollment associated with a project session, avoiding ambiguous global fallback. */ +function resolveEnrollmentForProject( + session: ProjectWorkspaceSession | undefined, + deps: NodeCommandDependencies +): ReturnType | undefined { + const env = deps.core.env; + if (session?.enrolledNodeId) { + return deps.resolveEnrollment({ + ...(env.RELAY_BASE_URL ? { baseUrl: env.RELAY_BASE_URL } : {}), + nodeId: session.enrolledNodeId, + env, + }); + } + if (session) { + return undefined; + } + return deps.resolveEnrollment({ + ...(env.RELAY_BASE_URL ? { baseUrl: env.RELAY_BASE_URL } : {}), + env, + }); +} + +/** Apply an enrollment or safely resume a project workspace when its enrollment is unavailable. */ +function applyResolvedNodeSession( + record: ReturnType | undefined, + projectSession: ProjectWorkspaceSession | undefined, + deps: NodeCommandDependencies +): string | undefined { + if (record) { + return applyEnrollment(record, deps); + } + if (!projectSession) { + return undefined; + } + if (projectSession.enrolledNodeId) { + deps.core.env.AGENT_RELAY_ENROLLED_NODE_ID = projectSession.enrolledNodeId; + deps.warn( + `Persisted enrollment for node "${projectSession.enrolledNodeId}" was not found; resuming the pinned workspace without that node identity.` + ); + } + resumeProjectWorkspace(projectSession, deps); + return undefined; +} + /** * `node up` with Cloud enrollment pickup: when no `RELAY_NODE_TOKEN` is set, * resolve a persisted enrollment and wire its credentials into the env before @@ -79,15 +163,16 @@ async function runNodeUp(options: UpCommandOptions, deps: NodeCommandDependencie // enrollment store records workspace ids, not keys, so a stored enrollment // cannot be matched against it — skip pickup entirely rather than risk // starting the broker with a token from a different workspace. - const explicitWorkspaceKey = Boolean(options.workspaceKey?.trim() || env.RELAY_WORKSPACE_KEY?.trim()); + const explicitWorkspaceKey = prepareExplicitWorkspaceForNodeUp(options, deps); let enrolledNodeName: string | undefined; + if (env.RELAY_NODE_TOKEN?.trim() && env.RELAY_NODE_ID?.trim()) { + env.AGENT_RELAY_ENROLLED_NODE_ID ??= env.RELAY_NODE_ID.trim(); + } if (!env.RELAY_NODE_TOKEN && !explicitWorkspaceKey) { + const projectSession = deps.resolveProjectWorkspaceSession(); let record: ReturnType | undefined; try { - record = deps.resolveEnrollment({ - ...(env.RELAY_BASE_URL ? { baseUrl: env.RELAY_BASE_URL } : {}), - env, - }); + record = resolveEnrollmentForProject(projectSession, deps); } catch (err) { // A missing store resolves to undefined (fine); only an ambiguous // multi-match throws, which must surface as a clear CLI error. @@ -95,20 +180,9 @@ async function runNodeUp(options: UpCommandOptions, deps: NodeCommandDependencie deps.exit(1); return; } - if (record) { - env.RELAY_NODE_TOKEN = record.nodeToken; - env.RELAY_NODE_ID = record.nodeId; - if (!env.RELAY_BASE_URL) { - env.RELAY_BASE_URL = record.relaycastUrl; - } - // Serve under the enrolled name (mirrors the old - // `fleet serve --enrollment-token` behavior where --name beat the - // enrollment record's nodeName). - enrolledNodeName = record.nodeName?.trim() || undefined; - deps.log( - `Using persisted Cloud enrollment for node "${record.nodeName}" (workspace ${record.relayWorkspaceId}).` - ); - } + // Serve under the enrolled name (mirrors the old `fleet serve + // --enrollment-token` behavior where --name beat the enrollment name). + enrolledNodeName = applyResolvedNodeSession(record, projectSession, deps); } const nodeName = options.brokerName ?? enrolledNodeName; diff --git a/packages/cli/src/cli/commands/relaycast-groups.test.ts b/packages/cli/src/cli/commands/relaycast-groups.test.ts index d5d48fea9..4884da968 100644 --- a/packages/cli/src/cli/commands/relaycast-groups.test.ts +++ b/packages/cli/src/cli/commands/relaycast-groups.test.ts @@ -150,6 +150,18 @@ describe('SDK-backed CLI groups', () => { expect(relay.messages.direct).toHaveBeenCalledWith({ to: 'lead', text: 'hi' }); }); + it('message dm send exposes immediate Relay delivery', async () => { + const { program, relay } = harness(registerMessageCommands); + await program.parseAsync(['message', 'dm', 'send', 'lead', 'wake up', '--mode', 'steer'], { + from: 'user', + }); + expect(relay.messages.direct).toHaveBeenCalledWith({ + to: 'lead', + text: 'wake up', + mode: 'steer', + }); + }); + it('integration webhook create routes to integrations.webhooks.create', async () => { const { program, relay } = harness(registerIntegrationCommands); await program.parseAsync( diff --git a/packages/cli/src/cli/commands/workspace.test.ts b/packages/cli/src/cli/commands/workspace.test.ts index 7ed6201a6..c4501f4bc 100644 --- a/packages/cli/src/cli/commands/workspace.test.ts +++ b/packages/cli/src/cli/commands/workspace.test.ts @@ -8,9 +8,24 @@ vi.mock('@agent-relay/cloud', () => ({ switchWorkspace: vi.fn(), })); -import { resolveActiveWorkspace } from '@agent-relay/cloud'; +vi.mock('../lib/workspace-session.js', () => ({ + persistWorkspaceSession: vi.fn(), + validateWorkspaceSessionName: vi.fn((name: string) => { + const trimmed = name.trim(); + if (!trimmed) throw new Error('Workspace name is required.'); + return trimmed; + }), +})); + +import { + readWorkspaceStore, + resolveActiveWorkspace, + setWorkspaceKey, + switchWorkspace, +} from '@agent-relay/cloud'; import { registerWorkspaceCommands, type WorkspaceCommandDependencies } from './workspace.js'; +import { persistWorkspaceSession, validateWorkspaceSessionName } from '../lib/workspace-session.js'; beforeEach(() => { vi.clearAllMocks(); @@ -83,4 +98,62 @@ describe('registerWorkspaceCommands', () => { apiUrl: 'https://cloud.test', }); }); + + it('workspace create starts and persists a new workspace session', async () => { + const { program, deps } = createHarness(); + vi.mocked(deps.createWorkspace).mockResolvedValueOnce({ + workspaceKey: 'rk_live_session_two', + } as never); + + await program.parseAsync(['node', 'agent-relay', 'workspace', 'create', 'session-two']); + + expect(persistWorkspaceSession).toHaveBeenCalledWith({ + name: 'session-two', + workspaceKey: 'rk_live_session_two', + }); + }); + + it('workspace create rejects a blank name before provisioning a remote workspace', async () => { + const { program, deps } = createHarness(); + + await expect(program.parseAsync(['node', 'agent-relay', 'workspace', 'create', ' '])).rejects.toThrow( + 'exit:1' + ); + + expect(validateWorkspaceSessionName).toHaveBeenCalledWith(' '); + expect(deps.error).toHaveBeenCalledWith('Workspace name is required.'); + expect(deps.createWorkspace).not.toHaveBeenCalled(); + expect(persistWorkspaceSession).not.toHaveBeenCalled(); + }); + + it('workspace join persists the joined workspace as the current session', async () => { + const { program } = createHarness(); + + await program.parseAsync(['node', 'agent-relay', 'workspace', 'join', 'shared', 'rk_live_shared']); + + expect(persistWorkspaceSession).toHaveBeenCalledWith({ + name: 'shared', + workspaceKey: 'rk_live_shared', + }); + expect(setWorkspaceKey).not.toHaveBeenCalled(); + expect(switchWorkspace).not.toHaveBeenCalled(); + }); + + it('workspace switch pins the selected workspace to the current project', async () => { + vi.mocked(readWorkspaceStore).mockReturnValueOnce({ + active: 'default', + workspaces: { + default: { key: 'rk_live_default' }, + shared: { key: 'rk_live_shared' }, + }, + }); + const { program } = createHarness(); + + await program.parseAsync(['node', 'agent-relay', 'workspace', 'switch', 'shared']); + + expect(persistWorkspaceSession).toHaveBeenCalledWith({ + name: 'shared', + workspaceKey: 'rk_live_shared', + }); + }); }); diff --git a/packages/cli/src/cli/commands/workspace.ts b/packages/cli/src/cli/commands/workspace.ts index ded73d2ad..549762ba7 100644 --- a/packages/cli/src/cli/commands/workspace.ts +++ b/packages/cli/src/cli/commands/workspace.ts @@ -3,7 +3,8 @@ import { InvalidArgumentError } from 'commander'; import { resolveActiveWorkspace } from '@agent-relay/cloud'; import { printJson, runSdk, withSdkDefaults, type SdkCommandDeps } from '../lib/sdk-command.js'; -import { readWorkspaceStore, setWorkspaceKey, switchWorkspace } from '../lib/workspace-store.js'; +import { readWorkspaceStore, setWorkspaceKey } from '../lib/workspace-store.js'; +import { persistWorkspaceSession, validateWorkspaceSessionName } from '../lib/workspace-session.js'; export type WorkspaceCommandDependencies = SdkCommandDeps; @@ -59,11 +60,12 @@ export function registerWorkspaceCommands( .option('--base-url ', 'Override the API base URL') .action(async (name: string, o: Record) => { await runSdk(deps, async () => { - const relay = await deps.createWorkspace(name, o.baseUrl as string | undefined); + const workspaceName = validateWorkspaceSessionName(name); + const relay = await deps.createWorkspace(workspaceName, o.baseUrl as string | undefined); if (relay.workspaceKey) { - setWorkspaceKey(name, relay.workspaceKey); + persistWorkspaceSession({ name: workspaceName, workspaceKey: relay.workspaceKey }); } - printJson(deps, { name, workspaceKey: relay.workspaceKey }); + printJson(deps, { name: workspaceName, workspaceKey: relay.workspaceKey }); }); }); @@ -99,8 +101,7 @@ export function registerWorkspaceCommands( .argument('', 'Workspace key') .action(async (name: string, key: string) => { await runSdk(deps, async () => { - setWorkspaceKey(name, key); - switchWorkspace(name); + persistWorkspaceSession({ name, workspaceKey: key }); deps.log(`Joined and switched to workspace "${name}".`); }); }); @@ -111,7 +112,14 @@ export function registerWorkspaceCommands( .argument('', 'Workspace name') .action(async (name: string) => { await runSdk(deps, async () => { - switchWorkspace(name); + const store = readWorkspaceStore(); + const workspace = store.workspaces[name]; + if (!workspace) { + throw new Error( + `Unknown workspace "${name}". Add it with \`relay workspace set_key ${name} \`.` + ); + } + persistWorkspaceSession({ name, workspaceKey: workspace.key }); deps.log(`Switched to workspace "${name}".`); }); }); diff --git a/packages/cli/src/cli/lib/broker-lifecycle.ts b/packages/cli/src/cli/lib/broker-lifecycle.ts index b02ddd7b0..d05266c39 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.ts @@ -22,7 +22,8 @@ import { type RunningNodeProviderChild, } from './node-provider-child.js'; import { startReflexCapture, type RunningReflexCapture } from './reflex-capture.js'; -import { writeProjectWorkspaceKey } from './project-workspace-key.js'; +import { projectWorkspaceKeyPath, writeProjectWorkspaceKey } from './project-workspace-key.js'; +import { promoteWorkspaceKeyEnvAlias } from './workspace-env.js'; type UpOptions = { spawn?: boolean; @@ -1197,6 +1198,59 @@ function planCapacitySource( return plan.mode === 'in-process' ? plan.definition : descriptorCapacitySource(plan.descriptor); } +interface PinnedProjectWorkspaceSession { + workspaceKey: string; + enrolledNodeId?: string; +} + +/** Read the minimal project session needed during broker startup. */ +function readPinnedProjectWorkspaceSession( + dataDir: string, + deps: CoreDependencies +): PinnedProjectWorkspaceSession | undefined { + try { + const parsed = JSON.parse(deps.fs.readFileSync(projectWorkspaceKeyPath(dataDir), 'utf8')) as Partial<{ + workspaceKey: string; + enrolledNodeId: string; + }>; + const workspaceKey = + typeof parsed.workspaceKey === 'string' ? parsed.workspaceKey.trim() || undefined : undefined; + if (!workspaceKey) { + return undefined; + } + const enrolledNodeId = + typeof parsed.enrolledNodeId === 'string' ? parsed.enrolledNodeId.trim() || undefined : undefined; + return { + workspaceKey, + ...(enrolledNodeId ? { enrolledNodeId } : {}), + }; + } catch { + return undefined; + } +} + +/** Resume the pinned project session unless explicit credentials override it. */ +function resumePinnedProjectWorkspace( + options: UpOptions, + deps: CoreDependencies, + projectDataDir: string +): PinnedProjectWorkspaceSession | undefined { + const explicitEnvWorkspaceKey = promoteWorkspaceKeyEnvAlias(deps.env); + if (options.workspaceKey?.trim() || explicitEnvWorkspaceKey || deps.env.RELAY_NODE_TOKEN?.trim()) { + return undefined; + } + + const session = readPinnedProjectWorkspaceSession(projectDataDir, deps); + if (session) { + deps.env.RELAY_WORKSPACE_KEY = session.workspaceKey; + deps.env.RELAY_API_KEY = session.workspaceKey; + if (session.enrolledNodeId) { + deps.env.AGENT_RELAY_ENROLLED_NODE_ID = session.enrolledNodeId; + } + } + return session; +} + export async function runUpCommand(options: UpOptions, deps: CoreDependencies): Promise { ensureBundledAgentRelayMcpCommand(deps); @@ -1207,6 +1261,7 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): // --state-dir), so the key must be persisted here even when broker state is // redirected elsewhere. const projectWorkspaceKeyDataDir = paths.dataDir; + const resumedProjectSession = resumePinnedProjectWorkspace(options, deps, projectWorkspaceKeyDataDir); // --state-dir overrides where the broker writes state / connection files if (options.stateDir) { const resolved = path.resolve(options.stateDir); @@ -1434,7 +1489,9 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): // workspace. Persistence must never abort startup, so a write failure is // swallowed. try { - writeProjectWorkspaceKey(projectWorkspaceKeyDataDir, relay.workspaceKey ?? undefined); + writeProjectWorkspaceKey(projectWorkspaceKeyDataDir, relay.workspaceKey ?? undefined, { + enrolledNodeId: deps.env.AGENT_RELAY_ENROLLED_NODE_ID ?? resumedProjectSession?.enrolledNodeId, + }); } catch { // best-effort: a broker that came up should stay up even if the key file // can't be written (read-only dir, etc.). diff --git a/packages/cli/src/cli/lib/project-workspace-key.test.ts b/packages/cli/src/cli/lib/project-workspace-key.test.ts index a6288345c..0c03b06a6 100644 --- a/packages/cli/src/cli/lib/project-workspace-key.test.ts +++ b/packages/cli/src/cli/lib/project-workspace-key.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { projectWorkspaceKeyPath, readProjectWorkspaceKey, + readProjectWorkspaceSession, writeProjectWorkspaceKey, } from './project-workspace-key.js'; @@ -29,6 +30,14 @@ describe('project workspace key store', () => { expect(mode).toBe(0o600); }); + it('round-trips the enrolled node association without exposing another credential', () => { + writeProjectWorkspaceKey(dataDir, 'rk_project', { enrolledNodeId: ' node_1 ' }); + expect(readProjectWorkspaceSession(dataDir)).toEqual({ + workspaceKey: 'rk_project', + enrolledNodeId: 'node_1', + }); + }); + it('reads undefined for a missing, malformed, or blank-key file', () => { expect(readProjectWorkspaceKey(dataDir)).toBeUndefined(); diff --git a/packages/cli/src/cli/lib/project-workspace-key.ts b/packages/cli/src/cli/lib/project-workspace-key.ts index 9099851e7..0cac556c4 100644 --- a/packages/cli/src/cli/lib/project-workspace-key.ts +++ b/packages/cli/src/cli/lib/project-workspace-key.ts @@ -3,5 +3,7 @@ export { projectWorkspaceKeyPath, readProjectWorkspaceKey, + readProjectWorkspaceSession, writeProjectWorkspaceKey, + type ProjectWorkspaceSession, } from '@agent-relay/cloud/workspace-key'; diff --git a/packages/cli/src/cli/lib/workspace-env.ts b/packages/cli/src/cli/lib/workspace-env.ts new file mode 100644 index 000000000..ef81563ca --- /dev/null +++ b/packages/cli/src/cli/lib/workspace-env.ts @@ -0,0 +1,15 @@ +/** + * Normalize the supported workspace-key environment aliases into the canonical + * `RELAY_WORKSPACE_KEY` variable while preserving their documented precedence. + * + * @returns The first non-blank workspace key, or `undefined` when none is set. + */ +export function promoteWorkspaceKeyEnvAlias(env: NodeJS.ProcessEnv): string | undefined { + const workspaceKey = [env.RELAY_WORKSPACE_KEY, env.AGENT_RELAY_WORKSPACE_KEY, env.RELAY_API_KEY] + .map((value) => value?.trim()) + .find(Boolean); + if (workspaceKey && !env.RELAY_WORKSPACE_KEY?.trim()) { + env.RELAY_WORKSPACE_KEY = workspaceKey; + } + return workspaceKey; +} diff --git a/packages/cli/src/cli/lib/workspace-session.test.ts b/packages/cli/src/cli/lib/workspace-session.test.ts new file mode 100644 index 000000000..8183077c9 --- /dev/null +++ b/packages/cli/src/cli/lib/workspace-session.test.ts @@ -0,0 +1,125 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { promoteWorkspaceKeyEnvAlias } from './workspace-env.js'; +import { persistWorkspaceSession, resolveWorkspaceSessionKey } from './workspace-session.js'; +import { readProjectWorkspaceKey } from './project-workspace-key.js'; +import { readWorkspaceStore, setWorkspaceKey } from './workspace-store.js'; + +const tempRoots: string[] = []; + +function tempRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-workspace-session-')); + tempRoots.push(root); + return root; +} + +function isolatedEnv(root: string): NodeJS.ProcessEnv { + const env = { ...process.env, AGENT_RELAY_HOME: path.join(root, 'home') }; + delete env.RELAY_WORKSPACE_KEY; + delete env.AGENT_RELAY_WORKSPACE_KEY; + delete env.RELAY_API_KEY; + return env; +} + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe('workspace session persistence', () => { + it('normalizes the first non-blank workspace-key environment alias', () => { + const env = { + RELAY_WORKSPACE_KEY: ' ', + AGENT_RELAY_WORKSPACE_KEY: ' rk_live_alias ', + RELAY_API_KEY: 'rk_live_legacy', + }; + + expect(promoteWorkspaceKeyEnvAlias(env)).toBe('rk_live_alias'); + expect(env.RELAY_WORKSPACE_KEY).toBe('rk_live_alias'); + }); + + it('preserves the canonical workspace-key environment precedence', () => { + const env = { + RELAY_WORKSPACE_KEY: ' rk_live_canonical ', + AGENT_RELAY_WORKSPACE_KEY: 'rk_live_alias', + RELAY_API_KEY: 'rk_live_legacy', + }; + + expect(promoteWorkspaceKeyEnvAlias(env)).toBe('rk_live_canonical'); + expect(env.RELAY_WORKSPACE_KEY).toBe(' rk_live_canonical '); + }); + + it('pins a named workspace to the project and makes it globally active', () => { + const root = tempRoot(); + const projectDataDir = path.join(root, 'project', '.agentworkforce', 'relay'); + const env = isolatedEnv(root); + + persistWorkspaceSession({ + workspaceKey: 'rk_live_session_two', + name: 'session-two', + projectDataDir, + env, + }); + + expect(readProjectWorkspaceKey(projectDataDir)).toBe('rk_live_session_two'); + expect(readWorkspaceStore(env)).toEqual({ + active: 'session-two', + workspaces: { + 'session-two': { key: 'rk_live_session_two' }, + }, + }); + }); + + it('pins an explicitly supplied key without changing the named global workspace', () => { + const root = tempRoot(); + const projectDataDir = path.join(root, 'project', '.agentworkforce', 'relay'); + const env = isolatedEnv(root); + setWorkspaceKey('default', 'rk_live_default', env); + + persistWorkspaceSession({ + workspaceKey: 'rk_live_shared', + projectDataDir, + env, + }); + + expect(readProjectWorkspaceKey(projectDataDir)).toBe('rk_live_shared'); + expect(readWorkspaceStore(env).active).toBe('default'); + }); + + it('rejects a whitespace-only workspace name before persisting the project session', () => { + const root = tempRoot(); + const projectDataDir = path.join(root, 'project', '.agentworkforce', 'relay'); + const env = isolatedEnv(root); + + expect(() => + persistWorkspaceSession({ + workspaceKey: 'rk_live_shared', + name: ' ', + projectDataDir, + env, + }) + ).toThrow('Workspace name is required.'); + + expect(readProjectWorkspaceKey(projectDataDir)).toBeUndefined(); + expect(readWorkspaceStore(env).workspaces).toEqual({}); + }); + + it('resumes the project workspace ahead of the machine-global active workspace', () => { + const root = tempRoot(); + const projectDataDir = path.join(root, 'project', '.agentworkforce', 'relay'); + const env = isolatedEnv(root); + setWorkspaceKey('default', 'rk_live_default', env); + persistWorkspaceSession({ + workspaceKey: 'rk_live_project', + projectDataDir, + env, + }); + + expect(resolveWorkspaceSessionKey({ projectDataDir, env })).toBe('rk_live_project'); + }); +}); diff --git a/packages/cli/src/cli/lib/workspace-session.ts b/packages/cli/src/cli/lib/workspace-session.ts new file mode 100644 index 000000000..f22289d3b --- /dev/null +++ b/packages/cli/src/cli/lib/workspace-session.ts @@ -0,0 +1,52 @@ +import { getProjectPaths } from '@agent-relay/config'; +import { resolveWorkspaceKeyWithSource } from '@agent-relay/cloud/workspace-key'; + +import { writeProjectWorkspaceKey } from './project-workspace-key.js'; +import { setWorkspaceKey, switchWorkspace, validateWorkspaceName } from './workspace-store.js'; + +export interface WorkspaceSessionOptions { + env?: NodeJS.ProcessEnv; + projectRoot?: string; + projectDataDir?: string; +} + +export interface PersistWorkspaceSessionOptions extends WorkspaceSessionOptions { + workspaceKey: string; + /** Named sessions are also stored and selected in the machine-global workspace store. */ + name?: string; +} + +/** Validate and normalize a workspace session name before local or remote writes. */ +export function validateWorkspaceSessionName(name: string): string { + return validateWorkspaceName(name); +} + +/** + * Resolve the workspace session for this process. Explicit/env selection still + * wins, then the project pin, then the machine-global active workspace. + */ +export function resolveWorkspaceSessionKey(options: WorkspaceSessionOptions = {}): string | undefined { + return resolveWorkspaceKeyWithSource(options)?.key; +} + +/** + * Pin a workspace to the current project so later CLI and MCP processes resume + * the same collaboration session. A named selection also becomes the + * machine-global active workspace; a bare shared key only changes this project. + */ +export function persistWorkspaceSession(options: PersistWorkspaceSessionOptions): void { + const workspaceKey = options.workspaceKey.trim(); + if (!workspaceKey) { + throw new Error('Workspace key is required.'); + } + + const name = options.name === undefined ? undefined : validateWorkspaceSessionName(options.name); + + const projectDataDir = options.projectDataDir ?? getProjectPaths(options.projectRoot).dataDir; + writeProjectWorkspaceKey(projectDataDir, workspaceKey); + + if (name) { + setWorkspaceKey(name, workspaceKey, options.env); + switchWorkspace(name, options.env); + } +} diff --git a/packages/cloud/src/fleet.test.ts b/packages/cloud/src/fleet.test.ts index 97fc6df56..7c44a5bb6 100644 --- a/packages/cloud/src/fleet.test.ts +++ b/packages/cloud/src/fleet.test.ts @@ -260,6 +260,39 @@ describe('fleet node enrollment store', () => { expect(resolved?.nodeId).toBe('node_b'); }); + it('resolves a project-associated enrollment by nodeId', () => { + upsertFleetNodeEnrollment(record({ relayWorkspaceId: 'rw_1', nodeId: 'node_1' }), env); + upsertFleetNodeEnrollment(record({ relayWorkspaceId: 'rw_2', nodeId: 'node_2' }), env); + expect(resolveActiveFleetNodeEnrollment({ nodeId: 'node_1', env })?.relayWorkspaceId).toBe('rw_1'); + expect(resolveActiveFleetNodeEnrollment({ nodeId: 'node_missing', env })).toBeUndefined(); + }); + + it('uses the active enrollment when every requested selector matches it', () => { + upsertFleetNodeEnrollment(record({ relayWorkspaceId: 'rw_1', nodeId: 'node_1' }), env); + upsertFleetNodeEnrollment(record({ relayWorkspaceId: 'rw_2', nodeId: 'node_2' }), env); + + expect( + resolveActiveFleetNodeEnrollment({ + baseUrl: 'https://relaycast.example.com', + env, + })?.nodeId + ).toBe('node_2'); + }); + + it('does not return an active enrollment that fails the requested nodeId selector', () => { + upsertFleetNodeEnrollment(record({ relayWorkspaceId: 'rw_1', nodeId: 'node_target' }), env); + upsertFleetNodeEnrollment(record({ relayWorkspaceId: 'rw_2', nodeId: 'node_target' }), env); + upsertFleetNodeEnrollment(record({ relayWorkspaceId: 'rw_3', nodeId: 'node_active' }), env); + + expect(() => + resolveActiveFleetNodeEnrollment({ + baseUrl: 'https://relaycast.example.com', + nodeId: 'node_target', + env, + }) + ).toThrow(/Multiple fleet node enrollments/); + }); + it('returns undefined when nothing matches', () => { expect(resolveActiveFleetNodeEnrollment({ env })).toBeUndefined(); upsertFleetNodeEnrollment(record(), env); diff --git a/packages/cloud/src/fleet.ts b/packages/cloud/src/fleet.ts index 033aedf2b..36c67c570 100644 --- a/packages/cloud/src/fleet.ts +++ b/packages/cloud/src/fleet.ts @@ -333,28 +333,32 @@ export function upsertFleetNodeEnrollment( } /** - * Resolve a stored enrollment, optionally narrowed by base URL and/or workspace. + * Resolve a stored enrollment, optionally narrowed by base URL, workspace, + * and/or node identity. * * @param input - Optional `baseUrl`/`workspaceId` selectors and env override. * @returns The matching record, or `undefined` when none match. * @throws When multiple records match and the selectors do not disambiguate. */ export function resolveActiveFleetNodeEnrollment( - input: { baseUrl?: string; workspaceId?: string; env?: NodeJS.ProcessEnv } = {} + input: { baseUrl?: string; workspaceId?: string; nodeId?: string; env?: NodeJS.ProcessEnv } = {} ): FleetNodeEnrollmentRecord | undefined { const store = readFleetNodeEnrollmentStore(input.env); const baseUrl = input.baseUrl ? normalizeStoreBaseUrl(input.baseUrl) : undefined; const workspaceId = input.workspaceId?.trim() || undefined; + const nodeId = input.nodeId?.trim() || undefined; if (baseUrl && workspaceId) { - return store.nodes[fleetNodeEnrollmentKey(baseUrl, workspaceId)]; + const record = store.nodes[fleetNodeEnrollmentKey(baseUrl, workspaceId)]; + return record && (nodeId === undefined || record.nodeId.trim() === nodeId) ? record : undefined; } const records = Object.values(store.nodes); const matches = records.filter( (record) => (baseUrl === undefined || normalizeStoreBaseUrl(record.relaycastUrl) === baseUrl) && - (workspaceId === undefined || record.relayWorkspaceId.trim() === workspaceId) + (workspaceId === undefined || record.relayWorkspaceId.trim() === workspaceId) && + (nodeId === undefined || record.nodeId.trim() === nodeId) ); if (matches.length === 0) { return undefined; @@ -368,7 +372,7 @@ export function resolveActiveFleetNodeEnrollment( if (baseUrl) { const activeKey = store.active[fleetNodeActiveBaseKey(baseUrl)]; const active = activeKey ? store.nodes[activeKey] : undefined; - if (active) { + if (active && matches.includes(active)) { return active; } } diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index 8739e32ce..557a97637 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -96,9 +96,11 @@ export { export { projectWorkspaceKeyPath, readProjectWorkspaceKey, + readProjectWorkspaceSession, resolveWorkspaceKey, resolveWorkspaceKeyWithSource, writeProjectWorkspaceKey, + type ProjectWorkspaceSession, type ResolveWorkspaceKeyOptions, type WorkspaceKeySource, } from './project-workspace-key.js'; diff --git a/packages/cloud/src/project-workspace-key.test.ts b/packages/cloud/src/project-workspace-key.test.ts index 682994e16..20e39662f 100644 --- a/packages/cloud/src/project-workspace-key.test.ts +++ b/packages/cloud/src/project-workspace-key.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { projectWorkspaceKeyPath, readProjectWorkspaceKey, + readProjectWorkspaceSession, resolveWorkspaceKeyWithSource, writeProjectWorkspaceKey, } from './project-workspace-key.js'; @@ -34,6 +35,17 @@ describe('project workspace key resolution', () => { expect(fs.statSync(projectWorkspaceKeyPath(dataDir)).mode & 0o777).toBe(0o600); }); + it('round-trips an enrolled Fleet identity and clears it on an explicit workspace change', () => { + writeProjectWorkspaceKey(dataDir, 'rk_enrolled', { enrolledNodeId: ' node_1 ' }); + expect(readProjectWorkspaceSession(dataDir)).toEqual({ + workspaceKey: 'rk_enrolled', + enrolledNodeId: 'node_1', + }); + + writeProjectWorkspaceKey(dataDir, 'rk_switched'); + expect(readProjectWorkspaceSession(dataDir)).toEqual({ workspaceKey: 'rk_switched' }); + }); + it('prefers explicit and environment keys over the project broker key', () => { writeProjectWorkspaceKey(dataDir, 'rk_project'); expect( diff --git a/packages/cloud/src/project-workspace-key.ts b/packages/cloud/src/project-workspace-key.ts index 3321eff24..5fbb77de1 100644 --- a/packages/cloud/src/project-workspace-key.ts +++ b/packages/cloud/src/project-workspace-key.ts @@ -8,8 +8,10 @@ import { resolveActiveWorkspaceKey } from './workspace-store.js'; const PROJECT_WORKSPACE_KEY_FILENAME = 'workspace-key.json'; -interface ProjectWorkspaceKeyFile { +export interface ProjectWorkspaceSession { workspaceKey: string; + /** Enrolled Fleet node associated with this project session, when one started the broker. */ + enrolledNodeId?: string; } export type WorkspaceKeySource = 'flag' | 'env' | 'project' | 'store'; @@ -30,10 +32,21 @@ export function projectWorkspaceKeyPath(dataDir: string): string { /** Read a project broker's workspace key, falling through on absent or malformed state. */ export function readProjectWorkspaceKey(dataDir: string): string | undefined { + return readProjectWorkspaceSession(dataDir)?.workspaceKey; +} + +/** Read the project workspace and its optional enrolled Fleet identity. */ +export function readProjectWorkspaceSession(dataDir: string): ProjectWorkspaceSession | undefined { try { const raw = fs.readFileSync(projectWorkspaceKeyPath(dataDir), 'utf-8'); - const parsed = JSON.parse(raw) as Partial; - return trimOrUndefined(parsed.workspaceKey); + const parsed = JSON.parse(raw) as Partial; + const workspaceKey = trimOrUndefined(parsed.workspaceKey); + if (!workspaceKey) return undefined; + const enrolledNodeId = trimOrUndefined(parsed.enrolledNodeId); + return { + workspaceKey, + ...(enrolledNodeId ? { enrolledNodeId } : {}), + }; } catch { return undefined; } @@ -43,14 +56,26 @@ export function readProjectWorkspaceKey(dataDir: string): string | undefined { * Persist the project broker's workspace key atomically with owner-only permissions. * A blank key never clobbers a previously recorded workspace. */ -export function writeProjectWorkspaceKey(dataDir: string, workspaceKey: string | undefined): void { +export function writeProjectWorkspaceKey( + dataDir: string, + workspaceKey: string | undefined, + options: { enrolledNodeId?: string } = {} +): void { const key = trimOrUndefined(workspaceKey); if (!key) return; + const enrolledNodeId = trimOrUndefined(options.enrolledNodeId); fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 }); const file = projectWorkspaceKeyPath(dataDir); // Worker threads share a PID, so include a per-write nonce as well as the PID. let tmp = `${file}.tmp.${process.pid}.${randomUUID()}`; - const data = `${JSON.stringify({ workspaceKey: key } satisfies ProjectWorkspaceKeyFile, null, 2)}\n`; + const data = `${JSON.stringify( + { + workspaceKey: key, + ...(enrolledNodeId ? { enrolledNodeId } : {}), + } satisfies ProjectWorkspaceSession, + null, + 2 + )}\n`; let fd: number; try { @@ -102,6 +127,7 @@ export function resolveWorkspaceKeyWithSource( return store ? { key: store, source: 'store' } : undefined; } +/** Resolve only the selected workspace key while preserving the shared precedence rules. */ export function resolveWorkspaceKey(options: ResolveWorkspaceKeyOptions = {}): string | undefined { return resolveWorkspaceKeyWithSource(options)?.key; } diff --git a/packages/cloud/src/workspace-key.ts b/packages/cloud/src/workspace-key.ts index e9066e91e..a2a153cdf 100644 --- a/packages/cloud/src/workspace-key.ts +++ b/packages/cloud/src/workspace-key.ts @@ -1,9 +1,11 @@ export { projectWorkspaceKeyPath, readProjectWorkspaceKey, + readProjectWorkspaceSession, resolveWorkspaceKey, resolveWorkspaceKeyWithSource, writeProjectWorkspaceKey, + type ProjectWorkspaceSession, type ResolveWorkspaceKeyOptions, type WorkspaceKeySource, } from './project-workspace-key.js';