From 992f1d9df69561b843073d9d821b637b2354b15d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 14:01:09 +0000 Subject: [PATCH 1/2] feat(cli): add `agent-relay observer` to mint read-only follow-along links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharing a live view of a workspace had no supported path. The engine rejects a workspace key on the realtime endpoint and only accepts a scoped `ot_live_` observer token, but nothing in the CLI or the MCP server minted one — the only routes were a hand-rolled `POST /v1/observer-tokens` or pasting an admin key into the dashboard login. So the bundled instructions told agents that observation "requires a separately provisioned observer token" and to omit the link when none exists, which in practice meant never showing one. Adds the missing primitive: - `agent-relay observer` mints a scoped token and prints the observer URL built from it. `observer list` / `observer revoke ` manage tokens. - `get_observer_url` MCP tool does the same for an orchestrating agent. - `@agent-relay/sdk` exports `createObserverToken`, `listObserverTokens`, and `revokeObserverToken`. Defaults are deliberately tighter than the observer dashboard's own auto-minted token (30 days, DMs included): that one backs a browser session behind an httpOnly cookie, while this one is printed as a URL meant to be pasted into chat. 24 hours, agent DMs excluded, widened explicitly via `--expires` / `--include-dms` / `--channels`. `observerUrl` refuses any credential that is not an `ot_live_` token, so the workspace-key-in-a-URL failure this command exists to prevent cannot be reintroduced by a later caller. Updates the Codex skill and Gemini extension instructions to point at the command instead of describing the link as unobtainable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmke9G9s7ftrN49opNmdx1 --- CHANGELOG.md | 8 +- packages/cli/src/cli/agent-relay-mcp.ts | 59 +++++ packages/cli/src/cli/bootstrap.test.ts | 23 ++ packages/cli/src/cli/bootstrap.ts | 2 + .../cli/src/cli/commands/observer.test.ts | 159 +++++++++++++ packages/cli/src/cli/commands/observer.ts | 219 ++++++++++++++++++ packages/cli/src/cli/lib/observer-url.ts | 57 +++++ .../sdk/src/__tests__/thin-client.test.ts | 64 +++++ packages/sdk/src/messaging/thin-client.ts | 136 +++++++++++ plugins/codex-relay-skill/SKILL.md | 12 +- plugins/gemini-relay-extension/GEMINI.md | 10 +- 11 files changed, 739 insertions(+), 10 deletions(-) create mode 100644 packages/cli/src/cli/commands/observer.test.ts create mode 100644 packages/cli/src/cli/commands/observer.ts create mode 100644 packages/cli/src/cli/lib/observer-url.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4525819da..c6f15ccff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,13 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased - Patch] +## [Unreleased - Minor] + +### Added + +- `agent-relay observer` mints a scoped, read-only observer token and prints the observer URL built from it, so sharing a live follow-along view no longer requires hand-rolling a `POST /v1/observer-tokens` call. Defaults to a 24-hour token with agent DMs excluded; `--channels`, `--include-dms`, and `--expires` widen it, and `observer list` / `observer revoke ` manage existing tokens. +- `get_observer_url` MCP tool does the same for an orchestrating agent, so a lead can hand the user a follow-along link without shelling out. +- `@agent-relay/sdk` exports `createObserverToken`, `listObserverTokens`, and `revokeObserverToken`. ### Fixed diff --git a/packages/cli/src/cli/agent-relay-mcp.ts b/packages/cli/src/cli/agent-relay-mcp.ts index 0125de853..b2ad8a95e 100644 --- a/packages/cli/src/cli/agent-relay-mcp.ts +++ b/packages/cli/src/cli/agent-relay-mcp.ts @@ -15,6 +15,7 @@ import { AgentRelay, RELAYCAST_SDK_VERSION, createAgentClient, + createObserverToken, createRealtimeClient, createWorkspaceClient, } from '@agent-relay/sdk'; @@ -22,6 +23,7 @@ import { z } from 'zod'; import { initTelemetry, shutdown as shutdownTelemetry } from './telemetry/index.js'; import { RealtimeResourceBridge, SubscriptionManager, registerResourceDefinitions } from './mcp/resources.js'; import { jsonContent, jsonResult, textContent } from './mcp/tool-results.js'; +import { observerUrl, resolveObserverBaseUrl } from './lib/observer-url.js'; import { createWorkspace, extractWorkspaceKey, @@ -577,6 +579,63 @@ function registerAgentRelayTools( } ); + server.registerTool( + 'get_observer_url', + { + title: 'Get Observer URL', + description: + 'Mint a scoped, read-only observer link so a human can follow this workspace live. ' + + 'Use this whenever the user asks to watch, follow along with, or see the agent conversation. ' + + 'Returns a URL backed by a read-only observer token that expires — NEVER build an observer ' + + 'URL from the workspace key, which is an administrative credential.', + inputSchema: { + channels: z + .array(z.string()) + .optional() + .describe('Restrict the view to these channels. Omit to show every channel.'), + include_dms: z + .boolean() + .optional() + .describe('Include agent DM traffic. Defaults to false (channels only).'), + expires_in_hours: z + .number() + .int() + .min(1) + .max(2160) + .optional() + .describe('Token lifetime in hours. Defaults to 24.'), + }, + outputSchema: jsonResult, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + }, + async ({ channels, include_dms, expires_in_hours }: any) => { + const session = getSession(); + requireWorkspaceKey(session); + const lifetimeHours = expires_in_hours ?? 24; + const token = await createObserverToken({ + workspaceKey: session.workspaceKey as string, + name: `observer-mcp-${Math.random().toString(36).slice(2, 10)}`, + description: 'Minted by the get_observer_url MCP tool for read-only follow-along', + filters: { + includeDms: include_dms === true, + ...(channels?.length ? { channelNames: channels } : {}), + }, + expiresAt: new Date(Date.now() + lifetimeHours * 3_600_000).toISOString(), + ...(baseUrl ? { baseUrl } : {}), + }); + if (!token.token) { + throw new Error('Observer token created, but the response did not include token material.'); + } + return jsonContent({ + url: observerUrl(resolveObserverBaseUrl(undefined), token.token), + tokenId: token.id, + expiresAt: token.expiresAt, + includesDms: include_dms === true, + ...(channels?.length ? { channels } : {}), + }); + } + ); + server.registerTool( 'query_nodes', { diff --git a/packages/cli/src/cli/bootstrap.test.ts b/packages/cli/src/cli/bootstrap.test.ts index cb4da75b7..ad9199a45 100644 --- a/packages/cli/src/cli/bootstrap.test.ts +++ b/packages/cli/src/cli/bootstrap.test.ts @@ -87,6 +87,10 @@ const expectedLeafCommands = [ 'workspace join', 'workspace key', 'workspace switch', + // observer (the bare `observer` mint action is asserted separately — it is a + // group with a default action, so the leaf walk does not reach it) + 'observer list', + 'observer revoke', // workspace agents 'agent register', 'agent list', @@ -212,6 +216,7 @@ describe('bootstrap CLI', () => { 'fleet', 'reflex', 'status', + 'observer', 'version', 'update', 'uninstall', @@ -243,6 +248,24 @@ describe('bootstrap CLI', () => { ); }); + it('registers `observer` as a runnable command, not just a group', () => { + // `observer` carries both a default action (mint a link) and subcommands + // (list/revoke), so the leaf-path walk in the inventory test below skips + // it. Assert the action directly — otherwise the primary command could be + // dropped and every other assertion would still pass. + // The action's behaviour is covered in commands/observer.test.ts, which + // parses a bare `observer` and asserts a token is minted. + const program = createProgram(); + const observer = program.commands.find((command) => command.name() === 'observer'); + expect(observer).toBeDefined(); + expect(observer?.commands.map((command) => command.name()).sort()).toEqual(['list', 'revoke']); + // The mint options live on the group itself, which is what makes a bare + // `agent-relay observer` runnable. + expect(observer?.options.map((option) => option.long)).toEqual( + expect.arrayContaining(['--channels', '--include-dms', '--expires']) + ); + }); + it('registers the expected executable commands', () => { const program = createProgram(); const leafCommandPaths = collectLeafCommandPaths(program); diff --git a/packages/cli/src/cli/bootstrap.ts b/packages/cli/src/cli/bootstrap.ts index 8d8db38c9..f3b7f9440 100644 --- a/packages/cli/src/cli/bootstrap.ts +++ b/packages/cli/src/cli/bootstrap.ts @@ -41,6 +41,7 @@ import { registerLocalWorkflowCommands } from './commands/local-workflow.js'; import { registerCloudCommands } from './commands/cloud.js'; import { registerReflexCommands } from './commands/reflex.js'; import { registerWorkspaceCommands } from './commands/workspace.js'; +import { registerObserverCommands } from './commands/observer.js'; import { registerAgentCommands } from './commands/agent.js'; import { registerChannelCommands } from './commands/channel.js'; import { registerMessageCommands } from './commands/message.js'; @@ -413,6 +414,7 @@ export function createProgram(options: { name?: string } = {}): Command { registerCloudCommands(program); registerReflexCommands(program); registerWorkspaceCommands(program); + registerObserverCommands(program); registerAgentCommands(program); registerChannelCommands(program); registerMessageCommands(program); diff --git a/packages/cli/src/cli/commands/observer.test.ts b/packages/cli/src/cli/commands/observer.test.ts new file mode 100644 index 000000000..bf1423ed4 --- /dev/null +++ b/packages/cli/src/cli/commands/observer.test.ts @@ -0,0 +1,159 @@ +import { Command } from 'commander'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerObserverCommands, type ObserverCommandDependencies } from './observer.js'; +import { observerUrl, resolveObserverBaseUrl } from '../lib/observer-url.js'; + +class ExitSignal extends Error { + constructor(public readonly code: number) { + super(`exit:${code}`); + } +} + +const WORKSPACE_KEY = 'rk_live_workspacekey000000'; +const FIXED_NOW = Date.parse('2026-08-03T00:00:00.000Z'); + +function createdToken(overrides: Record = {}) { + return { + id: 'ot_abc123', + name: 'observer-cli-deadbeef', + scopes: ['stream:read', 'messages:read'], + status: 'active', + expiresAt: '2026-08-04T00:00:00.000Z', + createdAt: '2026-08-03T00:00:00.000Z', + token: 'ot_live_secrettokenmaterial', + ...overrides, + }; +} + +function setup(overrides: Partial = {}) { + const logs: string[] = []; + const errors: string[] = []; + const createObserverToken = vi.fn(async () => createdToken()); + const listObserverTokens = vi.fn(async () => []); + const revokeObserverToken = vi.fn(async () => {}); + + const program = new Command(); + program.exitOverride(); + registerObserverCommands(program, { + log: (...args: unknown[]) => logs.push(args.join(' ')), + error: (...args: unknown[]) => errors.push(args.join(' ')), + exit: (code: number) => { + throw new ExitSignal(code); + }, + createObserverToken: createObserverToken as never, + listObserverTokens: listObserverTokens as never, + revokeObserverToken: revokeObserverToken as never, + now: () => FIXED_NOW, + randomSuffix: () => 'deadbeef', + ...overrides, + }); + + return { program, logs, errors, createObserverToken, listObserverTokens, revokeObserverToken }; +} + +describe('agent-relay observer', () => { + beforeEach(() => { + process.env.RELAY_WORKSPACE_KEY = WORKSPACE_KEY; + delete process.env.RELAY_OBSERVER_URL; + delete process.env.RELAY_BASE_URL; + }); + + it('mints a scoped token and prints an observer URL carrying the token, not the workspace key', async () => { + const { program, logs, createObserverToken } = setup(); + + await program.parseAsync(['observer'], { from: 'user' }); + + const [call] = createObserverToken.mock.calls as unknown as [[Record]]; + expect(call[0].workspaceKey).toBe(WORKSPACE_KEY); + // Default posture: DMs excluded, no channel narrowing, 24h lifetime. + expect(call[0].filters).toEqual({ includeDms: false }); + expect(call[0].expiresAt).toBe(new Date(FIXED_NOW + 24 * 3_600_000).toISOString()); + + const url = logs[0]; + expect(url).toBe('https://agentrelay.com/observer?key=ot_live_secrettokenmaterial'); + // The whole point: the administrative credential never reaches the output. + expect(logs.join('\n')).not.toContain(WORKSPACE_KEY); + }); + + it('narrows to channels and includes DMs when asked', async () => { + const { program, createObserverToken } = setup(); + + await program.parseAsync( + ['observer', '--channels', '#general, build ,general', '--include-dms', '--expires', '7d'], + { from: 'user' } + ); + + const [call] = createObserverToken.mock.calls as unknown as [[Record]]; + // Leading `#` stripped and duplicates collapsed. + expect(call[0].filters).toEqual({ includeDms: true, channelNames: ['general', 'build'] }); + expect(call[0].expiresAt).toBe(new Date(FIXED_NOW + 7 * 86_400_000).toISOString()); + }); + + it('rejects a bare-number expiry rather than guessing a unit', async () => { + const { program } = setup(); + + await expect( + program.parseAsync(['observer', '--expires', '24'], { from: 'user' }) + ).rejects.toThrow(/30m, 24h, or 7d/); + }); + + it('fails loudly when the engine returns no token material', async () => { + const { program, errors } = setup({ + createObserverToken: vi.fn(async () => createdToken({ token: undefined })) as never, + }); + + await expect(program.parseAsync(['observer'], { from: 'user' })).rejects.toBeInstanceOf( + ExitSignal + ); + expect(errors.join('\n')).toContain('did not include token material'); + }); + + it('list never prints token material', async () => { + const { program, logs } = setup({ + listObserverTokens: vi.fn(async () => [ + createdToken({ token: undefined, lastUsedAt: null }), + ]) as never, + }); + + await program.parseAsync(['observer', 'list'], { from: 'user' }); + + expect(logs.join('\n')).toContain('ot_abc123'); + expect(logs.join('\n')).not.toContain('ot_live_'); + }); + + it('revokes by id', async () => { + const { program, revokeObserverToken, logs } = setup(); + + await program.parseAsync(['observer', 'revoke', 'ot_abc123'], { from: 'user' }); + + const [call] = revokeObserverToken.mock.calls as unknown as [[Record]]; + expect(call[0]).toMatchObject({ workspaceKey: WORKSPACE_KEY, id: 'ot_abc123' }); + expect(logs.join('\n')).toContain('Revoked ot_abc123.'); + }); +}); + +describe('observer URL construction', () => { + it('refuses to build a URL from a workspace key', () => { + expect(() => observerUrl('https://agentrelay.com/observer', WORKSPACE_KEY)).toThrow( + /scoped observer token/ + ); + }); + + it('prefers an explicit URL, then RELAY_OBSERVER_URL, then the hosted default', () => { + const env = { RELAY_OBSERVER_URL: 'https://observer.relaycast.dev' } as NodeJS.ProcessEnv; + expect(resolveObserverBaseUrl('https://example.test/observer', env)).toBe( + 'https://example.test/observer' + ); + expect(resolveObserverBaseUrl(undefined, env)).toBe('https://observer.relaycast.dev'); + expect(resolveObserverBaseUrl(undefined, {} as NodeJS.ProcessEnv)).toBe( + 'https://agentrelay.com/observer' + ); + }); + + it('rejects a malformed observer URL instead of emitting a broken link', () => { + expect(() => resolveObserverBaseUrl('not-a-url', {} as NodeJS.ProcessEnv)).toThrow( + /Invalid observer URL/ + ); + }); +}); diff --git a/packages/cli/src/cli/commands/observer.ts b/packages/cli/src/cli/commands/observer.ts new file mode 100644 index 000000000..712cce44f --- /dev/null +++ b/packages/cli/src/cli/commands/observer.ts @@ -0,0 +1,219 @@ +/** + * `agent-relay observer` — mint a scoped, read-only observer link. + * + * The engine rejects a workspace key (`rk_live_`) on the realtime endpoint, and + * a workspace key is an administrative credential that has no business in a URL + * or a terminal transcript. This command mints a scoped `ot_live_` token instead + * and prints the observer URL built from it, so "let a human follow along" is a + * single command rather than a hand-rolled API call. + */ + +import type { Command } from 'commander'; +import { InvalidArgumentError } from 'commander'; + +import { + createObserverToken, + listObserverTokens, + revokeObserverToken, + type RelayObserverToken, +} from '@agent-relay/sdk'; + +import { + DEFAULT_OBSERVER_EXPIRES, + DEFAULT_OBSERVER_URL, + observerUrl, + resolveObserverBaseUrl, +} from '../lib/observer-url.js'; +import { printJson, runSdk, withSdkDefaults, type SdkCommandDeps } from '../lib/sdk-command.js'; +import { resolveBaseUrl, resolveWorkspaceKey } from '../lib/sdk-client.js'; + +const MAX_CHANNEL_FILTERS = 50; + +export interface ObserverCommandDependencies extends SdkCommandDeps { + createObserverToken: typeof createObserverToken; + listObserverTokens: typeof listObserverTokens; + revokeObserverToken: typeof revokeObserverToken; + /** Injected so tests get deterministic token names and expiry timestamps. */ + now: () => number; + randomSuffix: () => string; +} + +function withObserverDefaults( + overrides: Partial = {} +): ObserverCommandDependencies { + return { + ...withSdkDefaults(overrides), + createObserverToken, + listObserverTokens, + revokeObserverToken, + now: () => Date.now(), + randomSuffix: () => Math.random().toString(36).slice(2, 10), + ...overrides, + }; +} + +/** + * Parse a `30m` / `24h` / `7d` duration into milliseconds. Bare digits are + * rejected rather than guessed at — `--expires 24` is far more likely to mean + * hours than milliseconds, and silently picking either would be wrong. + */ +export function parseDuration(value: string): number { + const match = /^(\d+)([mhd])$/.exec(value.trim()); + if (!match) { + throw new InvalidArgumentError('Expected a duration like 30m, 24h, or 7d.'); + } + const amount = Number(match[1]); + if (amount <= 0) { + throw new InvalidArgumentError('Expected a positive duration.'); + } + const unitMs = { m: 60_000, h: 3_600_000, d: 86_400_000 }[match[2] as 'm' | 'h' | 'd']; + const total = amount * unitMs; + // The engine caps nothing here, but a token outliving the workspace is a + // liability rather than a convenience. + if (total > 90 * 86_400_000) { + throw new InvalidArgumentError('Expected a duration of 90d or less.'); + } + return total; +} + +function parseChannels(value: string): string[] { + const names = value + .split(',') + .map((name) => name.trim().replace(/^#/, '')) + .filter(Boolean); + if (names.length === 0) { + throw new InvalidArgumentError('Expected at least one channel name.'); + } + if (names.length > MAX_CHANNEL_FILTERS) { + throw new InvalidArgumentError(`Expected at most ${MAX_CHANNEL_FILTERS} channel names.`); + } + return [...new Set(names)]; +} + +/** + * Credentials shared by every observer subcommand. `baseUrl` is spread rather + * than passed as `undefined` because the SDK options treat an explicit + * `undefined` and an absent key differently. + */ +function connection(options: Record): { workspaceKey: string; baseUrl?: string } { + const baseUrl = resolveBaseUrl({ baseUrl: options.baseUrl as string | undefined }); + return { + workspaceKey: resolveWorkspaceKey({ + workspaceKey: options.workspaceKey as string | undefined, + }), + ...(baseUrl ? { baseUrl } : {}), + }; +} + +function describeToken(token: RelayObserverToken): Record { + return { + id: token.id, + name: token.name, + status: token.status, + expiresAt: token.expiresAt, + createdAt: token.createdAt, + ...(token.lastUsedAt === undefined ? {} : { lastUsedAt: token.lastUsedAt }), + }; +} + +export function registerObserverCommands( + program: Command, + overrides: Partial = {} +): void { + const deps = withObserverDefaults(overrides); + const env = process.env; + + const group = program + .command('observer') + .description('Mint a read-only observer link so a human can follow this workspace'); + + group + .option('--workspace-key ', 'Workspace key (defaults to RELAY_WORKSPACE_KEY or the active workspace)') + .option('--base-url ', 'Override the engine API base URL (defaults to RELAY_BASE_URL)') + .option('--observer-url ', `Observer dashboard URL (defaults to ${DEFAULT_OBSERVER_URL})`) + .option('--name ', 'Token name (defaults to a generated unique name)') + .option('--channels ', 'Restrict to a comma-separated list of channels', parseChannels) + .option('--include-dms', 'Include agent DM traffic (excluded by default)') + .option('--expires ', `Token lifetime, e.g. 30m, 24h, 7d (default ${DEFAULT_OBSERVER_EXPIRES})`, parseDuration) + .option('--json', 'Output the token metadata and URL as JSON') + .action(async (options: Record) => { + await runSdk(deps, async () => { + const lifetimeMs = (options.expires as number | undefined) ?? parseDuration(DEFAULT_OBSERVER_EXPIRES); + const name = + (options.name as string | undefined)?.trim() || `observer-cli-${deps.randomSuffix()}`; + + const token = await deps.createObserverToken({ + ...connection(options), + name, + description: 'Minted by `agent-relay observer` for read-only follow-along', + filters: { + includeDms: options.includeDms === true, + ...(options.channels ? { channelNames: options.channels as string[] } : {}), + }, + expiresAt: new Date(deps.now() + lifetimeMs).toISOString(), + }); + + if (!token.token) { + throw new Error('Observer token created, but the response did not include token material.'); + } + + const url = observerUrl( + resolveObserverBaseUrl(options.observerUrl as string | undefined, env), + token.token + ); + + if (options.json) { + printJson(deps, { ...describeToken(token), url }); + return; + } + + deps.log(url); + deps.log(''); + deps.log(`Read-only. Expires ${token.expiresAt ?? 'never'}.`); + deps.log( + options.includeDms === true + ? 'Includes agent DMs.' + : 'Channels only — agent DMs are excluded.' + ); + deps.log(`Revoke with: agent-relay observer revoke ${token.id}`); + }); + }); + + group + .command('list') + .description('List observer tokens for this workspace (token material is never shown)') + .option('--workspace-key ', 'Workspace key (defaults to RELAY_WORKSPACE_KEY or the active workspace)') + .option('--base-url ', 'Override the engine API base URL') + .option('--json', 'Output as JSON') + .action(async (options: Record) => { + await runSdk(deps, async () => { + const tokens = await deps.listObserverTokens(connection(options)); + + if (options.json) { + printJson(deps, tokens.map(describeToken)); + return; + } + + if (tokens.length === 0) { + deps.log('No observer tokens.'); + return; + } + for (const token of tokens) { + deps.log(`${token.id} ${token.status} expires ${token.expiresAt ?? 'never'} ${token.name}`); + } + }); + }); + + group + .command('revoke') + .description('Revoke an observer token by id') + .argument('', 'Observer token id') + .option('--workspace-key ', 'Workspace key (defaults to RELAY_WORKSPACE_KEY or the active workspace)') + .option('--base-url ', 'Override the engine API base URL') + .action(async (id: string, options: Record) => { + await runSdk(deps, async () => { + await deps.revokeObserverToken({ ...connection(options), id }); + deps.log(`Revoked ${id}.`); + }); + }); +} diff --git a/packages/cli/src/cli/lib/observer-url.ts b/packages/cli/src/cli/lib/observer-url.ts new file mode 100644 index 000000000..7f17de955 --- /dev/null +++ b/packages/cli/src/cli/lib/observer-url.ts @@ -0,0 +1,57 @@ +/** + * Observer dashboard URL construction, shared by the `observer` command group + * and the `get_observer_url` MCP tool so both produce identical links. + * + * The credential rides in the `?key=` query string because that is what the + * observer dashboard reads. That is precisely why the value must always be a + * scoped, read-only `ot_live_` token and never a workspace key: query strings + * land in browser history, referrer headers, and proxy logs. + */ + +/** Where the hosted observer dashboard lives. */ +export const DEFAULT_OBSERVER_URL = 'https://agentrelay.com/observer'; + +/** Default token lifetime for a link meant to be pasted into chat. */ +export const DEFAULT_OBSERVER_EXPIRES = '24h'; + +/** + * Resolve the observer dashboard base URL: explicit flag, then + * `RELAY_OBSERVER_URL` (for self-hosted or staging dashboards), then the + * hosted default. + * + * @param explicit - Value passed on the command line, if any + * @param env - Environment to read `RELAY_OBSERVER_URL` from + * @returns A validated absolute URL + */ +export function resolveObserverBaseUrl( + explicit: string | undefined, + env: NodeJS.ProcessEnv = process.env +): string { + const value = explicit?.trim() || env.RELAY_OBSERVER_URL?.trim() || DEFAULT_OBSERVER_URL; + try { + // Fail here rather than emitting a malformed link the caller only discovers + // after pasting it somewhere public. + new URL(value); + } catch { + throw new Error(`Invalid observer URL: ${value}`); + } + return value; +} + +/** + * Build the observer URL for a token. + * + * @param baseUrl - Observer dashboard base URL + * @param token - Scoped `ot_live_` observer token + * @returns The full observer URL + */ +export function observerUrl(baseUrl: string, token: string): string { + if (!token.startsWith('ot_live_')) { + // A workspace key in this position would be an administrative credential in + // a shareable URL — the exact failure this command exists to prevent. + throw new Error('Observer URLs require a scoped observer token (ot_live_...).'); + } + const url = new URL(baseUrl); + url.searchParams.set('key', token); + return url.toString(); +} diff --git a/packages/sdk/src/__tests__/thin-client.test.ts b/packages/sdk/src/__tests__/thin-client.test.ts index 0870e7778..4de3eaedd 100644 --- a/packages/sdk/src/__tests__/thin-client.test.ts +++ b/packages/sdk/src/__tests__/thin-client.test.ts @@ -7,6 +7,7 @@ const relaycastMocks = vi.hoisted(() => { const relayCastInstances: Array<{ config: Record; as: Mock; + observerTokens: { create: Mock; list: Mock; revoke: Mock }; agents: { list: Mock; registerOrRotate: Mock; spawn: Mock; release: Mock }; }> = []; const wsClientInstances: Array> = []; @@ -32,6 +33,19 @@ const relaycastMocks = vi.hoisted(() => { const instance = { config, as, + observerTokens: { + create: vi.fn(async (data: Record) => ({ + id: 'ot_1', + name: data.name, + scopes: data.scopes, + status: 'active', + expiresAt: data.expiresAt ?? null, + createdAt: '2026-08-03T00:00:00.000Z', + token: 'ot_live_material', + })), + list: vi.fn(async () => [{ id: 'ot_1', name: 'a', status: 'active' }]), + revoke: vi.fn(async () => undefined), + }, agents: { list: vi.fn(async () => [{ name: 'A' }]), registerOrRotate: vi.fn(async () => ({ name: 'A', token: 'at_live_a', extra_field: 1 })), @@ -67,10 +81,14 @@ vi.mock('@relaycast/sdk', async (importOriginal) => { }); import { + RELAY_OBSERVER_SCOPES, createAgentClient, + createObserverToken, createRealtimeClient, createWorkspace, createWorkspaceClient, + listObserverTokens, + revokeObserverToken, } from '../messaging/thin-client.js'; beforeEach(() => { @@ -257,3 +275,49 @@ describe('createWorkspace', () => { expect(relaycastMocks.createWorkspace).toHaveBeenCalledWith('Test', {}); }); }); + +describe('observer tokens', () => { + it('defaults to every read scope with DMs excluded', async () => { + const token = await createObserverToken({ + workspaceKey: 'rk_live_test', + name: 'observer-cli-1', + }); + + const instance = relaycastMocks.relayCastInstances[0]; + expect(instance.config).toEqual({ apiKey: 'rk_live_test' }); + const [payload] = instance.observerTokens.create.mock.calls[0] as [Record]; + expect(payload.scopes).toEqual([...RELAY_OBSERVER_SCOPES]); + // `stream:read` is what makes the token usable on the realtime endpoint. + expect(payload.scopes).toContain('stream:read'); + // Recorded explicitly rather than left to the server default, so the + // token's stored filters show the decision. + expect(payload.filters).toEqual({ includeDms: false }); + expect(token.token).toBe('ot_live_material'); + }); + + it('passes channel and DM narrowing through', async () => { + await createObserverToken({ + workspaceKey: 'rk_live_test', + name: 'observer-cli-2', + filters: { channelNames: ['general'], includeDms: true }, + expiresAt: '2026-08-04T00:00:00.000Z', + scopes: ['stream:read', 'messages:read'], + }); + + const instance = relaycastMocks.relayCastInstances[0]; + const [payload] = instance.observerTokens.create.mock.calls[0] as [Record]; + expect(payload).toMatchObject({ + scopes: ['stream:read', 'messages:read'], + filters: { includeDms: true, channelNames: ['general'] }, + expiresAt: '2026-08-04T00:00:00.000Z', + }); + }); + + it('lists and revokes through the workspace client', async () => { + await listObserverTokens({ workspaceKey: 'rk_live_test' }); + expect(relaycastMocks.relayCastInstances[0].observerTokens.list).toHaveBeenCalled(); + + await revokeObserverToken({ workspaceKey: 'rk_live_test', id: 'ot_1' }); + expect(relaycastMocks.relayCastInstances[1].observerTokens.revoke).toHaveBeenCalledWith('ot_1'); + }); +}); diff --git a/packages/sdk/src/messaging/thin-client.ts b/packages/sdk/src/messaging/thin-client.ts index 40c5971a6..1a0bd701a 100644 --- a/packages/sdk/src/messaging/thin-client.ts +++ b/packages/sdk/src/messaging/thin-client.ts @@ -183,6 +183,142 @@ export interface RelayRealtimeClientOptions extends RelaycastTelemetryOptions { baseUrl?: string; } +/** + * Read scopes granted to an observer token minted by {@link createObserverToken}. + * + * Mirrors the engine's `OBSERVER_SCOPES`. `stream:read` is what makes the token + * usable on the workspace realtime endpoint (`GET /v1/ws`) — the engine rejects + * a workspace key there, so a token without it can read REST but never streams. + */ +export const RELAY_OBSERVER_SCOPES = [ + 'stream:read', + 'messages:read', + 'threads:read', + 'dms:read', + 'channels:read', + 'search:read', + 'agents:read', + 'nodes:read', + 'deliveries:read', + 'activity:read', + 'files:read', + 'reactions:read', +] as const; + +export type RelayObserverScope = (typeof RELAY_OBSERVER_SCOPES)[number]; + +/** Visibility filters narrowing what an observer token can see. */ +export interface RelayObserverTokenFilters { + /** Restrict to these channel names. Omit for every channel in the workspace. */ + channelNames?: string[]; + /** Include agent DM traffic. Off unless explicitly requested. */ + includeDms?: boolean; + /** Restrict to events involving these agent ids. */ + agentIds?: string[]; +} + +export interface RelayCreateObserverTokenOptions extends RelaycastTelemetryOptions { + /** Workspace key (`rk_live_...`) — only a workspace key may mint observer tokens. */ + workspaceKey: string; + /** Token name. Must be unique within the workspace. */ + name: string; + description?: string; + /** Defaults to every read scope in {@link RELAY_OBSERVER_SCOPES}. */ + scopes?: readonly RelayObserverScope[]; + filters?: RelayObserverTokenFilters; + /** ISO-8601 expiry. The engine rejects a timestamp in the past. */ + expiresAt?: string; + baseUrl?: string; +} + +/** Observer token metadata. `token` is present only on the create response. */ +export interface RelayObserverToken { + id: string; + name: string; + scopes: RelayObserverScope[]; + status: string; + expiresAt: string | null; + createdAt: string; + lastUsedAt?: string | null; + /** Raw `ot_live_...` material. Returned once, at creation, and never again. */ + token?: string; +} + +/** + * The observer-token slice of the workspace client. Declared structurally so + * tests can substitute a fake without standing up a `RelayCast` instance. + */ +type ObserverTokenClient = { + observerTokens: { + create(data: Record): Promise; + list(): Promise; + revoke(id: string): Promise; + }; +}; + +function observerTokenClient( + workspaceKey: string, + options: { baseUrl?: string } & RelaycastTelemetryOptions +): ObserverTokenClient { + return new RelayCast(clientConfig(workspaceKey, options)) as unknown as ObserverTokenClient; +} + +/** + * Mint a scoped, read-only observer token for a workspace. + * + * This is the supported way to let a human follow a workspace without handing + * out the workspace key: the returned `ot_live_...` is accepted everywhere the + * observer dashboard accepts a credential, but it cannot send messages, spawn + * agents, or administer the workspace. + * + * @param options - Workspace key, token name, and optional scope/filter/expiry narrowing + * @returns The created token, including the raw material in `token` + */ +export async function createObserverToken( + options: RelayCreateObserverTokenOptions +): Promise { + const client = observerTokenClient(options.workspaceKey, options); + return client.observerTokens.create({ + name: options.name, + ...(options.description === undefined ? {} : { description: options.description }), + scopes: [...(options.scopes ?? RELAY_OBSERVER_SCOPES)], + // `include_dms` defaults to false server-side, but send it explicitly so the + // token's stored filters record the decision rather than an absence. + filters: { + includeDms: options.filters?.includeDms === true, + ...(options.filters?.channelNames?.length + ? { channelNames: options.filters.channelNames } + : {}), + ...(options.filters?.agentIds?.length ? { agentIds: options.filters.agentIds } : {}), + }, + ...(options.expiresAt === undefined ? {} : { expiresAt: options.expiresAt }), + }); +} + +/** + * List observer-token metadata for a workspace. Raw token material is never + * returned — only the creating call ever sees it. + * + * @param options - Workspace key, optional base URL and telemetry overrides + * @returns Token metadata, without `token` + */ +export async function listObserverTokens( + options: { workspaceKey: string; baseUrl?: string } & RelaycastTelemetryOptions +): Promise { + return observerTokenClient(options.workspaceKey, options).observerTokens.list(); +} + +/** + * Revoke an observer token by id. The token stops working immediately. + * + * @param options - Workspace key, token id, optional base URL and telemetry overrides + */ +export async function revokeObserverToken( + options: { workspaceKey: string; id: string; baseUrl?: string } & RelaycastTelemetryOptions +): Promise { + await observerTokenClient(options.workspaceKey, options).observerTokens.revoke(options.id); +} + export interface RelayCreateWorkspaceOptions extends Omit { baseUrl?: string; } diff --git a/plugins/codex-relay-skill/SKILL.md b/plugins/codex-relay-skill/SKILL.md index 7d172597d..3ecce4de1 100644 --- a/plugins/codex-relay-skill/SKILL.md +++ b/plugins/codex-relay-skill/SKILL.md @@ -30,11 +30,13 @@ Every relay-connected Codex agent must complete these steps IN ORDER before subs 2. **Register as an agent.** Call `register_agent` with your agent name and `type: "agent"`. Use `RELAY_AGENT_NAME` from the environment if set, otherwise derive a name from the task context (e.g., `lead`, `auth-worker`). 3. **Keep workspace credentials out of output.** Never print the workspace key - or construct an observer URL from it. If the user asks to follow the - conversation, explain that observation requires a separately provisioned, - read-only observer token (`ot_live_...`) delivered through an explicit - secret handoff. Do not print the token or place it in a URL query string; - when no scoped observer token is available, omit the observer link. + or construct an observer URL from it — it is an administrative credential. + When the user asks to follow the conversation, run `agent-relay observer` + and print the URL it returns. That mints a scoped, read-only token + (`ot_live_...`) that expires in 24 hours and excludes agent DMs, so the link + is safe to share. Narrow it further with `--channels`, widen it with + `--include-dms` or `--expires`, and revoke it with + `agent-relay observer revoke `. 4. **Check the relay inbox.** Call `check_inbox` to see if there are any pending messages or task assignments. diff --git a/plugins/gemini-relay-extension/GEMINI.md b/plugins/gemini-relay-extension/GEMINI.md index a6a11296b..10721bc21 100644 --- a/plugins/gemini-relay-extension/GEMINI.md +++ b/plugins/gemini-relay-extension/GEMINI.md @@ -8,10 +8,12 @@ Never print a workspace key or construct an observer URL from one. Workspace keys have administrative authority and do not belong in terminal transcripts or URL query strings. -If the user asks to follow the conversation, explain that observation requires -a separately provisioned, read-only observer token (`ot_live_...`). The token -must be delivered through an explicit secret handoff, not printed by the agent. -When no scoped observer token has been provisioned, omit the observer link. +When the user asks to follow the conversation, run `agent-relay observer` and +print the URL it returns. It mints a scoped, read-only observer token +(`ot_live_...`) — expiring in 24 hours and excluding agent DMs by default — and +builds the link from that instead of the workspace key. Use `--channels` to +narrow the view, `--include-dms` or `--expires` to widen it, and +`agent-relay observer revoke ` to cut it off early. ## Delegating to Sub-Agents From fcaacb074a195468769a0b2ab0762f567b5c0fd2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 3 Aug 2026 14:02:46 +0000 Subject: [PATCH 2/2] style: auto-format with Prettier --- packages/cli/src/cli/agent-relay-mcp.ts | 7 ++++- .../cli/src/cli/commands/observer.test.ts | 14 ++++------ packages/cli/src/cli/commands/observer.ts | 28 +++++++++++++------ packages/sdk/src/messaging/thin-client.ts | 4 +-- 4 files changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/cli/agent-relay-mcp.ts b/packages/cli/src/cli/agent-relay-mcp.ts index b2ad8a95e..a85ab3cd2 100644 --- a/packages/cli/src/cli/agent-relay-mcp.ts +++ b/packages/cli/src/cli/agent-relay-mcp.ts @@ -606,7 +606,12 @@ function registerAgentRelayTools( .describe('Token lifetime in hours. Defaults to 24.'), }, outputSchema: jsonResult, - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, }, async ({ channels, include_dms, expires_in_hours }: any) => { const session = getSession(); diff --git a/packages/cli/src/cli/commands/observer.test.ts b/packages/cli/src/cli/commands/observer.test.ts index bf1423ed4..0f22a1bf1 100644 --- a/packages/cli/src/cli/commands/observer.test.ts +++ b/packages/cli/src/cli/commands/observer.test.ts @@ -93,9 +93,9 @@ describe('agent-relay observer', () => { it('rejects a bare-number expiry rather than guessing a unit', async () => { const { program } = setup(); - await expect( - program.parseAsync(['observer', '--expires', '24'], { from: 'user' }) - ).rejects.toThrow(/30m, 24h, or 7d/); + await expect(program.parseAsync(['observer', '--expires', '24'], { from: 'user' })).rejects.toThrow( + /30m, 24h, or 7d/ + ); }); it('fails loudly when the engine returns no token material', async () => { @@ -103,17 +103,13 @@ describe('agent-relay observer', () => { createObserverToken: vi.fn(async () => createdToken({ token: undefined })) as never, }); - await expect(program.parseAsync(['observer'], { from: 'user' })).rejects.toBeInstanceOf( - ExitSignal - ); + await expect(program.parseAsync(['observer'], { from: 'user' })).rejects.toBeInstanceOf(ExitSignal); expect(errors.join('\n')).toContain('did not include token material'); }); it('list never prints token material', async () => { const { program, logs } = setup({ - listObserverTokens: vi.fn(async () => [ - createdToken({ token: undefined, lastUsedAt: null }), - ]) as never, + listObserverTokens: vi.fn(async () => [createdToken({ token: undefined, lastUsedAt: null })]) as never, }); await program.parseAsync(['observer', 'list'], { from: 'user' }); diff --git a/packages/cli/src/cli/commands/observer.ts b/packages/cli/src/cli/commands/observer.ts index 712cce44f..ca6a1137d 100644 --- a/packages/cli/src/cli/commands/observer.ts +++ b/packages/cli/src/cli/commands/observer.ts @@ -128,19 +128,25 @@ export function registerObserverCommands( .description('Mint a read-only observer link so a human can follow this workspace'); group - .option('--workspace-key ', 'Workspace key (defaults to RELAY_WORKSPACE_KEY or the active workspace)') + .option( + '--workspace-key ', + 'Workspace key (defaults to RELAY_WORKSPACE_KEY or the active workspace)' + ) .option('--base-url ', 'Override the engine API base URL (defaults to RELAY_BASE_URL)') .option('--observer-url ', `Observer dashboard URL (defaults to ${DEFAULT_OBSERVER_URL})`) .option('--name ', 'Token name (defaults to a generated unique name)') .option('--channels ', 'Restrict to a comma-separated list of channels', parseChannels) .option('--include-dms', 'Include agent DM traffic (excluded by default)') - .option('--expires ', `Token lifetime, e.g. 30m, 24h, 7d (default ${DEFAULT_OBSERVER_EXPIRES})`, parseDuration) + .option( + '--expires ', + `Token lifetime, e.g. 30m, 24h, 7d (default ${DEFAULT_OBSERVER_EXPIRES})`, + parseDuration + ) .option('--json', 'Output the token metadata and URL as JSON') .action(async (options: Record) => { await runSdk(deps, async () => { const lifetimeMs = (options.expires as number | undefined) ?? parseDuration(DEFAULT_OBSERVER_EXPIRES); - const name = - (options.name as string | undefined)?.trim() || `observer-cli-${deps.randomSuffix()}`; + const name = (options.name as string | undefined)?.trim() || `observer-cli-${deps.randomSuffix()}`; const token = await deps.createObserverToken({ ...connection(options), @@ -171,9 +177,7 @@ export function registerObserverCommands( deps.log(''); deps.log(`Read-only. Expires ${token.expiresAt ?? 'never'}.`); deps.log( - options.includeDms === true - ? 'Includes agent DMs.' - : 'Channels only — agent DMs are excluded.' + options.includeDms === true ? 'Includes agent DMs.' : 'Channels only — agent DMs are excluded.' ); deps.log(`Revoke with: agent-relay observer revoke ${token.id}`); }); @@ -182,7 +186,10 @@ export function registerObserverCommands( group .command('list') .description('List observer tokens for this workspace (token material is never shown)') - .option('--workspace-key ', 'Workspace key (defaults to RELAY_WORKSPACE_KEY or the active workspace)') + .option( + '--workspace-key ', + 'Workspace key (defaults to RELAY_WORKSPACE_KEY or the active workspace)' + ) .option('--base-url ', 'Override the engine API base URL') .option('--json', 'Output as JSON') .action(async (options: Record) => { @@ -208,7 +215,10 @@ export function registerObserverCommands( .command('revoke') .description('Revoke an observer token by id') .argument('', 'Observer token id') - .option('--workspace-key ', 'Workspace key (defaults to RELAY_WORKSPACE_KEY or the active workspace)') + .option( + '--workspace-key ', + 'Workspace key (defaults to RELAY_WORKSPACE_KEY or the active workspace)' + ) .option('--base-url ', 'Override the engine API base URL') .action(async (id: string, options: Record) => { await runSdk(deps, async () => { diff --git a/packages/sdk/src/messaging/thin-client.ts b/packages/sdk/src/messaging/thin-client.ts index 1a0bd701a..facf094e8 100644 --- a/packages/sdk/src/messaging/thin-client.ts +++ b/packages/sdk/src/messaging/thin-client.ts @@ -286,9 +286,7 @@ export async function createObserverToken( // token's stored filters record the decision rather than an absence. filters: { includeDms: options.filters?.includeDms === true, - ...(options.filters?.channelNames?.length - ? { channelNames: options.filters.channelNames } - : {}), + ...(options.filters?.channelNames?.length ? { channelNames: options.filters.channelNames } : {}), ...(options.filters?.agentIds?.length ? { agentIds: options.filters.agentIds } : {}), }, ...(options.expiresAt === undefined ? {} : { expiresAt: options.expiresAt }),