diff --git a/CHANGELOG.md b/CHANGELOG.md index ac358390b..a68f06b1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- CLI output masks credentials: `node up` / `node status` print the workspace key as `rk_live_…xxxx`, `cloud session --json` masks the access token unless `--reveal-token` is passed, and `workspace active --json` / `workspace create` mask workspace keys unless `--reveal-secrets` is passed. The new `workspace key [name]` command reads a stored key back from the local store (masked unless `--reveal-secrets`). +- The workspace key no longer appears on any child argv: the broker child and the `--background` daemon both receive it via the environment only, so `ps` output and startup error messages never carry it. Verbose startup steps mask the key in the handshake line. +- Error output redacts embedded credentials: cloud endpoint errors (which carry key-bearing URLs) and Commander unknown-option errors (which echo mistyped flags like `--workspce-key=rk_live_…` verbatim) mask any live credential in the text. - Upgraded `axios`, `concurrently`, `fast-uri`, `form-data`, `hono`, `js-yaml`, `postcss`, `shell-quote`, and `tar` past their high- and critical-severity advisories. ## [11.2.0] - 2026-07-25 diff --git a/packages/cli/src/cli/bootstrap.test.ts b/packages/cli/src/cli/bootstrap.test.ts index 97aca3248..937feddbd 100644 --- a/packages/cli/src/cli/bootstrap.test.ts +++ b/packages/cli/src/cli/bootstrap.test.ts @@ -84,6 +84,7 @@ const expectedLeafCommands = [ 'workspace list', 'workspace set_key', 'workspace join', + 'workspace key', 'workspace switch', // workspace agents 'agent register', @@ -164,6 +165,28 @@ function collectLeafCommandPaths(program: Command): string[] { return paths; } +describe('createProgram output redaction', () => { + it('masks a credential echoed by an unknown-option parse error', async () => { + const program = createProgram(); + program.exitOverride(); + const writes: string[] = []; + const spy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => { + writes.push(String(chunk)); + return true; + }) as never); + try { + await expect( + program.parseAsync(['node', 'agent-relay', 'node', 'up', '--workspce-key=rk_live_0123456789abcdef']) + ).rejects.toThrow(); + } finally { + spy.mockRestore(); + } + const stderr = writes.join(''); + expect(stderr).toContain('rk_live_…cdef'); + expect(stderr).not.toContain('rk_live_0123456789abcdef'); + }); +}); + describe('bootstrap CLI', () => { it('uses the expected program name', () => { const program = createProgram(); diff --git a/packages/cli/src/cli/bootstrap.ts b/packages/cli/src/cli/bootstrap.ts index 4db4f1878..be34f9803 100644 --- a/packages/cli/src/cli/bootstrap.ts +++ b/packages/cli/src/cli/bootstrap.ts @@ -9,6 +9,7 @@ import { Command } from 'commander'; import { config as dotenvConfig } from 'dotenv'; import { checkForUpdatesInBackground } from '@agent-relay/utils'; +import { redactCredentialValues } from '@agent-relay/cloud'; import { cloudIdentityEnv, readStoredIdentitySync, @@ -372,6 +373,14 @@ function installExitHooks(): void { export function createProgram(options: { name?: string } = {}): Command { const program = new Command(); + // Commander echoes offending tokens verbatim (`error: unknown option + // '--workspce-key=rk_live_…'`), so a mistyped credential flag would land a + // live key in stderr, scrollback, and CI logs. Redact at the output + // boundary; subcommands inherit this via copyInheritedSettings. + program.configureOutput({ + writeErr: (str) => process.stderr.write(redactCredentialValues(str)), + }); + program .name(options.name ?? 'agent-relay') .description('Agent-to-agent messaging') diff --git a/packages/cli/src/cli/commands/cloud.test.ts b/packages/cli/src/cli/commands/cloud.test.ts index d121343ae..06c1d8091 100644 --- a/packages/cli/src/cli/commands/cloud.test.ts +++ b/packages/cli/src/cli/commands/cloud.test.ts @@ -397,13 +397,33 @@ describe('registerCloudCommands', () => { const sessionJson = JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0])); expect(sessionJson).toEqual({ apiUrl: 'https://cloud.test', - accessToken: 'access-token', + accessToken: '…oken', accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', refreshTokenExpiresAt: '2999-04-01T00:00:00.000Z', }); expect(sessionJson).not.toHaveProperty('refreshToken'); }); + it('includes the raw access token in JSON output only with --reveal-token', async () => { + const { program, deps } = createHarness(); + vi.mocked(ensureCloudSession).mockResolvedValueOnce({ + auth: { + apiUrl: 'https://cloud.test', + accessToken: 'access-token', + refreshToken: 'refresh-token', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', + refreshTokenExpiresAt: '2999-04-01T00:00:00.000Z', + }, + client: {} as never, + }); + + await program.parseAsync(['node', 'agent-relay', 'cloud', 'session', '--json', '--reveal-token']); + + const sessionJson = JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0])); + expect(sessionJson.accessToken).toBe('access-token'); + expect(sessionJson).not.toHaveProperty('refreshToken'); + }); + it('connect requires a provider argument', () => { const { program } = createHarness(); const cloud = program.commands.find((command) => command.name() === 'cloud'); diff --git a/packages/cli/src/cli/commands/cloud.ts b/packages/cli/src/cli/commands/cloud.ts index d17a423a0..9eed3c859 100644 --- a/packages/cli/src/cli/commands/cloud.ts +++ b/packages/cli/src/cli/commands/cloud.ts @@ -33,6 +33,7 @@ import { } from '@agent-relay/cloud'; import { defaultExit } from '../lib/exit.js'; +import { maskSecret } from '../lib/redact.js'; import { errorClassName } from '../lib/telemetry-helpers.js'; import { track } from '../telemetry/index.js'; import { registerCloudRoomCommands } from './cloud-room.js'; @@ -518,45 +519,55 @@ export function registerCloudCommands(program: Command, overrides: Partial', 'Cloud API base URL') - .option('--json', 'Output the session as JSON') + .option('--json', 'Output the session as JSON (access token masked unless --reveal-token)') + .option('--reveal-token', 'Include the raw access token in --json output') .option( '--refresh-timeout ', 'Timeout for refreshing the cloud session', parsePositiveInteger ) - .action(async (options: { apiUrl?: string; json?: boolean; refreshTimeout?: number }) => { - const apiUrl = options.apiUrl || defaultApiUrl(); - const session = await ensureCloudSession({ - apiUrl, - interactive: false, - refreshTimeoutMs: options.refreshTimeout, - }); + .action( + async (options: { + apiUrl?: string; + json?: boolean; + revealToken?: boolean; + refreshTimeout?: number; + }) => { + const apiUrl = options.apiUrl || defaultApiUrl(); + const session = await ensureCloudSession({ + apiUrl, + interactive: false, + refreshTimeoutMs: options.refreshTimeout, + }); - if (options.json) { - deps.log( - JSON.stringify( - { - apiUrl: session.auth.apiUrl, - accessToken: session.auth.accessToken, - accessTokenExpiresAt: session.auth.accessTokenExpiresAt, - ...(session.auth.refreshTokenExpiresAt - ? { refreshTokenExpiresAt: session.auth.refreshTokenExpiresAt } - : {}), - }, - null, - 2 - ) - ); - return; - } + if (options.json) { + deps.log( + JSON.stringify( + { + apiUrl: session.auth.apiUrl, + accessToken: options.revealToken + ? session.auth.accessToken + : maskSecret(session.auth.accessToken), + accessTokenExpiresAt: session.auth.accessTokenExpiresAt, + ...(session.auth.refreshTokenExpiresAt + ? { refreshTokenExpiresAt: session.auth.refreshTokenExpiresAt } + : {}), + }, + null, + 2 + ) + ); + return; + } - deps.log(`API URL: ${session.auth.apiUrl}`); - deps.log(`Access token expires: ${session.auth.accessTokenExpiresAt}`); - if (session.auth.refreshTokenExpiresAt) { - deps.log(`Refresh token expires: ${session.auth.refreshTokenExpiresAt}`); + deps.log(`API URL: ${session.auth.apiUrl}`); + deps.log(`Access token expires: ${session.auth.accessTokenExpiresAt}`); + if (session.auth.refreshTokenExpiresAt) { + deps.log(`Refresh token expires: ${session.auth.refreshTokenExpiresAt}`); + } + deps.log(`Token file: ${AUTH_FILE_PATH}`); } - deps.log(`Token file: ${AUTH_FILE_PATH}`); - }); + ); cloudCommand .command('whoami') diff --git a/packages/cli/src/cli/commands/core.test.ts b/packages/cli/src/cli/commands/core.test.ts index d0025f334..e7bb634bb 100644 --- a/packages/cli/src/cli/commands/core.test.ts +++ b/packages/cli/src/cli/commands/core.test.ts @@ -100,7 +100,7 @@ function createRelayMock(overrides: Partial = {}): CoreRelay { spawn: vi.fn(async () => undefined), getStatus: vi.fn(async () => ({ agent_count: 0, pending_delivery_count: 0 })), shutdown: vi.fn(async () => undefined), - workspaceKey: 'rk_live_default', + workspaceKey: 'rk_live_defaultkey01', ...overrides, }; } @@ -536,7 +536,7 @@ describe('registerCoreCommands', () => { '--state-dir', stateDir, '--workspace-key', - 'rk_live_custom', + 'rk_live_customflag77', '--broker-name', 'relayfile-dev', ]; @@ -547,30 +547,25 @@ describe('registerCoreCommands', () => { '--state-dir', stateDir, '--workspace-key', - 'rk_live_custom', + 'rk_live_customflag77', '--broker-name', 'relayfile-dev', ]); expect(exitCode).toBe(0); + // The key reaches the detached child via env only — its argv (visible in + // `ps` for the daemon's whole lifetime) must never carry it. expect(deps.spawnProcess).toHaveBeenCalledWith( '/usr/bin/node', - [ - '/tmp/agent-relay.js', - 'up', - '--state-dir', - stateDir, - '--workspace-key', - 'rk_live_custom', - '--broker-name', - 'relayfile-dev', - ], + ['/tmp/agent-relay.js', 'up', '--state-dir', stateDir, '--broker-name', 'relayfile-dev'], { detached: true, stdio: 'ignore', env: deps.env, } ); + expect(deps.env.RELAY_WORKSPACE_KEY).toBe('rk_live_customflag77'); + expect(deps.env.RELAY_API_KEY).toBe('rk_live_customflag77'); expect(deps.env.AGENT_RELAY_STATE_DIR).toBe(stateDir); expect(deps.log).toHaveBeenCalledWith('Broker started.'); expect(deps.log).toHaveBeenCalledWith('Broker PID: 5151'); @@ -1181,7 +1176,7 @@ describe('registerCoreCommands', () => { const fs = createFsMock({ [connectionPath]: connectionFile(4242) }); sdkStatusClient.getStatus.mockResolvedValueOnce({ agent_count: 4, pending_delivery_count: 2 }); sdkStatusClient.getSession.mockResolvedValueOnce({ - workspace_key: 'rk_live_test123', + workspace_key: 'rk_live_teststatus123', node_id: 'node_enrolled', node_name: 'sf-mini', }); @@ -1195,8 +1190,9 @@ describe('registerCoreCommands', () => { expect(deps.log).toHaveBeenCalledWith('Agents: 4'); expect(deps.log).toHaveBeenCalledWith('Pending deliveries: 2'); expect(deps.log).toHaveBeenCalledWith('Node: sf-mini (node_enrolled)'); - expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_test123'); - expect(deps.log).toHaveBeenCalledWith('Observer: https://agentrelay.com/observer?key=rk_live_test123'); + expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_…s123'); + const logCalls = (deps.log as unknown as { mock: { calls: unknown[][] } }).mock.calls; + expect(logCalls.some((call) => String(call[0]).startsWith('Observer:'))).toBe(false); expect(sdkStatusClient.disconnect).toHaveBeenCalled(); }); @@ -1275,7 +1271,7 @@ describe('registerCoreCommands', () => { sdkStatusClient.getStatus .mockRejectedValueOnce(new Error('503 Service Unavailable')) .mockResolvedValueOnce({ agent_count: 1, pending_delivery_count: 0 }); - sdkStatusClient.getSession.mockResolvedValueOnce({ workspace_key: 'rk_live_ready' }); + sdkStatusClient.getSession.mockResolvedValueOnce({ workspace_key: 'rk_live_readywait9' }); const { program, deps } = createHarness({ fs, @@ -1292,7 +1288,7 @@ describe('registerCoreCommands', () => { expect(fs.unlinkSync).not.toHaveBeenCalledWith(connectionPath); expect(deps.log).toHaveBeenCalledWith('Status: RUNNING'); expect(deps.log).toHaveBeenCalledWith('Agents: 1'); - expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_ready'); + expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_…ait9'); }); it('status --wait-for treats getStatus success as ready even when session lookup fails', async () => { @@ -1471,46 +1467,46 @@ describe('registerCoreCommands', () => { const exitCode = await runCommand(program, ['up']); expect(exitCode).toBeUndefined(); - expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_default'); + expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_…ey01'); }); it('up logs the auto-created workspace key', async () => { - const relay = createRelayMock({ workspaceKey: 'rk_live_auto456' }); + const relay = createRelayMock({ workspaceKey: 'rk_live_autominted456' }); const { program, deps } = createHarness({ relay }); const exitCode = await runCommand(program, ['up']); expect(exitCode).toBeUndefined(); - expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_auto456'); + expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_…d456'); }); it('up --workspace-key sets RELAY_WORKSPACE_KEY in env before broker starts', async () => { const env: NodeJS.ProcessEnv = {}; - const relay = createRelayMock({ workspaceKey: 'rk_live_custom' }); + const relay = createRelayMock({ workspaceKey: 'rk_live_customflag77' }); const { program, deps } = createHarness({ relay, env }); - const exitCode = await runCommand(program, ['up', '--workspace-key', 'rk_live_custom']); + const exitCode = await runCommand(program, ['up', '--workspace-key', 'rk_live_customflag77']); expect(exitCode).toBeUndefined(); - expect(env.RELAY_WORKSPACE_KEY).toBe('rk_live_custom'); - expect(env.RELAY_API_KEY).toBe('rk_live_custom'); + expect(env.RELAY_WORKSPACE_KEY).toBe('rk_live_customflag77'); + expect(env.RELAY_API_KEY).toBe('rk_live_customflag77'); expect(deps.createRelay).toHaveBeenCalled(); - expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_custom'); + expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_…ag77'); }); it('up --wk is an alias for --workspace-key', async () => { const env: NodeJS.ProcessEnv = {}; - const relay = createRelayMock({ workspaceKey: 'rk_live_alias' }); + const relay = createRelayMock({ workspaceKey: 'rk_live_aliasflag88' }); const { program, deps } = createHarness({ relay, env }); - const exitCode = await runCommand(program, ['up', '--wk', 'rk_live_alias']); + const exitCode = await runCommand(program, ['up', '--wk', 'rk_live_aliasflag88']); expect(exitCode).toBeUndefined(); // The alias is folded into workspaceKey, so the broker sees the same env the // explicit flag would have set. - expect(env.RELAY_WORKSPACE_KEY).toBe('rk_live_alias'); - expect(env.RELAY_API_KEY).toBe('rk_live_alias'); - expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_alias'); + expect(env.RELAY_WORKSPACE_KEY).toBe('rk_live_aliasflag88'); + expect(env.RELAY_API_KEY).toBe('rk_live_aliasflag88'); + expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_…ag88'); }); it('up without --workspace-key or a pinned session does not set workspace key env vars', async () => { @@ -1545,20 +1541,20 @@ describe('registerCoreCommands', () => { 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 ', + AGENT_RELAY_WORKSPACE_KEY: ' rk_live_aliasflag88 ', }; const fs = createFsMock({ '/tmp/project/.agentworkforce/relay/workspace-key.json': JSON.stringify({ workspaceKey: 'rk_live_pinned', }), }); - const relay = createRelayMock({ workspaceKey: 'rk_live_alias' }); + const relay = createRelayMock({ workspaceKey: 'rk_live_aliasflag88' }); 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_WORKSPACE_KEY).toBe('rk_live_aliasflag88'); expect(env.RELAY_API_KEY).toBeUndefined(); }); @@ -1666,15 +1662,15 @@ describe('registerCoreCommands', () => { it('up --workspace-key overrides existing workspace key env vars', async () => { const env: NodeJS.ProcessEnv = { RELAY_API_KEY: 'rk_live_old' }; - const relay = createRelayMock({ workspaceKey: 'rk_live_new' }); + const relay = createRelayMock({ workspaceKey: 'rk_live_newmint99' }); const { program, deps } = createHarness({ relay, env }); - const exitCode = await runCommand(program, ['up', '--workspace-key', 'rk_live_new']); + const exitCode = await runCommand(program, ['up', '--workspace-key', 'rk_live_newmint99']); expect(exitCode).toBeUndefined(); - expect(env.RELAY_WORKSPACE_KEY).toBe('rk_live_new'); - expect(env.RELAY_API_KEY).toBe('rk_live_new'); - expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_new'); + expect(env.RELAY_WORKSPACE_KEY).toBe('rk_live_newmint99'); + expect(env.RELAY_API_KEY).toBe('rk_live_newmint99'); + expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_…nt99'); }); }); diff --git a/packages/cli/src/cli/commands/workspace.test.ts b/packages/cli/src/cli/commands/workspace.test.ts index c4501f4bc..252904a8a 100644 --- a/packages/cli/src/cli/commands/workspace.test.ts +++ b/packages/cli/src/cli/commands/workspace.test.ts @@ -87,7 +87,7 @@ describe('registerWorkspaceCommands', () => { }); expect(JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0]))).toEqual({ name: 'Ops', - key: 'rk_live_ops', + key: 'rk_live_…', cloudWorkspaceId: 'rw_ops', relaycastWorkspaceId: 'rc_ops', relayfileWorkspaceId: 'rw_ops', @@ -99,6 +99,48 @@ describe('registerWorkspaceCommands', () => { }); }); + it('workspace active --json includes raw keys only with --reveal-secrets', async () => { + const { program, deps } = createHarness(); + vi.mocked(resolveActiveWorkspace).mockResolvedValueOnce({ + name: 'Ops', + key: 'rk_live_ops', + cloudWorkspaceId: 'rw_ops', + relaycastWorkspaceId: 'rc_ops', + relaycastApiKey: 'rk_live_castkey01', + relayfileWorkspaceId: 'rw_ops', + relayauthWorkspaceId: 'rw_ops', + urls: {}, + apiUrl: 'https://cloud.test', + }); + + await program.parseAsync(['node', 'agent-relay', 'workspace', 'active', '--json', '--reveal-secrets']); + + const printed = JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0])); + expect(printed.key).toBe('rk_live_ops'); + expect(printed.relaycastApiKey).toBe('rk_live_castkey01'); + }); + + it('workspace active --json masks relaycastApiKey by default', async () => { + const { program, deps } = createHarness(); + vi.mocked(resolveActiveWorkspace).mockResolvedValueOnce({ + name: 'Ops', + key: 'rk_live_ops', + cloudWorkspaceId: 'rw_ops', + relaycastWorkspaceId: 'rc_ops', + relaycastApiKey: 'rk_live_castkey01', + relayfileWorkspaceId: 'rw_ops', + relayauthWorkspaceId: 'rw_ops', + urls: {}, + apiUrl: 'https://cloud.test', + }); + + await program.parseAsync(['node', 'agent-relay', 'workspace', 'active', '--json']); + + const printed = JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0])); + expect(printed.key).toBe('rk_live_…'); + expect(printed.relaycastApiKey).toBe('rk_live_…ey01'); + }); + it('workspace create starts and persists a new workspace session', async () => { const { program, deps } = createHarness(); vi.mocked(deps.createWorkspace).mockResolvedValueOnce({ @@ -111,6 +153,11 @@ describe('registerWorkspaceCommands', () => { name: 'session-two', workspaceKey: 'rk_live_session_two', }); + // The raw key lands in the store; the printed output only carries the mask. + expect(JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0]))).toEqual({ + name: 'session-two', + workspaceKey: 'rk_live_…_two', + }); }); it('workspace create rejects a blank name before provisioning a remote workspace', async () => { @@ -139,6 +186,30 @@ describe('registerWorkspaceCommands', () => { expect(switchWorkspace).not.toHaveBeenCalled(); }); + it('workspace key prints the stored key masked by default and raw with --reveal-secrets', async () => { + vi.mocked(readWorkspaceStore).mockReturnValue({ + active: 'default', + workspaces: { + default: { key: 'rk_live_defaultkey01' }, + shared: { key: 'rk_live_sharedkey02' }, + }, + }); + const first = createHarness(); + await first.program.parseAsync(['node', 'agent-relay', 'workspace', 'key']); + expect(first.deps.log).toHaveBeenCalledWith('rk_live_…ey01'); + + const second = createHarness(); + await second.program.parseAsync([ + 'node', + 'agent-relay', + 'workspace', + 'key', + 'shared', + '--reveal-secrets', + ]); + expect(second.deps.log).toHaveBeenCalledWith('rk_live_sharedkey02'); + }); + it('workspace switch pins the selected workspace to the current project', async () => { vi.mocked(readWorkspaceStore).mockReturnValueOnce({ active: 'default', diff --git a/packages/cli/src/cli/commands/workspace.ts b/packages/cli/src/cli/commands/workspace.ts index 549762ba7..2e4a0f7c2 100644 --- a/packages/cli/src/cli/commands/workspace.ts +++ b/packages/cli/src/cli/commands/workspace.ts @@ -2,6 +2,7 @@ import type { Command } from 'commander'; import { InvalidArgumentError } from 'commander'; import { resolveActiveWorkspace } from '@agent-relay/cloud'; +import { maskSecret } from '../lib/redact.js'; import { printJson, runSdk, withSdkDefaults, type SdkCommandDeps } from '../lib/sdk-command.js'; import { readWorkspaceStore, setWorkspaceKey } from '../lib/workspace-store.js'; import { persistWorkspaceSession, validateWorkspaceSessionName } from '../lib/workspace-session.js'; @@ -27,37 +28,57 @@ export function registerWorkspaceCommands( .command('active') .description('Show the active canonical cloud workspace') .option('--api-url ', 'Cloud API base URL') - .option('--json', 'Output the active workspace as JSON') + .option('--json', 'Output the active workspace as JSON (keys masked unless --reveal-secrets)') + .option('--reveal-secrets', 'Include raw workspace keys in --json output') .option( '--refresh-timeout ', 'Timeout for refreshing the cloud session', parsePositiveInteger ) - .action(async (options: { apiUrl?: string; json?: boolean; refreshTimeout?: number }) => { - await runSdk(deps, async () => { - const workspace = await resolveActiveWorkspace({ - apiUrl: options.apiUrl, - interactive: false, - refreshTimeoutMs: options.refreshTimeout, - }); + .action( + async (options: { + apiUrl?: string; + json?: boolean; + revealSecrets?: boolean; + refreshTimeout?: number; + }) => { + await runSdk(deps, async () => { + const workspace = await resolveActiveWorkspace({ + apiUrl: options.apiUrl, + interactive: false, + refreshTimeoutMs: options.refreshTimeout, + }); - if (options.json) { - printJson(deps, workspace); - return; - } + if (options.json) { + printJson( + deps, + options.revealSecrets + ? workspace + : { + ...workspace, + key: maskSecret(workspace.key), + ...(workspace.relaycastApiKey + ? { relaycastApiKey: maskSecret(workspace.relaycastApiKey) } + : {}), + } + ); + return; + } - deps.log(`Workspace: ${workspace.name ?? workspace.cloudWorkspaceId}`); - deps.log(`Cloud workspace ID: ${workspace.cloudWorkspaceId}`); - deps.log(`Relayfile workspace ID: ${workspace.relayfileWorkspaceId}`); - deps.log(`Relayauth workspace ID: ${workspace.relayauthWorkspaceId}`); - }); - }); + deps.log(`Workspace: ${workspace.name ?? workspace.cloudWorkspaceId}`); + deps.log(`Cloud workspace ID: ${workspace.cloudWorkspaceId}`); + deps.log(`Relayfile workspace ID: ${workspace.relayfileWorkspaceId}`); + deps.log(`Relayauth workspace ID: ${workspace.relayauthWorkspaceId}`); + }); + } + ); group .command('create') .description('Create a new workspace and store its key') .argument('', 'Workspace name') .option('--base-url ', 'Override the API base URL') + .option('--reveal-secrets', 'Include the raw workspace key in the output') .action(async (name: string, o: Record) => { await runSdk(deps, async () => { const workspaceName = validateWorkspaceSessionName(name); @@ -65,7 +86,13 @@ export function registerWorkspaceCommands( if (relay.workspaceKey) { persistWorkspaceSession({ name: workspaceName, workspaceKey: relay.workspaceKey }); } - printJson(deps, { name: workspaceName, workspaceKey: relay.workspaceKey }); + // The key is persisted to the workspace store either way; the output + // masks it unless the caller explicitly asks for the raw value. + printJson(deps, { + name: workspaceName, + workspaceKey: + relay.workspaceKey && !o.revealSecrets ? maskSecret(relay.workspaceKey) : relay.workspaceKey, + }); }); }); @@ -82,6 +109,26 @@ export function registerWorkspaceCommands( }); }); + group + .command('key') + .description('Print a stored workspace key from the local store (masked unless --reveal-secrets)') + .argument('[name]', 'Workspace name (defaults to the active workspace)') + .option('--reveal-secrets', 'Print the raw key') + .action(async (name: string | undefined, o: Record) => { + await runSdk(deps, async () => { + const store = readWorkspaceStore(); + const target = name?.trim() || store.active; + if (!target) { + throw new Error('No workspace named and no active workspace set.'); + } + const record = Object.hasOwn(store.workspaces, target) ? store.workspaces[target] : undefined; + if (!record?.key) { + throw new Error(`No stored key for workspace "${target}".`); + } + deps.log(o.revealSecrets ? record.key : maskSecret(record.key)); + }); + }); + group .command('set_key') .description('Store a workspace key under a name') diff --git a/packages/cli/src/cli/lib/broker-lifecycle.ts b/packages/cli/src/cli/lib/broker-lifecycle.ts index f07163594..3cb760ab3 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.ts @@ -21,6 +21,7 @@ import { type NodeDefinitionDescriptor, type RunningNodeProviderChild, } from './node-provider-child.js'; +import { maskSecret } from './redact.js'; import { startReflexCapture, type RunningReflexCapture } from './reflex-capture.js'; import { projectWorkspaceKeyPath, writeProjectWorkspaceKey } from './project-workspace-key.js'; import { promoteWorkspaceKeyEnvAlias } from './workspace-env.js'; @@ -954,13 +955,16 @@ function cleanupBrokerFiles(paths: CoreProjectPaths, deps: CoreDependencies): vo } function childUpArgsForDetachedStart(options: UpOptions, deps: CoreDependencies): string[] { - const args = cliUserArgs(deps).filter((arg) => !matchesCliOption(arg, '--background')); + // The workspace key travels to the detached child via env only (set on + // deps.env before the spawn): strip every argv spelling so the daemon's + // command line never carries it into `ps` output. + const args = stripCliOptionsWithValue( + cliUserArgs(deps).filter((arg) => !matchesCliOption(arg, '--background')), + ['--workspace-key', '--wk'] + ); if (options.stateDir && !hasCliOption(args, '--state-dir')) { args.push('--state-dir', path.resolve(options.stateDir)); } - if (options.workspaceKey && !hasCliOption(args, '--workspace-key')) { - args.push('--workspace-key', options.workspaceKey); - } if (options.brokerName && !hasCliOption(args, '--broker-name')) { args.push('--broker-name', options.brokerName); } @@ -970,6 +974,23 @@ function childUpArgsForDetachedStart(options: UpOptions, deps: CoreDependencies) return args; } +/** Drop each named option and, for the space-separated form, its value token. */ +function stripCliOptionsWithValue(args: string[], names: string[]): string[] { + const result: string[] = []; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (names.includes(arg)) { + i++; + continue; + } + if (names.some((name) => arg.startsWith(`${name}=`))) { + continue; + } + result.push(arg); + } + return result; +} + function cliUserArgs(deps: CoreDependencies): string[] { return hasEntrypointArgvSlot(deps) ? deps.argv.slice(2) : deps.argv.slice(1); } @@ -1307,6 +1328,16 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): deps.env.AGENT_RELAY_STATE_DIR = resolved; } + // If a workspace key was explicitly provided, inject it into the environment + // for both current tools and older compatibility paths. This must happen + // before the --background spawn: the detached child inherits deps.env, and + // env is the key's only channel — it never rides on argv, where `ps` would + // expose it for the daemon's whole lifetime. + if (options.workspaceKey) { + deps.env.RELAY_WORKSPACE_KEY = options.workspaceKey; + deps.env.RELAY_API_KEY = options.workspaceKey; + } + if (options.background) { const preflight = await recoverHalfStartedBroker(paths, deps); if (preflight === 'running') { @@ -1474,13 +1505,6 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): safeUnlink(path.join(paths.dataDir, CONNECTION_FILENAME), deps); } - // If a workspace key was explicitly provided, inject it into the environment - // for both current tools and older compatibility paths. - if (options.workspaceKey) { - deps.env.RELAY_WORKSPACE_KEY = options.workspaceKey; - deps.env.RELAY_API_KEY = options.workspaceKey; - } - // Point the shared logger at a file / level / format before the fleet // sidecar (which reads this env when it builds its logger) starts. applyNodeLogEnv(options, deps); @@ -1518,7 +1542,7 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): deps.log(`Relay API: http://localhost:${started.apiPort}`); deps.log(`Project: ${paths.projectRoot}`); deps.log('Mode: broker (stdio)'); - deps.log(`Workspace Key: ${relay.workspaceKey ?? 'unknown'}`); + deps.log(`Workspace Key: ${relay.workspaceKey ? maskSecret(relay.workspaceKey) : 'unknown'}`); deps.log('Broker started.'); // Record the workspace this broker joined (explicitly passed or auto-minted) @@ -1792,8 +1816,7 @@ export async function runStatusCommand( deps.log(`Node: ${session.node_name?.trim() || session.node_id} (${session.node_id})`); } if (session?.workspace_key) { - deps.log(`Workspace Key: ${session.workspace_key}`); - deps.log(`Observer: https://agentrelay.com/observer?key=${session.workspace_key}`); + deps.log(`Workspace Key: ${maskSecret(session.workspace_key)}`); } } } diff --git a/packages/cli/src/cli/lib/redact.test.ts b/packages/cli/src/cli/lib/redact.test.ts index 85be58816..92d45736a 100644 --- a/packages/cli/src/cli/lib/redact.test.ts +++ b/packages/cli/src/cli/lib/redact.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { redactSecrets } from './redact.js'; +import { maskSecret, redactSecrets } from './redact.js'; describe('redactSecrets', () => { it('redacts credential-named fields and preserves the rest', () => { @@ -68,3 +68,21 @@ describe('redactSecrets', () => { expect(input.a).toBe(creds); }); }); + +describe('maskSecret', () => { + it('keeps a known prefix and the last four characters', () => { + expect(maskSecret('rk_live_0123456789abcdef')).toBe('rk_live_…cdef'); + expect(maskSecret('at_live_0123456789abcdef')).toBe('at_live_…cdef'); + expect(maskSecret('nt_live_0123456789abcdef')).toBe('nt_live_…cdef'); + expect(maskSecret('cld_at_0123456789abcdef')).toBe('cld_at_…cdef'); + }); + + it('masks the entire body when it is too short to show a suffix', () => { + expect(maskSecret('rk_live_short')).toBe('rk_live_…'); + expect(maskSecret('tiny')).toBe('…'); + }); + + it('masks unknown-shaped tokens to an ellipsis and the last four characters', () => { + expect(maskSecret('some-opaque-access-token')).toBe('…oken'); + }); +}); diff --git a/packages/cli/src/cli/lib/redact.ts b/packages/cli/src/cli/lib/redact.ts index bb47db679..6f44f0aae 100644 --- a/packages/cli/src/cli/lib/redact.ts +++ b/packages/cli/src/cli/lib/redact.ts @@ -11,6 +11,25 @@ /** Keys whose values are credentials and must be redacted before display. */ const SECRET_KEY = /token|secret|password|api[_-]?key|workspace[_-]?key|authorization/i; +/** Known live-credential prefixes, kept visible so a masked value still identifies its kind. */ +const SECRET_PREFIX = /^(rk_live_|at_live_|nt_live_|ot_live_|br_|rth_at_|cld_at_|ocl_node_enr_)/; + +/** + * Mask a credential for display: the known prefix (if any) and the last four + * characters stay visible, everything else collapses to `…`. Values too short + * to safely show a suffix mask entirely. Use this wherever a command prints a + * key on purpose — the masked form still identifies which credential it is + * without being usable. + */ +export function maskSecret(value: string): string { + const prefix = value.match(SECRET_PREFIX)?.[1] ?? ''; + const body = value.slice(prefix.length); + if (body.length <= 8) { + return `${prefix}…`; + } + return `${prefix}…${body.slice(-4)}`; +} + const REDACTED = '[redacted]'; const CIRCULAR = '[circular]'; diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index 15cdc8c4d..bcdd014a5 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -65,6 +65,7 @@ export { } from './connect.js'; export { createWorkspace, issueWorkspaceToken, resolveActiveWorkspace } from './workspaces.js'; +export { redactCredentialValues } from './redact.js'; export { acknowledgeCloudWorkerAssignment, diff --git a/packages/cloud/src/proactive-runtime.ts b/packages/cloud/src/proactive-runtime.ts index 3e958aaf3..f6ffbd5c0 100644 --- a/packages/cloud/src/proactive-runtime.ts +++ b/packages/cloud/src/proactive-runtime.ts @@ -1,4 +1,5 @@ import { authorizedApiFetch, ensureAuthenticated } from './auth.js'; +import { redactCredentialValues } from './redact.js'; import { defaultApiUrl, type ProactiveAgentRecord, @@ -51,7 +52,9 @@ function buildEndpointError(action: string, endpoint: string, response: Response response.statusText) : response.statusText; - return new Error(`${action} failed at ${endpoint}: ${response.status} ${detail}`.trim()); + return new Error( + redactCredentialValues(`${action} failed at ${endpoint}: ${response.status} ${detail}`.trim()) + ); } function isUnsupported(response: Response): boolean { diff --git a/packages/cloud/src/redact.test.ts b/packages/cloud/src/redact.test.ts new file mode 100644 index 000000000..f2854a645 --- /dev/null +++ b/packages/cloud/src/redact.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { redactCredentialValues } from './redact.js'; + +describe('redactCredentialValues', () => { + it('masks a workspace key embedded in a URL path', () => { + expect( + redactCredentialValues( + 'Workspace resolve failed at /api/v1/workspaces/rk_live_0123456789abcdef/resolve: 404' + ) + ).toBe('Workspace resolve failed at /api/v1/workspaces/rk_live_…cdef/resolve: 404'); + }); + + it('masks a workspace key embedded in a query string', () => { + expect( + redactCredentialValues( + 'Workspace resolve failed at /api/v1/workspaces/active?key=rk_live_0123456789abcdef: 404 Not Found' + ) + ).toBe('Workspace resolve failed at /api/v1/workspaces/active?key=rk_live_…cdef: 404 Not Found'); + }); + + it('masks every credential kind, including server-echoed details', () => { + expect( + redactCredentialValues('denied for at_live_0123456789abcdef with session cld_at_0123456789abcdef') + ).toBe('denied for at_live_…cdef with session cld_at_…cdef'); + }); + + it('masks a short-bodied credential entirely instead of leaking most of it', () => { + expect(redactCredentialValues('bad key rk_live_abcd rejected')).toBe('bad key rk_live_… rejected'); + expect(redactCredentialValues('bad key rk_live_abcde rejected')).toBe('bad key rk_live_… rejected'); + }); + + it('masks a dot-chained (JWT-shaped) token through its final segment', () => { + expect( + redactCredentialValues('denied for cld_at_eyJhbGciOi.eyJzdWIiOiJ1c2VyIn0.SflKxwRJSMeKKF2QT4') + ).toBe('denied for cld_at_…2QT4'); + }); + + it('does not swallow a sentence period after a token', () => { + expect(redactCredentialValues('rotate rk_live_0123456789abcdef.')).toBe('rotate rk_live_…cdef.'); + }); + + it('leaves credential-free text untouched', () => { + expect(redactCredentialValues('Workspace create failed at /api/v1/workspaces: 500 oops')).toBe( + 'Workspace create failed at /api/v1/workspaces: 500 oops' + ); + }); +}); diff --git a/packages/cloud/src/redact.ts b/packages/cloud/src/redact.ts new file mode 100644 index 000000000..17c4e45ba --- /dev/null +++ b/packages/cloud/src/redact.ts @@ -0,0 +1,24 @@ +/** + * Value-pattern credential redaction for error and log text. + * + * Endpoint URLs put workspace keys in paths and query strings, and server + * error payloads are free to echo whatever was sent, so key-name redaction + * cannot help here — the only safe boundary is the text itself. Any + * live-credential token embedded in the string collapses to its prefix plus + * the last four characters, keeping the message actionable without carrying + * a usable secret. + */ +const LIVE_CREDENTIAL = + /(rk_live_|at_live_|nt_live_|ot_live_|cld_at_|rth_at_|ocl_node_enr_|br_)([A-Za-z0-9_%-]+(?:\.[A-Za-z0-9_%-]+)*)/g; + +/** + * Replace every embedded live credential in `text` with its masked form — + * the same rule as the CLI's `maskSecret`: prefix plus the last four BODY + * characters, and a body too short for a safe suffix masks entirely. Bodies + * may be dot-chained (JWT-shaped) so a token's tail segments never survive. + */ +export function redactCredentialValues(text: string): string { + return text.replace(LIVE_CREDENTIAL, (_match, prefix: string, body: string) => + body.length <= 8 ? `${prefix}…` : `${prefix}…${body.slice(-4)}` + ); +} diff --git a/packages/cloud/src/workspaces.ts b/packages/cloud/src/workspaces.ts index cc4f157b2..400869bfc 100644 --- a/packages/cloud/src/workspaces.ts +++ b/packages/cloud/src/workspaces.ts @@ -1,4 +1,5 @@ import { authorizedApiFetch, ensureAuthenticated } from './auth.js'; +import { redactCredentialValues } from './redact.js'; import { type ActiveWorkspaceDescriptor, type ActiveWorkspaceUrls, @@ -65,7 +66,9 @@ function buildEndpointError(action: string, endpoint: string, response: Response response.statusText) : response.statusText; - return new Error(`${action} failed at ${endpoint}: ${response.status} ${detail}`.trim()); + return new Error( + redactCredentialValues(`${action} failed at ${endpoint}: ${response.status} ${detail}`.trim()) + ); } function normalizeWorkspaceCreateResponse(payload: unknown): WorkspaceCreateResponse { @@ -356,6 +359,7 @@ export async function resolveActiveWorkspace( `/api/v1/workspaces/active?key=${encodedKey}`, ]; let lastUnsupported: Error | null = null; + let sawMethodNotAllowed = false; for (const endpoint of endpoints) { const { response, payload, apiUrl } = await tryGetJson(endpoint, { @@ -365,6 +369,7 @@ export async function resolveActiveWorkspace( }); if (response.status === 404 || response.status === 405) { + sawMethodNotAllowed ||= response.status === 405; lastUnsupported = buildEndpointError('Workspace resolve', endpoint, response, payload); continue; } @@ -376,5 +381,15 @@ export async function resolveActiveWorkspace( return normalizeActiveWorkspaceDescriptor(payload, key, apiUrl); } - throw lastUnsupported ?? new Error('Workspace resolution is not supported by the configured cloud API.'); + if (!lastUnsupported) { + throw new Error('Workspace resolution is not supported by the configured cloud API.'); + } + if (sawMethodNotAllowed) { + // A 405 is an API-shape signal, not evidence about the workspace — don't + // steer the user toward a provisioning diagnosis that may be wrong. + throw lastUnsupported; + } + throw new Error( + `${lastUnsupported.message} — the active workspace has no record on the cloud API; a messaging-only workspace (minted by \`node up\` without cloud provisioning) resolves only through Relaycast.` + ); } diff --git a/packages/harness-driver/src/client.ts b/packages/harness-driver/src/client.ts index debf87686..142d2203e 100644 --- a/packages/harness-driver/src/client.ts +++ b/packages/harness-driver/src/client.ts @@ -196,6 +196,20 @@ function normalizeMaxQueueSize(value: number | undefined): number { return Number.isFinite(value) && value >= 1 ? Math.floor(value) : DEFAULT_WORKER_STREAM_MAX_QUEUE_SIZE; } +/** + * Mask a workspace key for progress output: known prefix and last four + * characters stay visible, the rest collapses to `…`. Startup steps are + * surfaced verbatim by CLI `--verbose`, so they must never carry a usable key. + */ +function maskWorkspaceKey(key: string | null | undefined): string { + if (!key) { + return 'unknown'; + } + const prefix = key.match(/^(rk_live_|at_live_|nt_live_|ot_live_|br_)/)?.[1] ?? ''; + const body = key.slice(prefix.length); + return body.length <= 8 ? `${prefix}…` : `${prefix}…${body.slice(-4)}`; +} + type BrokerExitListener = (info: BrokerExitInfo) => void; // ── Client ───────────────────────────────────────────────────────────── @@ -463,7 +477,7 @@ export class HarnessDriverClient { await new Promise((resolve) => setTimeout(resolve, 1000)); } } - onStep?.(`Broker handshake complete (workspace: ${session?.workspace_key ?? 'unknown'})`); + onStep?.(`Broker handshake complete (workspace: ${maskWorkspaceKey(session?.workspace_key)})`); if (!client.brokerExitInfo) { client.connectEvents(); diff --git a/packages/harness-driver/src/spawn-config.test.ts b/packages/harness-driver/src/spawn-config.test.ts index 8a02d76ec..9fc18ce24 100644 --- a/packages/harness-driver/src/spawn-config.test.ts +++ b/packages/harness-driver/src/spawn-config.test.ts @@ -20,7 +20,7 @@ describe('buildBrokerSpawnConfig', () => { expect(config.env.RELAY_API_KEY).toBe('rk_live_legacy'); }); - it('promotes canonical workspace-key env vars into explicit workspace-key argv', () => { + it('carries the canonical workspace key in env only, never on argv', () => { const config = buildBrokerSpawnConfig( { cwd: '/tmp/my-project', @@ -34,8 +34,8 @@ describe('buildBrokerSpawnConfig', () => { ); expect(config.workspaceKey).toBe('rk_live_workspace'); - expect(config.args).toContain('--workspace-key'); - expect(config.args).toContain('rk_live_workspace'); + expect(config.args).not.toContain('--workspace-key'); + expect(config.args).not.toContain('rk_live_workspace'); expect(config.env.AGENT_RELAY_WORKSPACE_KEY).toBe('rk_live_workspace'); expect(config.env.RELAY_WORKSPACE_KEY).toBe('rk_live_workspace'); expect(config.env.RELAY_API_KEY).toBe('rk_live_workspace'); @@ -66,12 +66,11 @@ describe('buildBrokerSpawnConfig', () => { expect(config.brokerName).toBe('parent-broker'); expect(config.workspaceKey).toBe('rk_live_env_workspace'); + expect(config.env.AGENT_RELAY_WORKSPACE_KEY).toBe('rk_live_env_workspace'); expect(config.args).toEqual([ 'init', '--instance-name', 'parent-broker', - '--workspace-key', - 'rk_live_env_workspace', '--channels', 'general', '--persist', diff --git a/packages/harness-driver/src/spawn-config.ts b/packages/harness-driver/src/spawn-config.ts index f3c95b235..ee0212ab3 100644 --- a/packages/harness-driver/src/spawn-config.ts +++ b/packages/harness-driver/src/spawn-config.ts @@ -118,15 +118,11 @@ export function buildBrokerSpawnConfig( AGENT_RELAY_BROKER_NAME: brokerName, }; - const args = [ - 'init', - '--instance-name', - brokerName, - ...(workspaceKey ? ['--workspace-key', workspaceKey] : []), - '--channels', - channels.join(','), - ...userArgs, - ]; + // The workspace key travels only in the env block above — the broker's + // `resolved_workspace_key()` falls back to AGENT_RELAY_WORKSPACE_KEY — so it + // never appears on the child argv (visible in `ps`) or in startup error + // strings that embed the spawn command line. + const args = ['init', '--instance-name', brokerName, '--channels', channels.join(','), ...userArgs]; return { cwd, brokerName, workspaceKey, channels, timeoutMs, args, env }; }