fix: preserve workspace auth labels in model status

This commit is contained in:
Shakker
2026-04-29 22:48:44 +01:00
parent 0d702f3e28
commit 7d4a0bb621
11 changed files with 154 additions and 3 deletions

View File

@@ -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 },

View File

@@ -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,

View File

@@ -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");
});
});

View File

@@ -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") ||

View File

@@ -93,6 +93,7 @@ export async function applyInlineDirectivesFastLane(
surface: ctx.Surface,
gatewayClientScopes: ctx.GatewayClientScopes,
senderIsOwner: params.senderIsOwner,
workspaceDir: params.workspaceDir,
});
if (sessionEntry?.providerOverride) {

View File

@@ -104,6 +104,7 @@ export async function handleDirectiveOnly(
aliasIndex,
allowedModelCatalog,
resetModelOverride,
workspaceDir: params.workspaceDir,
surface: params.surface,
sessionEntry,
});

View File

@@ -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<string, { type: "api_key"; provider: string; key: string }>,
@@ -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;

View File

@@ -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<SessionEntry, "modelProvider" | "model">;
}): Promise<ReplyPayload | undefined> {
@@ -305,6 +306,7 @@ export async function maybeHandleModelDirectiveInfo(params: {
modelsPath,
params.agentDir,
authMode,
params.workspaceDir,
);
authByProvider.set(provider, formatAuthLabel(auth));
}

View File

@@ -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<OpenClawConfig["agents"]>["defaults"];

View File

@@ -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,

View File

@@ -158,7 +158,10 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
? resolveSessionAgentId({ sessionKey, config: cfg })
: resolveDefaultAgentId(cfg);
const statusAgentDir = resolveAgentDir(cfg, statusAgentId);
const statusWorkspaceDir = params.workspaceDir ?? resolveAgentWorkspaceDir(cfg, statusAgentId);
const statusWorkspaceDir =
params.workspaceDir ??
sessionEntry?.spawnedWorkspaceDir ??
resolveAgentWorkspaceDir(cfg, statusAgentId);
const modelRefs = resolveSelectedAndActiveModel({
selectedProvider: provider,
selectedModel: model,