Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/cli/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ const expectedLeafCommands = [
'workspace list',
'workspace set_key',
'workspace join',
'workspace key',
'workspace switch',
// workspace agents
'agent register',
Expand Down Expand Up @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/cli/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { config as dotenvConfig } from 'dotenv';

import { checkForUpdatesInBackground } from '@agent-relay/utils';
import { redactCredentialValues } from '@agent-relay/cloud';
import {
cloudIdentityEnv,
readStoredIdentitySync,
Expand Down Expand Up @@ -49,8 +50,8 @@

dotenvConfig({ quiet: true });

const __filename = fileURLToPath(import.meta.url);

Check warning on line 53 in packages/cli/src/cli/bootstrap.ts

View workflow job for this annotation

GitHub Actions / lint

Variable name `__filename` trimmed as `_filename` must match one of the following formats: camelCase, UPPER_CASE, PascalCase
const __dirname = path.dirname(__filename);

Check warning on line 54 in packages/cli/src/cli/bootstrap.ts

View workflow job for this annotation

GitHub Actions / lint

Variable name `__dirname` trimmed as `_dirname` must match one of the following formats: camelCase, UPPER_CASE, PascalCase

function findPackageJson(startDir: string): string {
let dir = startDir;
Expand Down Expand Up @@ -128,7 +129,7 @@
// Inherited from a parent process: leave it exactly as-is.
if (process.env[IDENTITY_ENV_KEYS.userId]) return;

let identity: CloudIdentity | null = null;

Check warning on line 132 in packages/cli/src/cli/bootstrap.ts

View workflow job for this annotation

GitHub Actions / lint

The value assigned to 'identity' is not used in subsequent statements
try {
identity = readStoredIdentitySync();
} catch {
Expand Down Expand Up @@ -372,6 +373,14 @@
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')
Expand Down Expand Up @@ -445,7 +454,7 @@
* tree so we can't drift if a new verb is added without updating both
* places.
*/
function collectTopLevelVerbs(program: Command): Set<string> {

Check warning on line 457 in packages/cli/src/cli/bootstrap.ts

View workflow job for this annotation

GitHub Actions / lint

'collectTopLevelVerbs' is defined but never used. Allowed unused vars must match /^_/u
const verbs = new Set<string>();
for (const command of program.commands) {
verbs.add(command.name());
Expand Down
22 changes: 21 additions & 1 deletion packages/cli/src/cli/commands/cloud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
73 changes: 42 additions & 31 deletions packages/cli/src/cli/commands/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
} 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';
Expand Down Expand Up @@ -301,7 +302,7 @@
};
} catch (error) {
if (isCloudLoginError(error)) {
throw new Error('Cloud login required. Run `agent-relay cloud login` and retry.');

Check warning on line 305 in packages/cli/src/cli/commands/cloud.ts

View workflow job for this annotation

GitHub Actions / lint

There is no `cause` attached to the symptom error being thrown
}
throw error;
}
Expand Down Expand Up @@ -518,45 +519,55 @@
.command('session')
.description('Show the canonical Agent Relay Cloud session')
.option('--api-url <url>', '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 <milliseconds>',
'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')
Expand Down
74 changes: 35 additions & 39 deletions packages/cli/src/cli/commands/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ function createRelayMock(overrides: Partial<CoreRelay> = {}): 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,
};
}
Expand Down Expand Up @@ -536,7 +536,7 @@ describe('registerCoreCommands', () => {
'--state-dir',
stateDir,
'--workspace-key',
'rk_live_custom',
'rk_live_customflag77',
'--broker-name',
'relayfile-dev',
];
Expand All @@ -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');
Expand Down Expand Up @@ -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',
});
Expand All @@ -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();
});

Expand Down Expand Up @@ -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,
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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');
});
});

Expand Down
Loading
Loading