Skip to content
Open
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased - Patch]

### Added

- `agent-relay workspace active` now reports whether Relaycast, Relayfile, and RelayAuth resolve the workspace to one data-plane ID. `--json` gains a `dataPlane` object (`unified`, the shared `workspaceId`, per-plane IDs, and the names of any that diverge), the human output prints the Relaycast ID it used to omit, and `--require-unified` turns a divergence into a non-zero exit for setup doctors and supervisors.
- `agent-relay node status` prints the durable `Workspace:` ID alongside the masked workspace key, so an operator can confirm a restart preserved workspace identity. See `specs/workspace-identity.md`.

### Fixed

- A node started with no project-pinned workspace no longer mints a throwaway workspace. `agent-relay node up` now falls back to the machine-global canonical workspace (`agent-relay workspace join|switch`) before letting the broker create one, so the node and its resident agents keep the same workspace — and the same delivery addresses — across a stop/start. Explicit `--workspace-key`, workspace env vars, and an existing project pin all still win; a machine with no canonical workspace set behaves as before.

- `agent-relay integration webhook create` now works. It took a `<url>` argument and sent `{ url, event }`, but `POST /v1/webhooks` accepts `{ channel, name? }` and returns the URL — so every invocation failed with `channel is required`. It now takes `<channel>` with an optional `--name`, matching `create-inbound`, which posts to the same endpoint.
- `@agent-relay/sdk` `RelayCreateWebhookInput` declared a required `url` and an `event`, neither of which the endpoint accepts. It is now `{ channel, name? }`. Code passing `url`/`event` was already failing at runtime.

Expand Down
102 changes: 99 additions & 3 deletions packages/cli/src/cli/commands/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Command } from 'commander';
import nodeFs from 'node:fs';
import os from 'node:os';
import nodePath from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { readProjectWorkspaceKey, readProjectWorkspaceSession } from '../lib/project-workspace-key.js';

Expand All @@ -12,8 +12,10 @@ const sdkStatusClient = {
async () =>
({ workspace_key: '' }) as {
workspace_key?: string;
default_workspace_id?: string;
node_id?: string;
node_name?: string;
node_token?: string;
}
),
disconnect: vi.fn(() => undefined),
Expand Down Expand Up @@ -60,6 +62,12 @@ beforeEach(() => {
telemetryMocks.track.mockClear();
});

afterEach(() => {
for (const dir of relayHomes.splice(0)) {
nodeFs.rmSync(dir, { recursive: true, force: true });
}
});

import {
registerCoreCommands,
registerCoreMaintenance,
Expand Down Expand Up @@ -127,6 +135,25 @@ function createFsMock(initialFiles: Record<string, string> = {}): CoreFileSystem
}

// eslint-disable-next-line complexity
const relayHomes: string[] = [];

/** An `AGENT_RELAY_HOME` with no workspace store — no canonical workspace exists. */
function emptyRelayHome(): string {
const dir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'core-relay-home-'));
relayHomes.push(dir);
return dir;
}

/** An `AGENT_RELAY_HOME` whose active workspace is `key`. */
function relayHomeWithCanonicalWorkspace(key: string): string {
const dir = emptyRelayHome();
nodeFs.writeFileSync(
nodePath.join(dir, 'workspaces.json'),
JSON.stringify({ active: 'default', workspaces: { default: { key } } })
);
return dir;
}

function createHarness(options?: {
fs?: CoreFileSystem;
relay?: CoreRelay;
Expand Down Expand Up @@ -156,6 +183,10 @@ function createHarness(options?: {
const spawnedProcess = options?.spawnedProcess ?? createSpawnedProcessMock();
const env = options?.env ?? {};
env.AGENT_RELAY_DISABLE_IMPLICIT_FLEET_NODE ??= '1';
// `up` now falls back to the machine-global workspace store. Point every
// harness at an isolated home by default so a test never picks up (or
// prints) whatever workspace the developer's own machine has active.
env.AGENT_RELAY_HOME ??= emptyRelayHome();

const exit = vi.fn((code: number) => {
throw new ExitSignal(code);
Expand Down Expand Up @@ -1196,6 +1227,33 @@ describe('registerCoreCommands', () => {
expect(sdkStatusClient.disconnect).toHaveBeenCalled();
});

it('status reports the durable workspace ID without leaking any credential', async () => {
// The workspace ID is what an operator compares before and after a restart
// to confirm identity held; everything credential-shaped stays masked.
const connectionPath = '/tmp/project/.agentworkforce/relay/connection.json';
const fs = createFsMock({ [connectionPath]: connectionFile(4242) });
sdkStatusClient.getStatus.mockResolvedValueOnce({ agent_count: 1 });
sdkStatusClient.getSession.mockResolvedValueOnce({
workspace_key: 'rk_live_teststatus123',
default_workspace_id: 'rw_7ccfea89',
node_id: 'node_enrolled',
node_name: 'sf-mini',
node_token: 'nt_live_nodetoken456',
});

const { program, deps } = createHarness({ fs });

await runCommand(program, ['status']);

expect(deps.log).toHaveBeenCalledWith('Workspace: rw_7ccfea89');
const output = (deps.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().join('\n');
expect(output).not.toContain('rk_live_teststatus123');
expect(output).not.toContain('nt_live_nodetoken456');
// Observer URLs carry a scoped token in the query string; status never
// prints one at all, which is the only way to guarantee it can't leak.
expect(output).not.toMatch(/ot_live_|[?&](token|key|api_key)=/);
});

it('status omits workspace key and observer when broker has no workspace_key', async () => {
const connectionPath = '/tmp/project/.agentworkforce/relay/connection.json';
const fs = createFsMock({ [connectionPath]: connectionFile(4242) });
Expand Down Expand Up @@ -1509,8 +1567,11 @@ describe('registerCoreCommands', () => {
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 () => {
const env: NodeJS.ProcessEnv = {};
it('up without --workspace-key, a pinned session, or a canonical workspace sets no key env vars', async () => {
// AGENT_RELAY_HOME points at an empty dir so the machine-global workspace
// store is genuinely absent rather than whatever the dev box happens to
// have active.
const env: NodeJS.ProcessEnv = { AGENT_RELAY_HOME: emptyRelayHome() };
const relay = createRelayMock();
const { program } = createHarness({ relay, env });

Expand All @@ -1521,6 +1582,41 @@ describe('registerCoreCommands', () => {
expect(env.RELAY_API_KEY).toBeUndefined();
});

it('up falls back to the machine-global canonical workspace when nothing else selects one', async () => {
const env: NodeJS.ProcessEnv = {
AGENT_RELAY_HOME: relayHomeWithCanonicalWorkspace('rk_live_canonicalstore01'),
};
const relay = createRelayMock({ workspaceKey: 'rk_live_canonicalstore01' });
const { program, deps } = createHarness({ relay, env });

const exitCode = await runCommand(program, ['up']);

expect(exitCode).toBeUndefined();
expect(env.RELAY_WORKSPACE_KEY).toBe('rk_live_canonicalstore01');
expect(env.RELAY_API_KEY).toBe('rk_live_canonicalstore01');
// Only the source is named; the key itself is a live credential.
const output = (deps.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().join('\n');
expect(output).toContain('machine-global canonical Agent Relay workspace');
expect(output).not.toContain('rk_live_canonicalstore01');
});

it('up prefers the project pin over the machine-global canonical workspace', async () => {
const env: NodeJS.ProcessEnv = {
AGENT_RELAY_HOME: relayHomeWithCanonicalWorkspace('rk_live_canonicalstore01'),
};
const fs = createFsMock({
'/tmp/project/.agentworkforce/relay/workspace-key.json': JSON.stringify({
workspaceKey: 'rk_live_projectpin01',
}),
});
const relay = createRelayMock({ workspaceKey: 'rk_live_projectpin01' });
const { program } = createHarness({ relay, env, fs });

await runCommand(program, ['up']);

expect(env.RELAY_WORKSPACE_KEY).toBe('rk_live_projectpin01');
});

it('up resumes the workspace session pinned to the project', async () => {
const env: NodeJS.ProcessEnv = {};
const fs = createFsMock({
Expand Down
123 changes: 113 additions & 10 deletions packages/cli/src/cli/commands/workspace.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { Command } from 'commander';
import { beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('@agent-relay/cloud', () => ({
readWorkspaceStore: vi.fn(() => ({ workspaces: {} })),
resolveActiveWorkspace: vi.fn(),
setWorkspaceKey: vi.fn(),
switchWorkspace: vi.fn(),
}));
vi.mock('@agent-relay/cloud', async (importOriginal) => {
// The convergence helpers are pure and are the thing under test here — keep
// the real implementations so the command's evidence output isn't asserted
// against a stub that could drift from it.
const actual = await importOriginal<typeof import('@agent-relay/cloud')>();
return {
describeDataPlaneConvergence: actual.describeDataPlaneConvergence,
formatDataPlaneDivergence: actual.formatDataPlaneDivergence,
readWorkspaceStore: vi.fn(() => ({ workspaces: {} })),
resolveActiveWorkspace: vi.fn(),
setWorkspaceKey: vi.fn(),
switchWorkspace: vi.fn(),
};
});

vi.mock('../lib/workspace-session.js', () => ({
persistWorkspaceSession: vi.fn(),
Expand Down Expand Up @@ -59,7 +67,7 @@ describe('registerWorkspaceCommands', () => {
name: 'Ops',
key: 'rk_live_ops',
cloudWorkspaceId: 'rw_ops',
relaycastWorkspaceId: 'rc_ops',
relaycastWorkspaceId: 'rw_ops',
relayfileWorkspaceId: 'rw_ops',
relayauthWorkspaceId: 'rw_ops',
organizationId: 'org_1',
Expand Down Expand Up @@ -89,14 +97,109 @@ describe('registerWorkspaceCommands', () => {
name: 'Ops',
key: 'rk_live_…',
cloudWorkspaceId: 'rw_ops',
relaycastWorkspaceId: 'rc_ops',
relaycastWorkspaceId: 'rw_ops',
relayfileWorkspaceId: 'rw_ops',
relayauthWorkspaceId: 'rw_ops',
organizationId: 'org_1',
slug: 'ops',
urls: {},
apiUrl: 'https://cloud.test',
dataPlane: {
unified: true,
workspaceId: 'rw_ops',
planes: { relaycast: 'rw_ops', relayfile: 'rw_ops', relayauth: 'rw_ops' },
divergent: [],
},
});
});

it('workspace active --json proves the three data planes share one workspace ID', async () => {
const { program, deps } = createHarness();
vi.mocked(resolveActiveWorkspace).mockResolvedValueOnce({
key: 'rk_live_ops',
cloudWorkspaceId: '50587328-441d-4acb-b8f3-dbe1b3c5de99',
relaycastWorkspaceId: 'rw_7ccfea89',
relayfileWorkspaceId: 'rw_7ccfea89',
relayauthWorkspaceId: 'rw_7ccfea89',
urls: {},
apiUrl: 'https://cloud.test',
});

await program.parseAsync(['node', 'agent-relay', 'workspace', 'active', '--json', '--require-unified']);

const printed = JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0]));
expect(printed.dataPlane).toEqual({
unified: true,
workspaceId: 'rw_7ccfea89',
planes: { relaycast: 'rw_7ccfea89', relayfile: 'rw_7ccfea89', relayauth: 'rw_7ccfea89' },
divergent: [],
});
expect(deps.error).not.toHaveBeenCalled();
expect(deps.exit).not.toHaveBeenCalled();
});

it('workspace active --require-unified exits 1 when the planes diverge', async () => {
const { program, deps } = createHarness();
vi.mocked(resolveActiveWorkspace).mockResolvedValueOnce({
key: 'rk_live_ops',
cloudWorkspaceId: 'rw_ops',
relaycastWorkspaceId: 'rw_cast',
relayfileWorkspaceId: 'rw_file',
relayauthWorkspaceId: 'rw_cast',
urls: {},
apiUrl: 'https://cloud.test',
});

await expect(
program.parseAsync(['node', 'agent-relay', 'workspace', 'active', '--json', '--require-unified'])
).rejects.toThrow('exit:1');

const printed = JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0]));
expect(printed.dataPlane).toMatchObject({ unified: false, divergent: ['relayfile'] });
expect(printed.dataPlane.workspaceId).toBeUndefined();
expect(vi.mocked(deps.error).mock.calls.flat().join('\n')).toContain('not durable');
});

it('workspace active warns but still succeeds on divergence without --require-unified', async () => {
const { program, deps } = createHarness();
vi.mocked(resolveActiveWorkspace).mockResolvedValueOnce({
key: 'rk_live_ops',
cloudWorkspaceId: 'rw_ops',
relaycastWorkspaceId: 'rw_cast',
relayfileWorkspaceId: 'rw_file',
relayauthWorkspaceId: 'rw_cast',
urls: {},
apiUrl: 'https://cloud.test',
});

await program.parseAsync(['node', 'agent-relay', 'workspace', 'active', '--json']);

expect(deps.error).toHaveBeenCalled();
expect(deps.exit).not.toHaveBeenCalled();
});

it('workspace active prints every plane ID, including Relaycast, in human output', async () => {
const { program, deps } = createHarness();
vi.mocked(resolveActiveWorkspace).mockResolvedValueOnce({
name: 'default',
key: 'rk_live_ops',
cloudWorkspaceId: 'cloud-uuid',
relaycastWorkspaceId: 'rw_7ccfea89',
relayfileWorkspaceId: 'rw_7ccfea89',
relayauthWorkspaceId: 'rw_7ccfea89',
urls: {},
apiUrl: 'https://cloud.test',
});

await program.parseAsync(['node', 'agent-relay', 'workspace', 'active']);

const output = vi.mocked(deps.log).mock.calls.flat().join('\n');
expect(output).toContain('Relaycast workspace ID: rw_7ccfea89');
expect(output).toContain('Relayfile workspace ID: rw_7ccfea89');
expect(output).toContain('Relayauth workspace ID: rw_7ccfea89');
expect(output).toContain('Data-plane workspace ID: rw_7ccfea89 (unified)');
// Human output must not carry the credential that unlocks the workspace.
expect(output).not.toContain('rk_live_ops');
});

it('workspace active --json includes raw keys only with --reveal-secrets', async () => {
Expand All @@ -105,7 +208,7 @@ describe('registerWorkspaceCommands', () => {
name: 'Ops',
key: 'rk_live_ops',
cloudWorkspaceId: 'rw_ops',
relaycastWorkspaceId: 'rc_ops',
relaycastWorkspaceId: 'rw_ops',
relaycastApiKey: 'rk_live_castkey01',
relayfileWorkspaceId: 'rw_ops',
relayauthWorkspaceId: 'rw_ops',
Expand All @@ -126,7 +229,7 @@ describe('registerWorkspaceCommands', () => {
name: 'Ops',
key: 'rk_live_ops',
cloudWorkspaceId: 'rw_ops',
relaycastWorkspaceId: 'rc_ops',
relaycastWorkspaceId: 'rw_ops',
relaycastApiKey: 'rk_live_castkey01',
relayfileWorkspaceId: 'rw_ops',
relayauthWorkspaceId: 'rw_ops',
Expand Down
Loading