From c2ee9b0be8aeeadedffc8c6aaa9f5f291283fea5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 21 Jun 2026 18:01:22 +0200 Subject: [PATCH] fix(gateway): preserve owner MCP tools for agent RPC --- extensions/anthropic/cli-shared.test.ts | 8 ++ src/agents/cli-runner/bundle-mcp-claude.ts | 43 ++++++++++ .../cli-runner/bundle-mcp.test-support.ts | 2 + src/agents/cli-runner/bundle-mcp.test.ts | 54 ++++++++++--- src/agents/cli-runner/bundle-mcp.ts | 46 ++++++++++- src/agents/cli-runner/claude-live-session.ts | 80 +++++++++++-------- src/agents/cli-runner/execute.ts | 1 + src/agents/cli-runner/prepare.test.ts | 1 + src/gateway/live-agent-probes.test.ts | 21 +++-- src/gateway/live-agent-probes.ts | 16 ++-- src/gateway/mcp-http.loopback-runtime.ts | 1 + src/gateway/mcp-http.test.ts | 6 +- src/gateway/server-methods/agent.test.ts | 38 +++++++++ src/gateway/server-methods/agent.ts | 1 + 14 files changed, 257 insertions(+), 61 deletions(-) diff --git a/extensions/anthropic/cli-shared.test.ts b/extensions/anthropic/cli-shared.test.ts index 8677e5756a3..6134ba7ef83 100644 --- a/extensions/anthropic/cli-shared.test.ts +++ b/extensions/anthropic/cli-shared.test.ts @@ -10,6 +10,12 @@ import { resolveClaudeCliExecutionArgs, } from "./cli-shared.js"; +function expectDefaultDisallowedTools(args: readonly string[] | undefined) { + const disallowedIndex = args?.indexOf("--disallowedTools") ?? -1; + expect(disallowedIndex).toBeGreaterThanOrEqual(0); + expect(args?.[disallowedIndex + 1]).toBe("ScheduleWakeup,CronCreate"); +} + describe("normalizeClaudePermissionArgs", () => { it("leaves args alone when they omit permission flags", () => { expect( @@ -356,8 +362,10 @@ describe("normalizeClaudeBackendConfig", () => { expect(backend.config.input).toBe("stdin"); expect(backend.config.args).toContain("--setting-sources"); expect(backend.config.args).toContain("user"); + expectDefaultDisallowedTools(backend.config.args); expect(backend.config.resumeArgs).toContain("--setting-sources"); expect(backend.config.resumeArgs).toContain("user"); + expectDefaultDisallowedTools(backend.config.resumeArgs); expect(backend.config.clearEnv).toEqual([...CLAUDE_CLI_CLEAR_ENV]); expect(backend.config.clearEnv).toContain("ANTHROPIC_API_TOKEN"); expect(backend.config.clearEnv).toContain("ANTHROPIC_BASE_URL"); diff --git a/src/agents/cli-runner/bundle-mcp-claude.ts b/src/agents/cli-runner/bundle-mcp-claude.ts index 0d79cca19f6..49df9a958f9 100644 --- a/src/agents/cli-runner/bundle-mcp-claude.ts +++ b/src/agents/cli-runner/bundle-mcp-claude.ts @@ -1,6 +1,7 @@ /** * Claude CLI argument helpers for OpenClaw-managed bundle MCP config. */ +import fs from "node:fs/promises"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; /** Find an existing Claude `--mcp-config` argument value. */ @@ -43,3 +44,45 @@ export function injectClaudeMcpConfigArgs( next.push("--strict-mcp-config", "--mcp-config", mcpConfigPath); return next; } + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Writes the active per-attempt capture token into OpenClaw's generated Claude MCP config. */ +export async function writeClaudeMcpCaptureConfig(params: { + mcpConfigPath: string; + captureKey: string; +}): Promise { + const raw = JSON.parse(await fs.readFile(params.mcpConfigPath, "utf-8")) as unknown; + if (!isRecord(raw)) { + throw new Error("Claude MCP capture requires an object config"); + } + const mcpServers = isRecord(raw.mcpServers) ? raw.mcpServers : {}; + const openclaw = isRecord(mcpServers.openclaw) ? mcpServers.openclaw : undefined; + if (!openclaw) { + throw new Error("Claude MCP capture requires an openclaw server config"); + } + const headers = isRecord(openclaw.headers) ? openclaw.headers : {}; + await fs.writeFile( + params.mcpConfigPath, + `${JSON.stringify( + { + ...raw, + mcpServers: { + ...mcpServers, + openclaw: { + ...openclaw, + headers: { + ...headers, + "x-openclaw-cli-capture-key": params.captureKey, + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); +} diff --git a/src/agents/cli-runner/bundle-mcp.test-support.ts b/src/agents/cli-runner/bundle-mcp.test-support.ts index 524b17b9cf6..28e2214c330 100644 --- a/src/agents/cli-runner/bundle-mcp.test-support.ts +++ b/src/agents/cli-runner/bundle-mcp.test-support.ts @@ -69,6 +69,7 @@ function createEnabledBundleProbeConfig(): OpenClawConfig { export async function prepareBundleProbeCliConfig(params?: { additionalConfig?: Parameters[0]["additionalConfig"]; + env?: Parameters[0]["env"]; }) { // Bundle discovery reads HOME for per-user plugin roots. return await withEnvAsync({ HOME: bundleProbeHomeDir }, async () => { @@ -82,6 +83,7 @@ export async function prepareBundleProbeCliConfig(params?: { workspaceDir: bundleProbeWorkspaceDir, config: createEnabledBundleProbeConfig(), additionalConfig: params?.additionalConfig, + env: params?.env, }); }); } diff --git a/src/agents/cli-runner/bundle-mcp.test.ts b/src/agents/cli-runner/bundle-mcp.test.ts index bef9bc0caf3..5bd36c68ed1 100644 --- a/src/agents/cli-runner/bundle-mcp.test.ts +++ b/src/agents/cli-runner/bundle-mcp.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { writeClaudeBundleManifest } from "../../plugins/bundle-mcp.test-support.js"; -import { prepareCliBundleMcpConfig } from "./bundle-mcp.js"; +import { prepareCliBundleMcpCaptureAttempt, prepareCliBundleMcpConfig } from "./bundle-mcp.js"; import { cliBundleMcpHarness, prepareBundleProbeCliConfig, @@ -116,18 +116,31 @@ describe("prepareCliBundleMcpConfig", () => { }); it("merges loopback overlay config with bundle MCP servers", async () => { - const prepared = await prepareBundleProbeCliConfig({ - additionalConfig: { - mcpServers: { - openclaw: { - type: "http", - url: "http://127.0.0.1:23119/mcp", - headers: { - Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", - }, + const additionalConfig = { + mcpServers: { + openclaw: { + type: "http", + url: "http://127.0.0.1:23119/mcp", + headers: { + Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", + "x-openclaw-cli-capture-key": "${OPENCLAW_MCP_CLI_CAPTURE_KEY}", }, }, }, + }; + const prepared = await prepareBundleProbeCliConfig({ + additionalConfig, + env: { + OPENCLAW_MCP_TOKEN: "loopback-token-123", + OPENCLAW_MCP_CLI_CAPTURE_KEY: "", + }, + }); + const otherEnvPrepared = await prepareBundleProbeCliConfig({ + additionalConfig, + env: { + OPENCLAW_MCP_TOKEN: "other-loopback-token", + OPENCLAW_MCP_CLI_CAPTURE_KEY: "", + }, }); const generatedConfigPath = requireMcpConfigPath(prepared.backend.args); @@ -136,9 +149,28 @@ describe("prepareCliBundleMcpConfig", () => { }; expect(Object.keys(raw.mcpServers ?? {}).toSorted()).toEqual(["bundleProbe", "openclaw"]); expect(raw.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp"); - expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer ${OPENCLAW_MCP_TOKEN}"); + expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer loopback-token-123"); + expect(raw.mcpServers?.openclaw?.headers?.["x-openclaw-cli-capture-key"]).toBe(""); + await prepareCliBundleMcpCaptureAttempt({ + mode: "claude-config-file", + backend: prepared.backend, + env: prepared.env, + captureKey: "attempt-123", + }); + const attemptRaw = JSON.parse(await fs.readFile(generatedConfigPath, "utf-8")) as { + mcpServers?: Record }>; + }; + expect(attemptRaw.mcpServers?.openclaw?.headers?.Authorization).toBe( + "Bearer loopback-token-123", + ); + expect(attemptRaw.mcpServers?.openclaw?.headers?.["x-openclaw-cli-capture-key"]).toBe( + "attempt-123", + ); + expect(prepared.mcpConfigHash).toBe(otherEnvPrepared.mcpConfigHash); + expect(prepared.mcpResumeHash).toBe(otherEnvPrepared.mcpResumeHash); await prepared.cleanup?.(); + await otherEnvPrepared.cleanup?.(); }); it("preserves extra env values alongside generated MCP config", async () => { diff --git a/src/agents/cli-runner/bundle-mcp.ts b/src/agents/cli-runner/bundle-mcp.ts index 18ee0defdc3..a53b6a51329 100644 --- a/src/agents/cli-runner/bundle-mcp.ts +++ b/src/agents/cli-runner/bundle-mcp.ts @@ -13,7 +13,11 @@ import { extractMcpServerMap, type BundleMcpConfig } from "../../plugins/bundle- import type { CliBundleMcpMode } from "../../plugins/types.js"; import { loadMergedBundleMcpConfig, toCliBundleMcpServerConfig } from "../bundle-mcp-config.js"; import { isRecord } from "./bundle-mcp-adapter-shared.js"; -import { findClaudeMcpConfigPath, injectClaudeMcpConfigArgs } from "./bundle-mcp-claude.js"; +import { + findClaudeMcpConfigPath, + injectClaudeMcpConfigArgs, + writeClaudeMcpCaptureConfig, +} from "./bundle-mcp-claude.js"; import { injectCodexMcpConfigArgs } from "./bundle-mcp-codex.js"; import { writeGeminiMcpCaptureSettings, writeGeminiSystemSettings } from "./bundle-mcp-gemini.js"; @@ -78,6 +82,28 @@ function canonicalizeBundleMcpConfigForResume(config: BundleMcpConfig): BundleMc }; } +const OPENCLAW_MCP_ENV_TEMPLATE_PATTERN = /\$\{(OPENCLAW_MCP_[A-Z0-9_]+)\}/g; + +function resolveOpenClawMcpEnvTemplates(value: unknown, env?: Record): unknown { + if (!env) { + return value; + } + if (typeof value === "string") { + return value.replace(OPENCLAW_MCP_ENV_TEMPLATE_PATTERN, (match, name: string) => { + return Object.hasOwn(env, name) ? env[name] : match; + }); + } + if (Array.isArray(value)) { + return value.map((entry) => resolveOpenClawMcpEnvTemplates(entry, env)); + } + if (!isRecord(value)) { + return value; + } + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, resolveOpenClawMcpEnvTemplates(entry, env)]), + ); +} + async function prepareModeSpecificBundleMcpConfig(params: { mode: CliBundleMcpMode; backend: CliBackendConfig; @@ -122,7 +148,11 @@ async function prepareModeSpecificBundleMcpConfig(params: { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-mcp-")); const mcpConfigPath = path.join(tempDir, "mcp.json"); - await fs.writeFile(mcpConfigPath, serializedConfig, "utf-8"); + const runtimeConfig = resolveOpenClawMcpEnvTemplates( + params.mergedConfig, + params.env, + ) as BundleMcpConfig; + await fs.writeFile(mcpConfigPath, `${JSON.stringify(runtimeConfig, null, 2)}\n`, "utf-8"); return { backend: { ...params.backend, @@ -201,6 +231,7 @@ export async function prepareCliBundleMcpConfig(params: { /** Prepares a per-attempt capture token without changing resume compatibility hashes. */ export async function prepareCliBundleMcpCaptureAttempt(params: { mode?: CliBundleMcpMode; + backend?: CliBackendConfig; env?: Record; captureKey?: string; }): Promise<{ env?: Record; cleanup?: () => Promise }> { @@ -213,6 +244,17 @@ export async function prepareCliBundleMcpCaptureAttempt(params: { captureKey: params.captureKey, }); } + if (resolveBundleMcpMode(params.mode) === "claude-config-file") { + const mcpConfigPath = + findClaudeMcpConfigPath(params.backend?.args) ?? + findClaudeMcpConfigPath(params.backend?.resumeArgs); + if (mcpConfigPath) { + await writeClaudeMcpCaptureConfig({ + mcpConfigPath, + captureKey: params.captureKey, + }); + } + } return { env: { ...params.env, diff --git a/src/agents/cli-runner/claude-live-session.ts b/src/agents/cli-runner/claude-live-session.ts index c9e9f6d9193..b78aa07722e 100644 --- a/src/agents/cli-runner/claude-live-session.ts +++ b/src/agents/cli-runner/claude-live-session.ts @@ -33,6 +33,7 @@ import { } from "../cli-output.js"; import { classifyFailoverReason } from "../embedded-agent-helpers.js"; import { FailoverError, resolveFailoverStatus } from "../failover-error.js"; +import { prepareCliBundleMcpCaptureAttempt } from "./bundle-mcp.js"; import { buildClaudeOwnerKey } from "./helpers.js"; import { cliBackendLog, formatCliBackendOutputDigest } from "./log.js"; import type { PreparedCliRunContext } from "./types.js"; @@ -1063,39 +1064,49 @@ async function createClaudeLiveSession(params: { cleanup: () => Promise; }): Promise { let session: ClaudeLiveSession | null = null; - const managedRun = await params.supervisor.spawn({ - sessionId: params.context.params.sessionId, - backendId: params.context.backendResolved.id, - scopeKey: `claude-live:${params.key}`, - replaceExistingScope: true, - mode: "child", - argv: params.argv, - cwd: params.context.cwd ?? params.context.workspaceDir, - env: params.mcpCaptureKey - ? { ...params.env, OPENCLAW_MCP_CLI_CAPTURE_KEY: params.mcpCaptureKey } - : params.env, - stdinMode: "pipe-open", - captureOutput: false, - onStdout: (chunk) => { - if (session) { - handleClaudeStdout(session, chunk); - } - }, - onStderr: (chunk) => { - if (session) { - session.stderr += chunk; - if (session.stderr.length > CLAUDE_LIVE_MAX_STDERR_CHARS) { - closeLiveSession( - session, - "abort", - createOutputLimitError(session, "Claude CLI stderr exceeded limit."), - ); - return; - } - resetNoOutputTimer(session); - } - }, + const mcpCaptureAttempt = await prepareCliBundleMcpCaptureAttempt({ + mode: params.context.backendResolved.bundleMcpMode, + backend: params.context.preparedBackend.backend, + env: params.env, + captureKey: params.mcpCaptureKey, }); + let managedRun: ManagedRun; + try { + managedRun = await params.supervisor.spawn({ + sessionId: params.context.params.sessionId, + backendId: params.context.backendResolved.id, + scopeKey: `claude-live:${params.key}`, + replaceExistingScope: true, + mode: "child", + argv: params.argv, + cwd: params.context.cwd ?? params.context.workspaceDir, + env: mcpCaptureAttempt.env ?? params.env, + stdinMode: "pipe-open", + captureOutput: false, + onStdout: (chunk) => { + if (session) { + handleClaudeStdout(session, chunk); + } + }, + onStderr: (chunk) => { + if (session) { + session.stderr += chunk; + if (session.stderr.length > CLAUDE_LIVE_MAX_STDERR_CHARS) { + closeLiveSession( + session, + "abort", + createOutputLimitError(session, "Claude CLI stderr exceeded limit."), + ); + return; + } + resetNoOutputTimer(session); + } + }, + }); + } catch (error) { + await mcpCaptureAttempt.cleanup?.(); + throw error; + } session = { key: params.key, fingerprint: params.fingerprint, @@ -1109,7 +1120,10 @@ async function createClaudeLiveSession(params: { drainTimer: null, drainingAbortedTurn: false, idleTimer: null, - cleanup: params.cleanup, + cleanup: async () => { + await mcpCaptureAttempt.cleanup?.(); + await params.cleanup(); + }, cleanupPromise: null, closing: false, mcpCaptureKey: params.mcpCaptureKey, diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index 2754bb7556c..45a5aa84f3d 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -677,6 +677,7 @@ export async function executePreparedCliRun( : buildCliMcpCaptureKey(context); const mcpCaptureAttempt = await prepareCliBundleMcpCaptureAttempt({ mode: context.backendResolved.bundleMcpMode, + backend, env: context.preparedBackend.env, captureKey: initialGatewayCaptureKey, }); diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index c0608982c4d..354fbe31032 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -112,6 +112,7 @@ function createTestMcpLoopbackServerConfig(port: number) { openclaw: { type: "http", url: `http://127.0.0.1:${port}/mcp`, + alwaysLoad: true, headers: { Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", "x-session-key": "${OPENCLAW_MCP_SESSION_KEY}", diff --git a/src/gateway/live-agent-probes.test.ts b/src/gateway/live-agent-probes.test.ts index 412297e7241..86a18a7c0f5 100644 --- a/src/gateway/live-agent-probes.test.ts +++ b/src/gateway/live-agent-probes.test.ts @@ -44,14 +44,19 @@ describe("live-agent-probes", () => { agentId: "codex", sessionKey: "agent:codex:acp:test", }); - expect( - buildLiveCronProbeMessage({ - agent: "claude-cli", - argsJson: spec.argsJson, - attempt: 1, - exactReply: spec.name, - }), - ).toContain("Preserve job.sessionTarget and job.sessionKey exactly as provided."); + const claudeRetryPrompt = buildLiveCronProbeMessage({ + agent: "claude-cli", + argsJson: spec.argsJson, + attempt: 1, + exactReply: spec.name, + }); + expect(claudeRetryPrompt).toContain( + "Preserve job.sessionTarget and job.sessionKey exactly as provided.", + ); + expect(claudeRetryPrompt).toContain("search/load MCP tools for `openclaw cron` or `cron`"); + expect(claudeRetryPrompt).toContain("mcp__openclaw__cron"); + expect(claudeRetryPrompt).toContain("Do not use Claude native `CronCreate`"); + expect(claudeRetryPrompt).not.toContain("openclaw-tools"); expect( buildLiveCronProbeMessage({ agent: "future-agent", diff --git a/src/gateway/live-agent-probes.ts b/src/gateway/live-agent-probes.ts index 35a1ebde206..7d96cc026c1 100644 --- a/src/gateway/live-agent-probes.ts +++ b/src/gateway/live-agent-probes.ts @@ -103,8 +103,10 @@ export function buildLiveCronProbeMessage(params: { const claudeLike = isClaudeLikeLiveAgent(params.agent); if (params.attempt === 0) { return ( - "Use the OpenClaw MCP tool `openclaw-tools/cron` (server `openclaw-tools`, tool `cron`). " + - "If the harness shows Claude-style MCP names, use `mcp__openclaw-tools__cron` or `mcp__openclaw_tools__cron`. " + + "Use the OpenClaw MCP cron tool from server `openclaw`. " + + "If it is not already visible, search/load MCP tools for `openclaw cron` or `cron`, " + + "then call the matching OpenClaw MCP tool; Claude-style names may appear as `mcp__openclaw__cron`. " + + "Do not use Claude native `CronCreate`, `CronList`, or `CronDelete`; those are not OpenClaw proof. " + `Call it with JSON arguments ${params.argsJson}. ` + "Preserve the JSON exactly, including job.sessionTarget and job.sessionKey; do not omit, rename, or flatten those fields. " + "Do the actual tool call; I will verify externally with the OpenClaw cron CLI. " + @@ -113,8 +115,10 @@ export function buildLiveCronProbeMessage(params: { } if (claudeLike) { return ( - "Retry the OpenClaw MCP tool `openclaw-tools/cron` now. " + - "If the harness shows Claude-style MCP names, use `mcp__openclaw-tools__cron` or `mcp__openclaw_tools__cron`. " + + "Retry the OpenClaw MCP cron tool from server `openclaw` now. " + + "If it is not already visible, search/load MCP tools for `openclaw cron` or `cron`, " + + "then call the matching OpenClaw MCP tool; Claude-style names may appear as `mcp__openclaw__cron`. " + + "Do not use Claude native `CronCreate`, `CronList`, or `CronDelete`; those are not OpenClaw proof. " + `Use these exact JSON arguments: ${params.argsJson}. ` + "Preserve job.sessionTarget and job.sessionKey exactly as provided. " + `If the cron job is created, reply exactly: ${params.exactReply}. ` + @@ -125,8 +129,8 @@ export function buildLiveCronProbeMessage(params: { } return ( "Your previous OpenClaw cron MCP tool call was cancelled before the job was created. " + - "Retry the OpenClaw MCP tool `openclaw-tools/cron` now. " + - "If the harness shows Claude-style MCP names, use `mcp__openclaw-tools__cron` or `mcp__openclaw_tools__cron`. " + + "Retry the OpenClaw MCP cron tool from server `openclaw` now. " + + "If the harness shows Claude-style MCP names, use `mcp__openclaw__cron`. " + `Use these exact JSON arguments: ${params.argsJson}. ` + "Preserve job.sessionTarget and job.sessionKey exactly as provided. " + `If the cron job is created, reply exactly: ${params.exactReply}. ` + diff --git a/src/gateway/mcp-http.loopback-runtime.ts b/src/gateway/mcp-http.loopback-runtime.ts index 13906cf010e..96fbe38abd7 100644 --- a/src/gateway/mcp-http.loopback-runtime.ts +++ b/src/gateway/mcp-http.loopback-runtime.ts @@ -373,6 +373,7 @@ export function createMcpLoopbackServerConfig(port: number) { openclaw: { type: "http", url: `http://127.0.0.1:${port}/mcp`, + alwaysLoad: true, headers: { Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", "x-session-key": "${OPENCLAW_MCP_SESSION_KEY}", diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index ab485ed99aa..09b88691514 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -1688,9 +1688,13 @@ describe("mcp loopback server", () => { describe("createMcpLoopbackServerConfig", () => { it("builds a server entry with env-driven headers", () => { const config = createMcpLoopbackServerConfig(23119) as { - mcpServers?: Record }>; + mcpServers?: Record< + string, + { alwaysLoad?: boolean; url?: string; headers?: Record } + >; }; expect(config.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp"); + expect(config.mcpServers?.openclaw?.alwaysLoad).toBe(true); expect(config.mcpServers?.openclaw?.headers?.Authorization).toBe( "Bearer ${OPENCLAW_MCP_TOKEN}", ); diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 0a253037963..f70c25988fe 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -2233,6 +2233,44 @@ describe("gateway agent handler", () => { }); }); + it("forwards admin caller ownership to ingress agent runs", async () => { + primeMainAgentRun({ cfg: mocks.loadConfigReturn }); + mocks.agentCommand.mockClear(); + + await invokeAgent( + { + message: "owner tool check", + agentId: "main", + sessionKey: "agent:main:main", + idempotencyKey: "test-admin-sender-owner", + }, + { + reqId: "admin-sender-owner", + client: { connect: { scopes: ["operator.admin"] } } as AgentHandlerArgs["client"], + }, + ); + + expect((await waitForAgentCommandCall<{ senderIsOwner?: boolean }>()).senderIsOwner).toBe(true); + + mocks.agentCommand.mockClear(); + await invokeAgent( + { + message: "non-owner tool check", + agentId: "main", + sessionKey: "agent:main:main", + idempotencyKey: "test-write-sender-owner", + }, + { + reqId: "write-sender-owner", + client: backendGatewayClient(), + }, + ); + + expect((await waitForAgentCommandCall<{ senderIsOwner?: boolean }>()).senderIsOwner).toBe( + false, + ); + }); + it("rejects public transcriptMessage overrides", async () => { primeMainAgentRun({ cfg: mocks.loadConfigReturn }); mocks.agentCommand.mockClear(); diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index c7458559128..3cfa5dc788b 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -2770,6 +2770,7 @@ export const agentHandlers: GatewayRequestHandlers = { acpTurnSource: request.acpTurnSource, internalEvents: request.internalEvents, inputProvenance, + senderIsOwner: clientHasAdminScope(client), sessionEffects, skipInitialSessionTouch: skipAgentInitialSessionTouch, preserveUserFacingSessionModelState,