From 7d4a0bb62163b65954b3a3e5385fa895bb463f47 Mon Sep 17 00:00:00 2001 From: Shakker Date: Wed, 29 Apr 2026 22:48:44 +0100 Subject: [PATCH] fix: preserve workspace auth labels in model status --- .../openclaw-tools.session-status.test.ts | 24 ++++++ src/agents/tools/session-status-tool.ts | 1 + .../reply/directive-handling.auth.test.ts | 40 +++++++++- .../reply/directive-handling.auth.ts | 3 +- .../reply/directive-handling.fast-lane.ts | 1 + .../reply/directive-handling.impl.ts | 1 + .../reply/directive-handling.model.test.ts | 76 +++++++++++++++++++ .../reply/directive-handling.model.ts | 2 + .../reply/directive-handling.params.ts | 2 + .../reply/get-reply-directives-apply.ts | 2 + src/status/status-text.ts | 5 +- 11 files changed, 154 insertions(+), 3 deletions(-) diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index b4ca5fd03a5..aeca01ee603 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -183,6 +183,7 @@ function createCommandsStatusRuntimeModuleMock() { statusChannel: string; provider?: string; model: string; + workspaceDir?: string; primaryModelLabelOverride?: string; includeTranscriptUsage?: boolean; taskLineOverride?: string; @@ -225,6 +226,7 @@ function createCommandsStatusRuntimeModuleMock() { sessionEntry: params.sessionEntry, modelAuth, includeTranscriptUsage: params.includeTranscriptUsage, + workspaceDir: params.workspaceDir, }); return ["OpenClaw", `🧠 Model: ${primary}`, params.taskLineOverride] .filter(Boolean) @@ -427,6 +429,28 @@ describe("session_status tool", () => { ); }); + it("passes spawned workspace to session_status auth labels", async () => { + resetSessionStore({ + "agent:main:spawned": { + sessionId: "spawned-status", + updatedAt: 10, + spawnedWorkspaceDir: "/tmp/openclaw-spawned-workspace", + providerOverride: "anthropic", + modelOverride: "claude-opus-4-6", + }, + }); + + const tool = getSessionStatusTool("agent:main:spawned"); + + await tool.execute("call-spawned-workspace-status", {}); + + expect(buildStatusMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceDir: "/tmp/openclaw-spawned-workspace", + }), + ); + }); + it("errors for unknown session keys", async () => { resetSessionStore({ main: { sessionId: "s1", updatedAt: 10 }, diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index da27ddbe962..854bb461823 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -564,6 +564,7 @@ export function createSessionStatusTool(opts?: { statusSessionEntry.lastChannel ?? statusSessionEntry.origin?.provider ?? "unknown", + workspaceDir: statusSessionEntry.spawnedWorkspaceDir, provider: providerForCard, model: defaultModelForCard, resolvedThinkLevel: statusSessionEntry.thinkingLevel as ThinkLevel | undefined, diff --git a/src/auto-reply/reply/directive-handling.auth.test.ts b/src/auto-reply/reply/directive-handling.auth.test.ts index 5e1248c8a61..874a3b5257f 100644 --- a/src/auto-reply/reply/directive-handling.auth.test.ts +++ b/src/auto-reply/reply/directive-handling.auth.test.ts @@ -4,6 +4,15 @@ import type { OpenClawConfig } from "../../config/config.js"; let mockStore: AuthProfileStore; let mockOrder: string[]; +const resolveEnvApiKeyMock = vi.hoisted(() => + vi.fn( + ( + _provider?: string, + _env?: NodeJS.ProcessEnv, + _options?: { config?: OpenClawConfig; workspaceDir?: string }, + ) => null as { apiKey: string; source: string } | null, + ), +); const githubCopilotTokenRefProfile: AuthProfileStore["profiles"][string] = { type: "token", provider: "github-copilot", @@ -39,7 +48,11 @@ vi.mock("../../agents/model-auth.js", () => ({ ensureAuthProfileStore: () => mockStore, resolveUsableCustomProviderApiKey: () => null, resolveAuthProfileOrder: () => mockOrder, - resolveEnvApiKey: () => null, + resolveEnvApiKey: ( + provider?: string, + env?: NodeJS.ProcessEnv, + options?: { config?: OpenClawConfig; workspaceDir?: string }, + ) => resolveEnvApiKeyMock(provider, env, options), })); const { resolveAuthLabel } = await import("./directive-handling.auth.js"); @@ -73,6 +86,8 @@ describe("resolveAuthLabel ref-aware labels", () => { profiles: {}, }; mockOrder = []; + resolveEnvApiKeyMock.mockReset(); + resolveEnvApiKeyMock.mockReturnValue(null); }); it("shows api-key (ref) for keyRef-only profiles in compact mode", async () => { @@ -112,4 +127,27 @@ describe("resolveAuthLabel ref-aware labels", () => { expect(result.label).toContain("github-copilot:default=token:ref"); expect(result.label).not.toContain("token:missing"); }); + + it("passes workspace scope to env auth labels", async () => { + const cfg = { plugins: { allow: ["workspace-auth-label"] } } as OpenClawConfig; + resolveEnvApiKeyMock.mockReturnValue({ + apiKey: "workspace-local-credentials", + source: "workspace credentials", + }); + + const result = await resolveAuthLabel( + "anthropic", + cfg, + "/tmp/models.json", + "/tmp/agent", + "verbose", + "/tmp/workspace", + ); + + expect(resolveEnvApiKeyMock).toHaveBeenCalledWith("anthropic", process.env, { + config: cfg, + workspaceDir: "/tmp/workspace", + }); + expect(result.source).toBe("workspace credentials"); + }); }); diff --git a/src/auto-reply/reply/directive-handling.auth.ts b/src/auto-reply/reply/directive-handling.auth.ts index 4b5cd11da7d..51d710ed773 100644 --- a/src/auto-reply/reply/directive-handling.auth.ts +++ b/src/auto-reply/reply/directive-handling.auth.ts @@ -56,6 +56,7 @@ export const resolveAuthLabel = async ( modelsPath: string, agentDir?: string, mode: ModelAuthDetailMode = "compact", + workspaceDir?: string, ): Promise<{ label: string; source: string }> => { const formatPath = (value: string) => shortenHomePath(value); const store = ensureAuthProfileStore(agentDir, { @@ -193,7 +194,7 @@ export const resolveAuthLabel = async ( }; } - const envKey = resolveEnvApiKey(provider); + const envKey = resolveEnvApiKey(provider, process.env, { config: cfg, workspaceDir }); if (envKey) { const isOAuthEnv = envKey.source.includes("ANTHROPIC_OAUTH_TOKEN") || diff --git a/src/auto-reply/reply/directive-handling.fast-lane.ts b/src/auto-reply/reply/directive-handling.fast-lane.ts index 3c8fc202cc9..188c364a18e 100644 --- a/src/auto-reply/reply/directive-handling.fast-lane.ts +++ b/src/auto-reply/reply/directive-handling.fast-lane.ts @@ -93,6 +93,7 @@ export async function applyInlineDirectivesFastLane( surface: ctx.Surface, gatewayClientScopes: ctx.GatewayClientScopes, senderIsOwner: params.senderIsOwner, + workspaceDir: params.workspaceDir, }); if (sessionEntry?.providerOverride) { diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index 47745932178..cc77f8b5b48 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -104,6 +104,7 @@ export async function handleDirectiveOnly( aliasIndex, allowedModelCatalog, resetModelOverride, + workspaceDir: params.workspaceDir, surface: params.surface, sessionEntry, }); diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index d663b398b87..3684d169e3a 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const authProfilesStoreMock = vi.hoisted(() => ({ profiles: {} as Record, @@ -70,6 +73,7 @@ import { enqueueSystemEvent } from "../../infra/system-events.js"; import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import type { ProviderPlugin } from "../../plugins/types.js"; +import { withEnvAsync } from "../../test-utils/env.js"; import type { ElevatedLevel } from "../thinking.js"; import { handleDirectiveOnly } from "./directive-handling.impl.js"; import { @@ -92,6 +96,7 @@ vi.mock("../../agents/agent-scope.js", () => ({ resolveAgentDir: vi.fn(() => "/tmp/agent"), resolveAgentEffectiveModelPrimary: vi.fn(() => undefined), resolveAgentModelFallbacksOverride: vi.fn(() => undefined), + resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), resolveSessionAgentId: vi.fn(() => "main"), })); @@ -396,6 +401,77 @@ describe("/model chat UX", () => { expect(reply?.text).not.toContain("missing (missing)"); }); + it("uses workspace-scoped auth evidence in /model status labels", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-model-status-auth-label-")); + const workspaceDir = path.join(tempRoot, "workspace"); + const pluginDir = path.join(workspaceDir, ".openclaw", "extensions", "workspace-model-auth"); + const bundledDir = path.join(tempRoot, "bundled"); + const stateDir = path.join(tempRoot, "state"); + const credentialPath = path.join(tempRoot, "credentials.json"); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.mkdirSync(bundledDir, { recursive: true }); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(pluginDir, "index.ts"), "export default {}\n", "utf8"); + fs.writeFileSync(credentialPath, "{}", "utf8"); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "workspace-model-auth", + configSchema: { type: "object" }, + setup: { + providers: [ + { + id: "anthropic", + authEvidence: [ + { + type: "local-file-with-env", + fileEnvVar: "WORKSPACE_MODEL_CREDENTIALS", + credentialMarker: "workspace-model-local-credentials", + source: "workspace model credentials", + }, + ], + }, + ], + }, + }), + "utf8", + ); + + try { + await withEnvAsync( + { + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, + OPENCLAW_STATE_DIR: stateDir, + WORKSPACE_MODEL_CREDENTIALS: credentialPath, + }, + async () => { + const reply = await resolveModelInfoReply({ + directives: parseInlineDirectives("/model status"), + workspaceDir, + cfg: { + ...baseConfig(), + plugins: { allow: ["workspace-model-auth"] }, + agents: { + defaults: { + models: { + "anthropic/claude-opus-4-6": {}, + }, + }, + }, + } as OpenClawConfig, + allowedModelCatalog: [ + { provider: "anthropic", id: "claude-opus-4-6", name: "Claude Opus 4.6" }, + ], + }); + + expect(reply?.text).toContain("workspace model credentials"); + }, + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + it("auto-applies closest match for typos", () => { const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5"); const cfg = { commands: { text: true } } as unknown as OpenClawConfig; diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index 174818585ba..4ccbb2702cc 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -202,6 +202,7 @@ export async function maybeHandleModelDirectiveInfo(params: { aliasIndex: ModelAliasIndex; allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>; resetModelOverride: boolean; + workspaceDir?: string; surface?: string; sessionEntry?: Pick; }): Promise { @@ -305,6 +306,7 @@ export async function maybeHandleModelDirectiveInfo(params: { modelsPath, params.agentDir, authMode, + params.workspaceDir, ); authByProvider.set(provider, formatAuthLabel(auth)); } diff --git a/src/auto-reply/reply/directive-handling.params.ts b/src/auto-reply/reply/directive-handling.params.ts index d82e46dd22a..99e81e3e84e 100644 --- a/src/auto-reply/reply/directive-handling.params.ts +++ b/src/auto-reply/reply/directive-handling.params.ts @@ -40,6 +40,7 @@ export type HandleDirectiveOnlyParams = HandleDirectiveOnlyCoreParams & { currentVerboseLevel?: VerboseLevel; currentReasoningLevel?: ReasoningLevel; currentElevatedLevel?: ElevatedLevel; + workspaceDir?: string; surface?: string; gatewayClientScopes?: string[]; senderIsOwner?: boolean; @@ -49,6 +50,7 @@ export type ApplyInlineDirectivesFastLaneParams = HandleDirectiveOnlyCoreParams commandAuthorized: boolean; senderIsOwner: boolean; ctx: MsgContext; + workspaceDir?: string; agentId?: string; isGroup: boolean; agentCfg?: NonNullable["defaults"]; diff --git a/src/auto-reply/reply/get-reply-directives-apply.ts b/src/auto-reply/reply/get-reply-directives-apply.ts index 53a19660a87..9e376389043 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.ts @@ -344,6 +344,7 @@ export async function applyInlineDirectiveOverrides(params: { surface: ctx.Surface, gatewayClientScopes: ctx.GatewayClientScopes, senderIsOwner: command.senderIsOwner, + workspaceDir, }); let statusReply: ReplyPayload | undefined; if (directives.hasStatusDirective && allowTextCommands && command.isAuthorizedSender) { @@ -389,6 +390,7 @@ export async function applyInlineDirectiveOverrides(params: { commandAuthorized: command.isAuthorizedSender, senderIsOwner: command.senderIsOwner, ctx, + workspaceDir, cfg, agentId, isGroup, diff --git a/src/status/status-text.ts b/src/status/status-text.ts index 85839668faa..f5f660f6de7 100644 --- a/src/status/status-text.ts +++ b/src/status/status-text.ts @@ -158,7 +158,10 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise