From 28a6e9a4923bbd1b6c0708c4b8664bf0cf463c1b Mon Sep 17 00:00:00 2001 From: relay Date: Thu, 30 Jul 2026 22:34:08 -0400 Subject: [PATCH 1/4] fix(plugins): stop exposing workspace keys in observer links --- .agents/skills/using-agent-relay/SKILL.md | 7 +- .claude/skills/using-agent-relay/SKILL.md | 7 +- CHANGELOG.md | 4 ++ .../src/cli/plugin-credential-safety.test.ts | 71 +++++++++++++++++++ plugins/codex-relay-skill/SKILL.md | 7 +- plugins/gemini-relay-extension/GEMINI.md | 15 ++-- .../commands/fanout/fanout.toml | 2 +- .../commands/status/status.toml | 4 +- .../commands/team/team.toml | 2 +- .../hooks/session-start.sh | 17 ++--- 10 files changed, 112 insertions(+), 24 deletions(-) create mode 100644 packages/cli/src/cli/plugin-credential-safety.test.ts diff --git a/.agents/skills/using-agent-relay/SKILL.md b/.agents/skills/using-agent-relay/SKILL.md index 296741fe9..9301e9018 100644 --- a/.agents/skills/using-agent-relay/SKILL.md +++ b/.agents/skills/using-agent-relay/SKILL.md @@ -189,13 +189,16 @@ remove_agent(name: "reviewer-1", reason: "Review accepted") ## Current CLI Reference +Startup and status commands are intentionally omitted from these agent-facing +examples. Published Agent Relay versions through 11.3.0 can print live +workspace credentials when those commands run in a transcribed session. Upgrade +to a patched release before running them there. + These are the current CLI forms for local broker and SDK-backed messaging operations: ```bash agent-relay status -agent-relay node up --verbose -agent-relay node status --wait-for 10 agent-relay node agent list agent-relay node agent spawn claude --name Worker --task "Use https://agentrelay.com/skill and ACK over Relay." agent-relay node tail --agent Worker diff --git a/.claude/skills/using-agent-relay/SKILL.md b/.claude/skills/using-agent-relay/SKILL.md index 296741fe9..9301e9018 100644 --- a/.claude/skills/using-agent-relay/SKILL.md +++ b/.claude/skills/using-agent-relay/SKILL.md @@ -189,13 +189,16 @@ remove_agent(name: "reviewer-1", reason: "Review accepted") ## Current CLI Reference +Startup and status commands are intentionally omitted from these agent-facing +examples. Published Agent Relay versions through 11.3.0 can print live +workspace credentials when those commands run in a transcribed session. Upgrade +to a patched release before running them there. + These are the current CLI forms for local broker and SDK-backed messaging operations: ```bash agent-relay status -agent-relay node up --verbose -agent-relay node status --wait-for 10 agent-relay node agent list agent-relay node agent spawn claude --name Worker --task "Use https://agentrelay.com/skill and ACK over Relay." agent-relay node tail --agent Worker diff --git a/CHANGELOG.md b/CHANGELOG.md index ab1518025..8dc76ba82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `agent-relay integration webhook create` now works. It took a `` 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 `` 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. +### Security + +- Bundled Gemini and Codex relay instructions and hooks no longer expose workspace administration keys in observer URLs or terminal transcripts; observation now requires a separately provisioned, read-only observer token. + ## [11.3.0] - 2026-07-30 ### Changed diff --git a/packages/cli/src/cli/plugin-credential-safety.test.ts b/packages/cli/src/cli/plugin-credential-safety.test.ts new file mode 100644 index 000000000..a1067bfd6 --- /dev/null +++ b/packages/cli/src/cli/plugin-credential-safety.test.ts @@ -0,0 +1,71 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..'); + +const observerInstructionFiles = [ + 'plugins/gemini-relay-extension/GEMINI.md', + 'plugins/gemini-relay-extension/commands/status/status.toml', + 'plugins/gemini-relay-extension/commands/team/team.toml', + 'plugins/gemini-relay-extension/commands/fanout/fanout.toml', + 'plugins/gemini-relay-extension/hooks/session-start.sh', + 'plugins/codex-relay-skill/SKILL.md', +] as const; + +const agentFacingSkillFiles = [ + '.agents/skills/using-agent-relay/SKILL.md', + '.claude/skills/using-agent-relay/SKILL.md', +] as const; + +function readRepoFile(path: string): string { + return readFileSync(join(repoRoot, path), 'utf8'); +} + +function listTrackedPluginFiles(): string[] { + return execFileSync( + 'git', + ['ls-files', '-z', 'plugins/gemini-relay-extension', 'plugins/codex-relay-skill'], + { cwd: repoRoot, encoding: 'utf8' } + ) + .split('\0') + .filter(Boolean); +} + +describe('shipped relay plugin credential safety', () => { + it('does not construct observer URLs from workspace keys in shipped plugin assets', () => { + for (const path of listTrackedPluginFiles()) { + const source = readRepoFile(path); + expect(source, path).not.toMatch(/agentrelay\.com\/observer\?key=/i); + } + }); + + it('does not instruct agents to print real workspace keys or observer links', () => { + for (const path of observerInstructionFiles) { + const source = readRepoFile(path); + expect(source, path).not.toMatch(/\b(?:actual key|real clickable URL)\b/i); + expect(source, path).not.toMatch(/\bprint the observer URL\b/i); + + const credentialPrintLines = source + .split('\n') + .filter((line) => /\bprint\b/i.test(line) && /(?:workspace key|observer URL|ot_live_)/i.test(line)); + + for (const line of credentialPrintLines) { + expect(line, `${path}: ${line}`).toMatch(/\b(?:never|do not)\b[^\n]*\bprint\b/i); + } + } + + const sessionStartHook = readRepoFile('plugins/gemini-relay-extension/hooks/session-start.sh'); + expect(sessionStartHook).not.toMatch(/\bWORKSPACE_KEY\s*=/); + expect(sessionStartHook).not.toMatch(/\$\{?WORKSPACE_KEY\b/); + }); + + it('keeps credential-printing startup commands out of mirrored agent-facing skills', () => { + const [agentsSkill, claudeSkill] = agentFacingSkillFiles.map(readRepoFile); + + expect(agentsSkill).toBe(claudeSkill); + expect(agentsSkill).not.toMatch(/\bagent-relay node (?:up|status)\b/); + }); +}); diff --git a/plugins/codex-relay-skill/SKILL.md b/plugins/codex-relay-skill/SKILL.md index a82956350..7d172597d 100644 --- a/plugins/codex-relay-skill/SKILL.md +++ b/plugins/codex-relay-skill/SKILL.md @@ -29,7 +29,12 @@ 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. **Tell the user** they can follow the conversation live at `https://agentrelay.com/observer?key=` (use the workspace key from step 1). This lets them watch all agent messages in real time. +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. 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 7915d2eb2..a6a11296b 100644 --- a/plugins/gemini-relay-extension/GEMINI.md +++ b/plugins/gemini-relay-extension/GEMINI.md @@ -2,15 +2,16 @@ This extension lets your Gemini CLI session communicate with other agents in real time via Agent Relay. A workspace is auto-created on first use — no configuration needed. -## Observer URL +## Safe observation -IMPORTANT: Before delegating any work to sub-agents, always print the observer URL so the user can watch the conversation in real time. Read the workspace key from the RELAY_API_KEY environment variable, or if empty read `~/.relay/workspace-key`, then print: +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. -``` -Follow along at: https://agentrelay.com/observer?key= -``` - -Do not print a placeholder — print the real clickable URL with the actual key substituted in. This is mandatory every time you coordinate agents. +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. ## Delegating to Sub-Agents diff --git a/plugins/gemini-relay-extension/commands/fanout/fanout.toml b/plugins/gemini-relay-extension/commands/fanout/fanout.toml index ad5e34ebe..adcd9f4f9 100644 --- a/plugins/gemini-relay-extension/commands/fanout/fanout.toml +++ b/plugins/gemini-relay-extension/commands/fanout/fanout.toml @@ -3,7 +3,7 @@ prompt = """Run a fan-out relay workflow for: {{args}} Use fan-out only when the work can be split into independent subtasks. Follow this protocol: -1. Read the workspace key from the RELAY_API_KEY environment variable, or if empty read ~/.relay/workspace-key. Print the observer URL so the user can follow along: https://agentrelay.com/observer?key=. This is mandatory. +1. Do not read or print the workspace key and do not construct an observer URL from it. If the user asks to follow along, explain that observation requires a separately provisioned, read-only observer token delivered through an explicit secret handoff. If none is available, omit the observer link. 2. Break the task into parallel subtasks with minimal overlap (max 5) 3. Delegate each subtask to a @relay-worker sub-agent. Give each a clear, bounded task description. Tell each worker your agent name so they can send ACK/DONE messages back to you via mcp_agent_relay_send_dm. 4. Monitor progress with mcp_agent_relay_check_inbox and answer worker questions diff --git a/plugins/gemini-relay-extension/commands/status/status.toml b/plugins/gemini-relay-extension/commands/status/status.toml index f5536ff17..38e4c905e 100644 --- a/plugins/gemini-relay-extension/commands/status/status.toml +++ b/plugins/gemini-relay-extension/commands/status/status.toml @@ -1,5 +1,5 @@ prompt = """Check the relay status: -1. Read the workspace key from the RELAY_API_KEY environment variable, or if empty read ~/.relay/workspace-key. Print the observer URL: https://agentrelay.com/observer?key=. Do not print a placeholder — print the real clickable URL. +1. Do not read or print the workspace key and do not construct an observer URL from it. Observation requires a separately provisioned, read-only observer token delivered through an explicit secret handoff. If none is available, omit the observer link. 2. Call mcp_agent_relay_list_agents to see who's online 3. Call mcp_agent_relay_check_inbox to see unread messages -4. Report a summary of the observer URL, agents, and any pending messages""" +4. Report a summary of the agents and any pending messages""" diff --git a/plugins/gemini-relay-extension/commands/team/team.toml b/plugins/gemini-relay-extension/commands/team/team.toml index 5d546701f..349539167 100644 --- a/plugins/gemini-relay-extension/commands/team/team.toml +++ b/plugins/gemini-relay-extension/commands/team/team.toml @@ -1,7 +1,7 @@ prompt = """Spawn a coordinated team of relay agents for: {{args}} Follow this protocol: -1. Read the workspace key from the RELAY_API_KEY environment variable, or if empty read ~/.relay/workspace-key. Print the observer URL so the user can follow along: https://agentrelay.com/observer?key=. This is mandatory. +1. Do not read or print the workspace key and do not construct an observer URL from it. If the user asks to follow along, explain that observation requires a separately provisioned, read-only observer token delivered through an explicit secret handoff. If none is available, omit the observer link. 2. Analyze the task and choose the simplest coordination pattern that fits it 3. Determine how many workers are needed (max 5) and assign each a bounded responsibility 4. Choose the right sub-agent type for each task: diff --git a/plugins/gemini-relay-extension/hooks/session-start.sh b/plugins/gemini-relay-extension/hooks/session-start.sh index d11bfa42b..9b9950fcc 100755 --- a/plugins/gemini-relay-extension/hooks/session-start.sh +++ b/plugins/gemini-relay-extension/hooks/session-start.sh @@ -21,10 +21,11 @@ load_env() { load_env -# Resolve workspace key: env > persisted key file -WORKSPACE_KEY="${RELAY_API_KEY:-}" -if [ -z "$WORKSPACE_KEY" ] && [ -s "$KEY_FILE" ]; then - WORKSPACE_KEY=$(cat "$KEY_FILE" 2>/dev/null || true) +# Track whether Relay is configured without reading credential material into +# this hook. Workspace keys must never be placed in injected context. +WORKSPACE_CONFIGURED=0 +if [ -n "${RELAY_API_KEY:-}" ] || [ -s "$KEY_FILE" ]; then + WORKSPACE_CONFIGURED=1 fi TOKEN="" @@ -39,13 +40,13 @@ if [ -f "$STATE_FILE" ] && command -v jq >/dev/null 2>&1; then fi if [ -n "${TOKEN:-}" ] && [ -n "${AGENT_NAME:-}" ]; then - if [ -n "${WORKSPACE_KEY:-}" ]; then - CONTEXT=$(printf 'Relaycast is connected as %s. Use the Agent Relay MCP tools for DMs, channels, inbox checks, and worker coordination. Follow the ACK/DONE protocol: acknowledge new assignments promptly, and send DONE when the task is complete. To spawn workers, use run_shell_command with: RELAY_AGENT_NAME=WorkerName gemini -y -i "task prompt" &. The user can observe agent conversations at: https://agentrelay.com/observer?key=%s' "$AGENT_NAME" "$WORKSPACE_KEY") + if [ "$WORKSPACE_CONFIGURED" -eq 1 ]; then + CONTEXT=$(printf 'Relaycast is connected as %s. Use the Agent Relay MCP tools for DMs, channels, inbox checks, and worker coordination. Follow the ACK/DONE protocol: acknowledge new assignments promptly, and send DONE when the task is complete. To spawn workers, use run_shell_command with: RELAY_AGENT_NAME=WorkerName gemini -y -i "task prompt" &. Never print the workspace key or construct an observer URL from it. Observation requires a separately provisioned, read-only observer token delivered through an explicit secret handoff.' "$AGENT_NAME") else CONTEXT=$(printf 'Relaycast is connected as %s. Use the Agent Relay MCP tools for DMs, channels, inbox checks, and worker coordination. Follow the ACK/DONE protocol: acknowledge new assignments promptly, and send DONE when the task is complete. To spawn workers, use run_shell_command with: RELAY_AGENT_NAME=WorkerName gemini -y -i "task prompt" &.' "$AGENT_NAME") fi -elif [ -n "${WORKSPACE_KEY:-}" ]; then - CONTEXT=$(printf 'Relaycast workspace key is configured. If the relay tools report "Not registered", call the register tool with your exact agent name before using messaging tools. The user can observe agent conversations at: https://agentrelay.com/observer?key=%s' "$WORKSPACE_KEY") +elif [ "$WORKSPACE_CONFIGURED" -eq 1 ]; then + CONTEXT='Relaycast workspace key is configured. If the relay tools report "Not registered", call the register tool with your exact agent name before using messaging tools. Never print the workspace key or construct an observer URL from it. Observation requires a separately provisioned, read-only observer token delivered through an explicit secret handoff.' else CONTEXT='Relaycast is connected. A workspace was auto-created. Use the Agent Relay MCP tools for messaging and worker coordination.' fi From fb95460a16fddda70ad1ed027225c21bcdfb6a2e Mon Sep 17 00:00:00 2001 From: relay Date: Thu, 30 Jul 2026 22:39:10 -0400 Subject: [PATCH 2/4] test(plugins): avoid git ownership dependency --- .../src/cli/plugin-credential-safety.test.ts | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/cli/plugin-credential-safety.test.ts b/packages/cli/src/cli/plugin-credential-safety.test.ts index a1067bfd6..520470dfb 100644 --- a/packages/cli/src/cli/plugin-credential-safety.test.ts +++ b/packages/cli/src/cli/plugin-credential-safety.test.ts @@ -1,5 +1,4 @@ -import { execFileSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; @@ -24,20 +23,24 @@ function readRepoFile(path: string): string { return readFileSync(join(repoRoot, path), 'utf8'); } -function listTrackedPluginFiles(): string[] { - return execFileSync( - 'git', - ['ls-files', '-z', 'plugins/gemini-relay-extension', 'plugins/codex-relay-skill'], - { cwd: repoRoot, encoding: 'utf8' } - ) - .split('\0') - .filter(Boolean); +function listPluginFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + if (entry.name.startsWith('.') || entry.name === 'node_modules') return []; + + const path = join(directory, entry.name); + return entry.isDirectory() ? listPluginFiles(path) : [path]; + }); } describe('shipped relay plugin credential safety', () => { it('does not construct observer URLs from workspace keys in shipped plugin assets', () => { - for (const path of listTrackedPluginFiles()) { - const source = readRepoFile(path); + const pluginFiles = [ + ...listPluginFiles(join(repoRoot, 'plugins/gemini-relay-extension')), + ...listPluginFiles(join(repoRoot, 'plugins/codex-relay-skill')), + ]; + + for (const path of pluginFiles) { + const source = readFileSync(path, 'utf8'); expect(source, path).not.toMatch(/agentrelay\.com\/observer\?key=/i); } }); From 4b3c252d310dd33d86b3e14063bb74dc785865af Mon Sep 17 00:00:00 2001 From: relay Date: Sun, 2 Aug 2026 07:40:49 -0400 Subject: [PATCH 3/4] docs(changelog): keep observer safety unreleased --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dc76ba82..d37827116 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `agent-relay cloud whoami` prints the organization and workspace IDs alongside their names. +### Security + +- Bundled Gemini and Codex relay instructions and hooks no longer expose workspace administration keys in observer URLs or terminal transcripts; observation now requires a separately provisioned, read-only observer token. + ## [11.3.1] - 2026-07-31 ### Fixed @@ -24,10 +28,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `agent-relay integration webhook create` now works. It took a `` 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 `` 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. -### Security - -- Bundled Gemini and Codex relay instructions and hooks no longer expose workspace administration keys in observer URLs or terminal transcripts; observation now requires a separately provisioned, read-only observer token. - ## [11.3.0] - 2026-07-30 ### Changed From 3e40345c91cfcd772caf14f82c2a97e7f9b9a6a5 Mon Sep 17 00:00:00 2001 From: relay Date: Sun, 2 Aug 2026 07:51:37 -0400 Subject: [PATCH 4/4] test(cli): cover all plugin credentials --- .agents/skills/using-agent-relay/SKILL.md | 2 +- .claude/skills/using-agent-relay/SKILL.md | 2 +- .../src/cli/plugin-credential-safety.test.ts | 20 +++++-------------- 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/.agents/skills/using-agent-relay/SKILL.md b/.agents/skills/using-agent-relay/SKILL.md index 9301e9018..e7a76f730 100644 --- a/.agents/skills/using-agent-relay/SKILL.md +++ b/.agents/skills/using-agent-relay/SKILL.md @@ -192,7 +192,7 @@ remove_agent(name: "reviewer-1", reason: "Review accepted") Startup and status commands are intentionally omitted from these agent-facing examples. Published Agent Relay versions through 11.3.0 can print live workspace credentials when those commands run in a transcribed session. Upgrade -to a patched release before running them there. +to Agent Relay 11.3.1 or later before running them there. These are the current CLI forms for local broker and SDK-backed messaging operations: diff --git a/.claude/skills/using-agent-relay/SKILL.md b/.claude/skills/using-agent-relay/SKILL.md index 9301e9018..e7a76f730 100644 --- a/.claude/skills/using-agent-relay/SKILL.md +++ b/.claude/skills/using-agent-relay/SKILL.md @@ -192,7 +192,7 @@ remove_agent(name: "reviewer-1", reason: "Review accepted") Startup and status commands are intentionally omitted from these agent-facing examples. Published Agent Relay versions through 11.3.0 can print live workspace credentials when those commands run in a transcribed session. Upgrade -to a patched release before running them there. +to Agent Relay 11.3.1 or later before running them there. These are the current CLI forms for local broker and SDK-backed messaging operations: diff --git a/packages/cli/src/cli/plugin-credential-safety.test.ts b/packages/cli/src/cli/plugin-credential-safety.test.ts index 520470dfb..9383a030d 100644 --- a/packages/cli/src/cli/plugin-credential-safety.test.ts +++ b/packages/cli/src/cli/plugin-credential-safety.test.ts @@ -5,15 +5,6 @@ import { describe, expect, it } from 'vitest'; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..'); -const observerInstructionFiles = [ - 'plugins/gemini-relay-extension/GEMINI.md', - 'plugins/gemini-relay-extension/commands/status/status.toml', - 'plugins/gemini-relay-extension/commands/team/team.toml', - 'plugins/gemini-relay-extension/commands/fanout/fanout.toml', - 'plugins/gemini-relay-extension/hooks/session-start.sh', - 'plugins/codex-relay-skill/SKILL.md', -] as const; - const agentFacingSkillFiles = [ '.agents/skills/using-agent-relay/SKILL.md', '.claude/skills/using-agent-relay/SKILL.md', @@ -34,10 +25,7 @@ function listPluginFiles(directory: string): string[] { describe('shipped relay plugin credential safety', () => { it('does not construct observer URLs from workspace keys in shipped plugin assets', () => { - const pluginFiles = [ - ...listPluginFiles(join(repoRoot, 'plugins/gemini-relay-extension')), - ...listPluginFiles(join(repoRoot, 'plugins/codex-relay-skill')), - ]; + const pluginFiles = listPluginFiles(join(repoRoot, 'plugins')); for (const path of pluginFiles) { const source = readFileSync(path, 'utf8'); @@ -46,8 +34,10 @@ describe('shipped relay plugin credential safety', () => { }); it('does not instruct agents to print real workspace keys or observer links', () => { - for (const path of observerInstructionFiles) { - const source = readRepoFile(path); + const pluginFiles = listPluginFiles(join(repoRoot, 'plugins')); + + for (const path of pluginFiles) { + const source = readFileSync(path, 'utf8'); expect(source, path).not.toMatch(/\b(?:actual key|real clickable URL)\b/i); expect(source, path).not.toMatch(/\bprint the observer URL\b/i);