diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 70306c5c63b..965487e8c6f 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -390,6 +390,7 @@ export async function compactEmbeddedPiSessionDirect( cfg: params.config, profileId: authProfileId, agentDir, + workspaceDir: resolvedWorkspace, }); if (!apiKeyInfo.apiKey) { diff --git a/src/agents/pi-embedded-runner/run/auth-controller.test.ts b/src/agents/pi-embedded-runner/run/auth-controller.test.ts index 17636ab40af..5a96847815d 100644 --- a/src/agents/pi-embedded-runner/run/auth-controller.test.ts +++ b/src/agents/pi-embedded-runner/run/auth-controller.test.ts @@ -176,6 +176,12 @@ describe("createEmbeddedRunAuthController", () => { await controller.initializeAuthProfile(); + expect(mocks.getApiKeyForModel).toHaveBeenCalledWith( + expect.objectContaining({ + agentDir: "/tmp/agent", + workspaceDir: "/tmp/workspace", + }), + ); expect(harness.runtimeModel.baseUrl).toBe("https://runtime.example.com/v1"); expect(harness.runtimeModel.headers).toEqual({ "api-key": "runtime-header-token", diff --git a/src/agents/pi-embedded-runner/run/auth-controller.ts b/src/agents/pi-embedded-runner/run/auth-controller.ts index 053b026d2dc..00c0752381f 100644 --- a/src/agents/pi-embedded-runner/run/auth-controller.ts +++ b/src/agents/pi-embedded-runner/run/auth-controller.ts @@ -353,6 +353,7 @@ export function createEmbeddedRunAuthController(params: { profileId: candidate, store: params.authStore, agentDir: params.agentDir, + workspaceDir: params.workspaceDir, lockedProfile: candidate != null && candidate === params.lockedProfileId, }); }; diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts index 3fe08ae231c..b65cb068c64 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -10,6 +10,15 @@ import { requestHeartbeatNow } from "../../infra/heartbeat-wake.js"; import * as execModule from "../../process/exec.js"; import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; import { VERSION } from "../../version.js"; + +const runtimeModelAuthMocks = vi.hoisted(() => ({ + getApiKeyForModel: vi.fn(), + getRuntimeAuthForModel: vi.fn(), + resolveApiKeyForProvider: vi.fn(), +})); + +vi.mock("./runtime-model-auth.runtime.js", () => runtimeModelAuthMocks); + import { clearGatewaySubagentRuntime, createPluginRuntime, @@ -107,6 +116,9 @@ function expectRunCommandOutcome(params: { describe("plugin runtime command execution", () => { beforeEach(() => { vi.restoreAllMocks(); + runtimeModelAuthMocks.getApiKeyForModel.mockReset(); + runtimeModelAuthMocks.getRuntimeAuthForModel.mockReset(); + runtimeModelAuthMocks.resolveApiKeyForProvider.mockReset(); resetConfigRuntimeState(); clearGatewaySubagentRuntime(); }); @@ -291,6 +303,57 @@ describe("plugin runtime command execution", () => { expect(runtime.modelAuth.getApiKeyForModel).not.toBe(rawGetApiKey); }); + it("modelAuth wrappers preserve workspace scope while stripping credential steering", async () => { + const runtime = createPluginRuntime(); + const model = { + id: "workspace-cloud/model", + provider: "workspace-cloud", + api: "openai-responses", + baseUrl: "https://workspace-cloud.example/v1", + }; + const cfg = { plugins: { allow: ["workspace-cloud"] } } as OpenClawConfig; + runtimeModelAuthMocks.getApiKeyForModel.mockResolvedValue({ + apiKey: "model-key", + source: "workspace cloud credentials", + mode: "api-key", + }); + runtimeModelAuthMocks.resolveApiKeyForProvider.mockResolvedValue({ + apiKey: "provider-key", + source: "workspace cloud credentials", + mode: "api-key", + }); + + await expect( + runtime.modelAuth.getApiKeyForModel({ + model: model as never, + cfg, + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + store: { version: 1, profiles: {} }, + } as never), + ).resolves.toMatchObject({ apiKey: "model-key" }); + await expect( + runtime.modelAuth.resolveApiKeyForProvider({ + provider: "workspace-cloud", + cfg, + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + store: { version: 1, profiles: {} }, + } as never), + ).resolves.toMatchObject({ apiKey: "provider-key" }); + + expect(runtimeModelAuthMocks.getApiKeyForModel).toHaveBeenCalledWith({ + model, + cfg, + workspaceDir: "/tmp/workspace", + }); + expect(runtimeModelAuthMocks.resolveApiKeyForProvider).toHaveBeenCalledWith({ + provider: "workspace-cloud", + cfg, + workspaceDir: "/tmp/workspace", + }); + }); + it("keeps subagent unavailable by default even after gateway initialization", async () => { const { runtime } = createGatewaySubagentRunFixture(); diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index c0179e831b3..10fef42e22e 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -115,6 +115,7 @@ function createRuntimeModelAuth(): PluginRuntime["modelAuth"] { getApiKeyForModel({ model: params.model, cfg: params.cfg, + workspaceDir: params.workspaceDir, }), getRuntimeAuthForModel: (params) => getRuntimeAuthForModel({ @@ -126,6 +127,7 @@ function createRuntimeModelAuth(): PluginRuntime["modelAuth"] { resolveApiKeyForProvider({ provider: params.provider, cfg: params.cfg, + workspaceDir: params.workspaceDir, }), }; } diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index 109a1dca911..051d6a830ad 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -241,10 +241,11 @@ export type PluginRuntimeCore = { /** @deprecated Use runtime.tasks.flows for DTO-based TaskFlow access. */ taskFlow: import("./runtime-taskflow.types.js").PluginRuntimeTaskFlow; modelAuth: { - /** Resolve auth for a model. Only provider/model and optional cfg are used. */ + /** Resolve auth for a model. Only provider/model, optional cfg, and workspaceDir are used. */ getApiKeyForModel: (params: { model: import("@mariozechner/pi-ai").Model; cfg?: import("../../config/types.openclaw.js").OpenClawConfig; + workspaceDir?: string; }) => Promise; /** Resolve request-ready auth for a model, including provider runtime exchanges. */ getRuntimeAuthForModel: (params: { @@ -252,10 +253,11 @@ export type PluginRuntimeCore = { cfg?: import("../../config/types.openclaw.js").OpenClawConfig; workspaceDir?: string; }) => Promise; - /** Resolve auth for a provider by name. Only provider and optional cfg are used. */ + /** Resolve auth for a provider by name. Only provider, optional cfg, and workspaceDir are used. */ resolveApiKeyForProvider: (params: { provider: string; cfg?: import("../../config/types.openclaw.js").OpenClawConfig; + workspaceDir?: string; }) => Promise; }; };