From edb941a50822dff5eff727394cbe76febfcd223a Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Wed, 12 Aug 2026 20:17:13 -0700 Subject: [PATCH 1/6] oc-e35: restore explicit multi-agent UI ownership (#122889) --- .../agent-command.ingress-diagnostics.test.ts | 22 +++++- src/agents/agent-command.ts | 13 ++-- src/agents/command/ingress-diagnostics.ts | 2 + src/agents/command/post-run.ts | 1 + src/agents/command/session-store.test.ts | 76 ++++++++++++++++++- src/agents/command/session-store.ts | 2 + ...ed-agent.auth-profile-rotation.e2e.test.ts | 36 +++++++++ .../embedded-agent-runner/run-orchestrator.ts | 6 +- ui/src/components/sidebar-attention.test.ts | 5 +- ui/src/components/sidebar-attention.ts | 7 +- ui/src/pages/chat/chat-pane-base.ts | 27 +++---- ui/src/pages/chat/chat-pane-render.ts | 4 +- .../chat/chat-pane-session-hydration.test.ts | 5 ++ ui/src/pages/chat/chat-state-refresh.ts | 5 +- ui/src/pages/chat/chat-state.test.ts | 17 +++++ 15 files changed, 198 insertions(+), 30 deletions(-) diff --git a/src/agents/agent-command.ingress-diagnostics.test.ts b/src/agents/agent-command.ingress-diagnostics.test.ts index 134de3713424..78d6e318fb48 100644 --- a/src/agents/agent-command.ingress-diagnostics.test.ts +++ b/src/agents/agent-command.ingress-diagnostics.test.ts @@ -9,7 +9,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { emitIngressModelUsageDiagnostic } from "./command/ingress-diagnostics.js"; +import { emitIngressModelUsageDiagnostic as emitIngressModelUsageDiagnosticBase } from "./command/ingress-diagnostics.js"; const mocks = vi.hoisted(() => ({ emitTrustedDiagnosticEvent: vi.fn(), @@ -97,12 +97,20 @@ function makeOpts(overrides?: Record) { }; } +function emitIngressModelUsageDiagnostic( + result: Parameters[0], + opts: Parameters[1], + agentDir = "/state/agents/main/agent", +) { + emitIngressModelUsageDiagnosticBase(result, opts, agentDir); +} + describe("emitIngressModelUsageDiagnostic", () => { it("emits model.usage when diagnostics are enabled and result has usage", () => { const result = makeResult(); const opts = makeOpts(); - emitIngressModelUsageDiagnostic(result, opts); + emitIngressModelUsageDiagnostic(result, opts, "/state/agents/main/agent"); expect(mocks.emitTrustedDiagnosticEvent).toHaveBeenCalledTimes(1); const event = mocks.emitTrustedDiagnosticEvent.mock.calls[0]?.[0]; @@ -139,7 +147,14 @@ describe("emitIngressModelUsageDiagnostic", () => { }, }); - emitIngressModelUsageDiagnostic(result, makeOpts()); + emitIngressModelUsageDiagnostic(result, makeOpts(), "/state/agents/marie/agent"); + + expect(mocks.resolveModelCostConfig).toHaveBeenCalledWith({ + provider: "openai", + model: "gpt-5.5", + config: {}, + agentDir: "/state/agents/marie/agent", + }); expect(mocks.estimateUsageCost).toHaveBeenCalledWith({ usage: { @@ -247,6 +262,7 @@ describe("emitIngressModelUsageDiagnostic", () => { provider: "openai", model: "gpt-5.5", config: expect.any(Object) as unknown, + agentDir: "/state/agents/main/agent", }); expect(mocks.estimateUsageCost).toHaveBeenCalled(); expect(mocks.emitTrustedDiagnosticEvent).toHaveBeenCalledTimes(1); diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 6f59cbc2ccea..27c2ffab9d6f 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -629,6 +629,7 @@ async function agentCommandFromIngressInternal( const lifecycleGeneration = opts.lifecycleGeneration ?? captureAgentRunLifecycleGeneration(opts.runId ?? ""); return await withAgentRunLifecycleGeneration(lifecycleGeneration, async () => { + let preparedAgentDir: string | undefined; const result = await runWithAgentCommandRecoveryOwner({ lifecycleGeneration, mode: "claim", @@ -639,8 +640,9 @@ async function agentCommandFromIngressInternal( }, prepare: async (preparedOpts) => await prepareAgentCommandExecution(preparedOpts, runtime), restoreAdmittedRecovery: recovery?.restoreAdmittedRecovery, - run: async (prepared) => - await withAgentPluginRegistry({ + run: async (prepared) => { + preparedAgentDir = prepared.agentDir; + return await withAgentPluginRegistry({ config: prepared.cfg, workspaceDir: prepared.workspaceDir, run: async () => @@ -651,11 +653,12 @@ async function agentCommandFromIngressInternal( runtime, deps, ), - }), + }); + }, }); - if (result) { - emitIngressModelUsageDiagnostic(result, opts); + if (result && preparedAgentDir) { + emitIngressModelUsageDiagnostic(result, opts, preparedAgentDir); } return result; diff --git a/src/agents/command/ingress-diagnostics.ts b/src/agents/command/ingress-diagnostics.ts index abc51f376031..73b8c5a8d037 100644 --- a/src/agents/command/ingress-diagnostics.ts +++ b/src/agents/command/ingress-diagnostics.ts @@ -37,6 +37,7 @@ function ingressDiagnosticChannel(opts: AgentCommandIngressOpts): string { export function emitIngressModelUsageDiagnostic( result: AgentCommandResult, opts: AgentCommandIngressOpts, + agentDir: string, ): void { const cfg = getRuntimeConfig(); if (!isDiagnosticsEnabled(cfg)) { @@ -65,6 +66,7 @@ export function emitIngressModelUsageDiagnostic( provider: providerUsed, model: modelUsed, config: cfg, + agentDir, }); const costUsd = hasBillableUsageBuckets ? estimateUsageCost({ usage, cost: costConfig }) diff --git a/src/agents/command/post-run.ts b/src/agents/command/post-run.ts index 2b1e8ff0e6b6..1141605518a0 100644 --- a/src/agents/command/post-run.ts +++ b/src/agents/command/post-run.ts @@ -146,6 +146,7 @@ export async function finalizeEmbeddedAgentCommand(params: { const { updateSessionStoreAfterAgentRun } = await loadSessionStoreRuntime(); await updateSessionStoreAfterAgentRun({ cfg, + agentDir, contextTokensOverride: agentCfg?.contextTokens, sessionId: effectiveSessionId, sessionKey, diff --git a/src/agents/command/session-store.test.ts b/src/agents/command/session-store.test.ts index 61c9bd64819d..fbb341557fde 100644 --- a/src/agents/command/session-store.test.ts +++ b/src/agents/command/session-store.test.ts @@ -19,7 +19,7 @@ import { persistCliSessionForkSuccessorInStore, restoreCliSessionForkInStore, recordCliCompactionInStore, - updateSessionStoreAfterAgentRun, + updateSessionStoreAfterAgentRun as updateSessionStoreAfterAgentRunBase, } from "./session-store.js"; import { resolveSession } from "./session.js"; @@ -60,7 +60,16 @@ vi.mock("../../utils/usage-format.js", () => ({ } return total / 1e6; }, - resolveModelCostConfig: (params: { provider?: string; model?: string; config?: unknown }) => { + resolveModelCostConfig: (params: { + provider?: string; + model?: string; + config?: unknown; + agentDir?: string; + }) => { + const agents = (params.config as OpenClawConfig | undefined)?.agents?.list ?? []; + if (agents.length > 1 && !params.agentDir) { + throw new Error("multi-agent cost resolution requires an explicit agent directory"); + } const providers = (params.config as MockUsageFormatConfig | undefined)?.models?.providers; if (!providers) { return undefined; @@ -126,7 +135,70 @@ afterEach(() => { closeOpenClawAgentDatabasesForTest(); }); +type SessionStoreUpdateParams = Parameters[0]; + +async function updateSessionStoreAfterAgentRun( + params: Omit & { agentDir?: string }, +) { + await updateSessionStoreAfterAgentRunBase({ + ...params, + agentDir: params.agentDir ?? "/tmp/openclaw-session-store-test-agent", + }); +} + describe("updateSessionStoreAfterAgentRun", () => { + it("uses the prepared agent directory for multi-agent cost accounting", async () => { + await withTempSessionStore(async ({ dir, storePath }) => { + const sessionKey = "agent:marie:dashboard:cost-accounting"; + const sessionId = "cost-accounting-session"; + const sessionStore: Record = {}; + + await updateSessionStoreAfterAgentRun({ + cfg: { + agents: { list: [{ id: "main" }, { id: "marie" }] }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + models: [ + { + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + input: ["text"], + cost: { input: 2, output: 4, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + }, + ], + }, + }, + }, + } satisfies OpenClawConfig, + agentDir: path.join(dir, "agents", "marie", "agent"), + sessionId, + sessionKey, + storePath, + sessionStore, + defaultProvider: "openai", + defaultModel: "gpt-5.5", + result: { + meta: { + durationMs: 1, + agentMeta: { + sessionId, + provider: "openai", + model: "gpt-5.5", + usage: { input: 1_000_000, output: 1_000_000 }, + }, + }, + }, + }); + + expect(sessionStore[sessionKey]?.estimatedCostUsd).toBe(6); + }); + }); + it("clears the durable replay-safe recovery guard after the recovery run terminates", async () => { await withTempSessionStore(async ({ storePath }) => { const sessionKey = "agent:main:explicit:restart-recovery"; diff --git a/src/agents/command/session-store.ts b/src/agents/command/session-store.ts index 121eb2fe1b66..9f27a266d1ad 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -47,6 +47,7 @@ function resolvePositiveInteger(value: number | undefined): number | undefined { /** Applies run result metadata, usage, and CLI bindings to a session entry. */ export async function updateSessionStoreAfterAgentRun(params: { cfg: OpenClawConfig; + agentDir: string; contextTokensOverride?: number; sessionId: string; sessionKey: string; @@ -218,6 +219,7 @@ export async function updateSessionStoreAfterAgentRun(params: { provider: providerUsed, model: modelUsed, config: cfg, + agentDir: params.agentDir, }), }), ); diff --git a/src/agents/embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts b/src/agents/embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts index 6f1d7e034c72..aeac206de0e1 100644 --- a/src/agents/embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts @@ -659,6 +659,42 @@ async function runTurnWithCooldownSeed(params: { } describe("runEmbeddedAgent auth profile rotation", () => { + it("runs an agent-scoped session without an ambient default owner", async () => { + await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { + runEmbeddedAttemptMock.mockResolvedValueOnce({ + ...makeAttempt({ + assistantTexts: ["ok"], + lastAssistant: buildAssistant({ + provider: "openai", + model: "mock-1", + stopReason: "stop", + content: [{ type: "text", text: "ok" }], + }), + }), + }); + + await runEmbeddedAgentInline({ + sessionId: "session:work", + sessionKey: "agent:work:dashboard:scoped-run", + workspaceDir, + agentDir, + config: { + ...makeConfig(), + agents: { entries: { main: {}, work: {} } }, + }, + prompt: "hello", + provider: "openai", + model: "mock-1", + authProfileId: "openai:p1", + authProfileIdSource: "auto", + timeoutMs: 5_000, + runId: "run:work", + }); + + expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1); + }); + }); + it("does not persist auth profile bookkeeping for read-only probes", async () => { await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { await writeAuthStore(agentDir); diff --git a/src/agents/embedded-agent-runner/run-orchestrator.ts b/src/agents/embedded-agent-runner/run-orchestrator.ts index 98519a1e6ddb..79ed3811fb30 100644 --- a/src/agents/embedded-agent-runner/run-orchestrator.ts +++ b/src/agents/embedded-agent-runner/run-orchestrator.ts @@ -26,9 +26,9 @@ import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js" import { resolveAgentDir, resolveAgentWorkspaceDir, - resolveDefaultAgentDir, resolveRunModelFallbacksOverride, } from "../agent-scope.js"; +import { resolveLegacyInheritedAuthDir } from "../legacy-inherited-auth-dir.js"; import { resolveModelCandidateChain } from "../model-fallback-candidates.js"; import { acquireAgentRunPreparedModelRuntime, @@ -250,7 +250,9 @@ async function runEmbeddedAgentInternal( config, agentId: requestedWorkspaceResolution.agentId, agentDir: requestedAgentDir, - inheritedAuthDir: resolveDefaultAgentDir(config), + // Shared credential inheritance stays anchored to its compatibility owner; + // the selected session agent already owns this prepared runtime. + inheritedAuthDir: resolveLegacyInheritedAuthDir(config), workspaceDir: requestedWorkspaceResolution.workspaceDir, preserveWorkspaceDirOnRefresh: !requestedWorkspaceResolution.isCanonicalWorkspace, ...(params.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true } : {}), diff --git a/ui/src/components/sidebar-attention.test.ts b/ui/src/components/sidebar-attention.test.ts index c8e151f7e9da..691cb0395e6f 100644 --- a/ui/src/components/sidebar-attention.test.ts +++ b/ui/src/components/sidebar-attention.test.ts @@ -179,7 +179,7 @@ describe("sidebar attention refresh ownership", () => { "cron.list": [firstCron, secondCron], "models.authStatus": [firstAuth, secondAuth], }; - const request = vi.fn((method: keyof typeof responses) => { + const request = vi.fn((method: keyof typeof responses, _params?: unknown) => { const response = responses[method].shift(); if (!response) { throw new Error(`Unexpected request: ${method}`); @@ -226,6 +226,9 @@ describe("sidebar attention refresh ownership", () => { provider.append(element); document.body.append(provider); await waitForFast(() => expect(request).toHaveBeenCalledTimes(2)); + expect(request.mock.calls.find(([method]) => method === "models.authStatus")?.[1]).toEqual({ + agentId: "main", + }); document.dispatchEvent(new Event("visibilitychange")); await waitForFast(() => expect(request).toHaveBeenCalledTimes(4)); diff --git a/ui/src/components/sidebar-attention.ts b/ui/src/components/sidebar-attention.ts index 8f849cf64cf9..2a91545adc8e 100644 --- a/ui/src/components/sidebar-attention.ts +++ b/ui/src/components/sidebar-attention.ts @@ -77,7 +77,12 @@ class SidebarAttention extends OpenClawLightDomContentsElement { ]; if (refreshModelAuth) { loads.push( - loadModelAuthStatus(client, { signal }) + loadModelAuthStatus(client, { + signal, + ...(gateway.snapshot.assistantAgentId + ? { agentId: gateway.snapshot.assistantAgentId } + : {}), + }) .catch(() => null) .then((modelAuthStatus) => { if (!signal.aborted) { diff --git a/ui/src/pages/chat/chat-pane-base.ts b/ui/src/pages/chat/chat-pane-base.ts index 687d88fdb397..62cdd4f4cea6 100644 --- a/ui/src/pages/chat/chat-pane-base.ts +++ b/ui/src/pages/chat/chat-pane-base.ts @@ -55,6 +55,7 @@ import { } from "./chat-session-companion.ts"; import { ChatStateController } from "./chat-state-controller.ts"; import type { ChatPageHost } from "./chat-state-host.ts"; +import { resolveChatAgentId } from "./chat-state-route.ts"; import type { ChatPaneHeaderAction } from "./components/chat-pane-header.ts"; import type { SessionRailCommand, SessionRailMode } from "./components/chat-session-rail.ts"; import type { ChatSessionSharingState } from "./components/chat-session-sharing.ts"; @@ -254,26 +255,28 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { return; } const sessionKey = state.sessionKey; + const agentId = resolveChatAgentId(state); this.requestSessionRail("open"); if (!state.connected || !state.client) { - this.sessionCompanionThreads.setDraft(sessionKey, question, state.assistantAgentId); + this.sessionCompanionThreads.setDraft(sessionKey, question, agentId); return; } const client = state.client; await this.sessionCompanionThreads.submit( sessionKey, question, - (key, value) => requestSessionCompanionAnswer(client, key, value, state.assistantAgentId), - state.assistantAgentId, + (key, value) => requestSessionCompanionAnswer(client, key, value, agentId), + agentId, ); }; protected readonly prefillSessionCompanionQuestion = (question: string) => { - const sessionKey = this.state?.sessionKey; + const state = this.state; + const sessionKey = state?.sessionKey; if (!sessionKey) { return; } - this.sessionCompanionThreads.setDraft(sessionKey, question, this.state?.assistantAgentId); + this.sessionCompanionThreads.setDraft(sessionKey, question, resolveChatAgentId(state)); this.requestSessionRail("open"); }; @@ -282,7 +285,8 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { if (!state?.connected || !state.client || !sessionKey || parseCatalogSessionKey(sessionKey)) { return; } - const hydrationKey = `${this.connectionGeneration}\0${state.assistantAgentId ?? ""}\0${sessionKey}`; + const agentId = resolveChatAgentId(state); + const hydrationKey = `${this.connectionGeneration}\0${agentId}\0${sessionKey}`; if (this.sessionCompanionHydrationKey === hydrationKey) { return; } @@ -290,8 +294,8 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { this.ensureSessionRail(); void this.sessionCompanionThreads.hydrate( sessionKey, - (key) => requestSessionCompanionState(state.client!, key, state.assistantAgentId), - state.assistantAgentId, + (key) => requestSessionCompanionState(state.client!, key, agentId), + agentId, ); } @@ -300,12 +304,9 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { if (!state?.connected || !state.client || !state.sessionKey) { return; } + const agentId = resolveChatAgentId(state); await this.sessionCompanionThreads - .reset( - state.sessionKey, - (key) => resetSessionCompanion(state.client!, key, state.assistantAgentId), - state.assistantAgentId, - ) + .reset(state.sessionKey, (key) => resetSessionCompanion(state.client!, key, agentId), agentId) .catch(() => undefined); }; protected resetConfirmation: diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index 85b34698e6e6..8e87625bb650 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -302,13 +302,13 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { observerLastReadAt: selectedSession?.lastReadAt, sessionRailCompanion: catalogKey ? undefined - : this.sessionCompanionThreads.view(state.sessionKey, state.assistantAgentId), + : this.sessionCompanionThreads.view(state.sessionKey, currentAgentId), ...this.sessionRailCommandProps(state.sessionKey), sessionRailMode: this.selectedSessionRailMode(state.sessionKey), sessionRailDocked: !catalogKey && chatMainWidth >= SESSION_RAIL_SIDE_MIN_PANE_WIDTH, onSessionRailSubmit: (question) => void this.submitSessionCompanionQuestion(question), onSessionRailDraftChange: (draft) => - this.sessionCompanionThreads.setDraft(state.sessionKey, draft, state.assistantAgentId), + this.sessionCompanionThreads.setDraft(state.sessionKey, draft, currentAgentId), onSessionRailClear: () => void this.clearSessionCompanion(), onSessionRailModeChange: (mode) => { if (state.sessionKey !== this.sessionRailModeSessionKey || mode !== this.sessionRailMode) { diff --git a/ui/src/pages/chat/chat-pane-session-hydration.test.ts b/ui/src/pages/chat/chat-pane-session-hydration.test.ts index 9ed104b10240..a31f9e74079e 100644 --- a/ui/src/pages/chat/chat-pane-session-hydration.test.ts +++ b/ui/src/pages/chat/chat-pane-session-hydration.test.ts @@ -27,6 +27,8 @@ describe("chat pane session hydration", () => { } as unknown as SessionCapability; const client = { request } as unknown as GatewayBrowserClient; const { pane, state } = createTestChatPane({ client, sessions }); + state.assistantAgentId = "main"; + state.sessionKey = "agent:work:current"; pane.context.gateway.snapshot.hello = { features: { methods: [SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD, "session.discussion.info"], @@ -65,6 +67,9 @@ describe("chat pane session hydration", () => { "sessions.companion.state", SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD, ]); + expect( + request.mock.calls.find(([method]) => method === "sessions.companion.state")?.[1], + ).toEqual({ sessionKey: state.sessionKey, agentId: "work" }); expect(complete).toHaveBeenCalledOnce(); }); diff --git a/ui/src/pages/chat/chat-state-refresh.ts b/ui/src/pages/chat/chat-state-refresh.ts index 780d8370fb7d..5fe21df1a314 100644 --- a/ui/src/pages/chat/chat-state-refresh.ts +++ b/ui/src/pages/chat/chat-state-refresh.ts @@ -280,7 +280,10 @@ export async function refreshChatModelAuthStatus(host: ChatPageHost, opts?: { re const client = host.client; const connectionEpoch = host.connectionEpoch; try { - const result = await loadModelAuthStatus(client, opts); + const result = await loadModelAuthStatus(client, { + ...opts, + agentId: resolveChatAgentId(host), + }); if (host.client !== client || !host.connected || host.connectionEpoch !== connectionEpoch) { return; } diff --git a/ui/src/pages/chat/chat-state.test.ts b/ui/src/pages/chat/chat-state.test.ts index e24698d73b0b..caca45505c6f 100644 --- a/ui/src/pages/chat/chat-state.test.ts +++ b/ui/src/pages/chat/chat-state.test.ts @@ -1754,6 +1754,23 @@ describe("refreshChatMetadata", () => { }); describe("refreshChatModelAuthStatus", () => { + it("scopes auth status to the selected session agent", async () => { + const request = vi.fn(async () => ({ ts: 1, providers: [] })); + const state = { + client: { request }, + connected: true, + connectionEpoch: 1, + sessionKey: "agent:work:dashboard:current", + assistantAgentId: "main", + modelAuthStatusResult: null, + modelAuthStatusError: null, + } as unknown as ChatPageHost; + + await refreshChatModelAuthStatus(state); + + expect(request).toHaveBeenCalledWith("models.authStatus", { agentId: "work" }); + }); + it.each(["success", "failure"] as const)( "ignores a stale auth status %s after reconnecting the same client", async (outcome) => { From 7dbe21b9cff2c8a177c180de3bd2e3f0e95dafb6 Mon Sep 17 00:00:00 2001 From: Feng Date: Thu, 13 Aug 2026 11:18:15 +0800 Subject: [PATCH 2/6] fix(agents): hydrate CLI images from agent workspaces (#122684) * fix(agents): hydrate CLI images from agent workspace * fix(agents): preserve resolved CLI workspace owner * fix(agents): keep workspace owner in prepared params * fix(agents): resolve CLI owner before preparation * fix(agents): preserve CLI runtime policy owner --------- Co-authored-by: Adkid-Zephyr <169631528+Adkid-Zephyr@users.noreply.github.com> Co-authored-by: FullerStackDev <263060202+fuller-stack-dev@users.noreply.github.com> --- src/agents/cli-runner.helpers.test.ts | 50 +++++++++++++++++++++++++++ src/agents/cli-runner.spawn.test.ts | 27 +++++++++++++++ src/agents/cli-runner/execute.ts | 2 ++ src/agents/cli-runner/helpers.ts | 2 ++ src/agents/cli-runner/prepare.test.ts | 31 ++++++++++++++++- src/agents/cli-runner/prepare.ts | 8 +++-- 6 files changed, 116 insertions(+), 4 deletions(-) diff --git a/src/agents/cli-runner.helpers.test.ts b/src/agents/cli-runner.helpers.test.ts index 94b9d81fec3c..fb6eb946e692 100644 --- a/src/agents/cli-runner.helpers.test.ts +++ b/src/agents/cli-runner.helpers.test.ts @@ -9,6 +9,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createSolidPngBuffer } from "../../test/helpers/image-fixtures.js"; import { buildInboundMediaNoteProjection } from "../auto-reply/media-note.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; +import { getAgentScopedMediaLocalRoots } from "../media/local-roots.js"; import { escapeRegExp } from "../shared/regexp.js"; import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; import { @@ -107,6 +108,55 @@ describe("prepareCliPromptImagePayload prompt references", () => { } }); + it("hydrates structured media from the active agent workspace without widening sibling access", async () => { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-agent-image-")); + const workspaceDir = path.join(stateDir, "workspace-arthur"); + const siblingWorkspaceDir = path.join(stateDir, "workspace-merlin"); + const imagePath = path.join(workspaceDir, "media", "inbound", "photo.png"); + const siblingImagePath = path.join(siblingWorkspaceDir, "media", "inbound", "photo.png"); + const image = createSolidPngBuffer(1, 1, { r: 255, g: 0, b: 0 }); + await fs.mkdir(path.dirname(imagePath), { recursive: true }); + await fs.mkdir(path.dirname(siblingImagePath), { recursive: true }); + await fs.writeFile(imagePath, image); + await fs.writeFile(siblingImagePath, image); + const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); + const config = { + agents: { + entries: { + arthur: { default: true, workspace: workspaceDir }, + merlin: { workspace: siblingWorkspaceDir }, + }, + }, + }; + + try { + const localRoots = getAgentScopedMediaLocalRoots(config, "arthur"); + const prepared = await prepareCliPromptImagePayload({ + backend: { command: "claude", input: "stdin" }, + prompt: "describe the attachment", + workspaceDir, + localRoots, + media: [{ path: imagePath, contentType: "image/png" }], + }); + + expect(prepared.imagePaths).toHaveLength(1); + await expect(fs.readFile(prepared.imagePaths?.[0] ?? "")).resolves.toEqual(image); + await expect( + prepareCliPromptImagePayload({ + backend: { command: "claude", input: "stdin" }, + prompt: "describe the attachment", + workspaceDir, + localRoots, + media: [{ path: siblingImagePath, contentType: "image/png" }], + }), + ).rejects.toThrow("failed to hydrate 1 structured image attachment"); + } finally { + envSnapshot.restore(); + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + it("dedupes repeated refs and skips failed loads before sanitizing", async () => { const workspaceDir = await fs.mkdtemp( path.join(resolvePreferredOpenClawTmpDir(), "openclaw-cli-ref-dedupe-"), diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index 87edbb63b244..00c37632e840 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createSolidPngBuffer } from "../../test/helpers/image-fixtures.js"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { markMcpLoopbackToolCallFinished, @@ -149,6 +150,32 @@ async function createCliPackageFixture(version: string): Promise<{ } describe("runCliAgent spawn path", () => { + it("hydrates a session-key-owned agent workspace image before spawning the CLI", async () => { + const stateDir = tempDirs.make("openclaw-cli-agent-image-"); + const workspaceDir = path.join(stateDir, "workspace-arthur"); + const imagePath = path.join(workspaceDir, "media", "inbound", "photo.png"); + const image = createSolidPngBuffer(1, 1, { r: 255, g: 0, b: 0 }); + await fs.mkdir(path.dirname(imagePath), { recursive: true }); + await fs.writeFile(imagePath, image); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + mockSuccessfulCliRun(CLAUDE_OK_JSONL); + const context = buildPreparedCliRunContext({ + sessionKey: "agent:arthur:main", + agentId: "arthur", + workspaceDir, + config: { + agents: { entries: { arthur: { default: true, workspace: workspaceDir } } }, + }, + backend: { imageArg: "--image" }, + }); + context.params.media = [{ path: imagePath, contentType: "image/png" }]; + + await expect(executePreparedCliRun(context)).resolves.toMatchObject({ text: "ok" }); + const spawn = requireRecord(mockCallArg(supervisorSpawnMock), "CLI spawn"); + const hydratedPath = requireArgAfter(spawn.argv as string[], "--image"); + await expect(fs.readFile(hydratedPath)).resolves.toEqual(image); + }); + it("formats output digests without logging response content", () => { expect(formatCliBackendOutputDigest("one")).toBe("outBytes=3 outHash=7692c3ad3540"); expect(formatCliBackendOutputDigest("∑")).toBe("outBytes=3 outHash=be27c7179a61"); diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index 84731e11f863..2c5a2c008a6c 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -6,6 +6,7 @@ import { isTruthyEnvValue } from "../../infra/env.js"; import { formatErrorMessage, toErrorObject } from "../../infra/errors.js"; import { sanitizeHostExecEnv } from "../../infra/host-env-security.js"; import { compareValidSemver } from "../../infra/semver.js"; +import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import type { CliBackendThinkingLevel } from "../../plugins/cli-backend.types.js"; import { applySkillEnvOverridesFromSnapshot } from "../../skills/runtime/env-overrides.js"; import { appendBootstrapPromptWarning } from "../bootstrap-budget.js"; @@ -185,6 +186,7 @@ export async function executePreparedCliRun( prompt, imagePrompt: params.imagePrompt, workspaceDir: context.workspaceDir, + localRoots: getAgentScopedMediaLocalRoots(params.config ?? {}, params.agentId), images: params.images, imageOrder: params.imageOrder, media: params.media, diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index 5053bdde8079..0dcedb85f084 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -388,6 +388,7 @@ export async function prepareCliPromptImagePayload(params: { prompt: string; imagePrompt?: string; workspaceDir: string; + localRoots?: readonly string[]; images?: ImageContent[]; imageOrder?: PromptImageOrderEntry[]; media?: MediaFact[]; @@ -411,6 +412,7 @@ export async function prepareCliPromptImagePayload(params: { existingImages: params.images, imageOrder: params.imageOrder, maxBytes: MAX_IMAGE_BYTES, + localRoots: params.localRoots, }) : undefined; if (imageResult?.failedMediaCount) { diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 69ca9b3cd8f6..d9085a226de8 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -419,6 +419,34 @@ describe("prepareCliRunContext", () => { fixture.cleanup(); }); + it("carries the session-key-derived workspace owner into prepared params", async () => { + const { dir } = fixture.session; + const arthurWorkspace = path.join(dir, "workspace-arthur"); + const normalizeConfig = vi.fn((config: CliBackendPlugin["config"]) => config); + setRawCliBackendForPrepareTest({ ...defaultTestCliBackend, normalizeConfig }); + const config = { + agents: { + list: [ + { id: "main", default: true, workspace: path.join(dir, "workspace-main") }, + { id: "arthur", workspace: arthurWorkspace }, + ], + }, + } satisfies OpenClawConfig; + const context = await fixture.prepare({ + sessionKey: "agent:arthur:main", + workspaceDir: arthurWorkspace, + config, + }); + + expect(normalizeConfig).toHaveBeenCalledWith(expect.any(Object), { + backendId: "test-cli", + agentId: "arthur", + config, + }); + expect(context.params.agentId).toBe("arthur"); + expect(context.workspaceDir).toBe(arthurWorkspace); + }); + it("honors an explicit auth agent directory independently of session identity", async () => { const { dir } = fixture.session; const modelOwnerAgentDir = path.join(dir, "ops-agent"); @@ -4289,7 +4317,7 @@ describe("prepareCliRunContext", () => { expect(getLiveSessionGeneration).toHaveBeenCalledWith({ backendId: "claude-cli", agentAccountId: undefined, - agentId: undefined, + agentId: "main", authProfileId: undefined, sessionId: "session-test", sessionKey: "agent:main:telegram:direct:peer", @@ -4303,6 +4331,7 @@ describe("prepareCliRunContext", () => { mode: "reuse", sessionId: "warm-claude-sid", }); + expect(context.params.agentId).toBe("main"); expect(context.requiredClaudeLiveSessionGeneration).toBe("warm-live-generation"); expect(context.openClawHistoryPrompt).toContain("earlier warm context"); expect(context.openClawHistoryPrompt).toContain("warm follow-up"); diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 7f52f4e4600d..dae7acec53cd 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -444,7 +444,7 @@ export async function prepareCliRunContext( preparedRunAdmission: candidate.preparedRunAdmission, }); const { preparedRunAdmission: _preparedRunAdmission, ...rest } = candidate; - return { ...rest, admittedRunContext }; + return { ...rest, agentId: workspaceResolution.agentId, admittedRunContext }; }; const runtimeChatType = params.chatType ?? params.sessionEntry?.chatType; const workspaceResolution = resolveRunWorkspaceDir({ @@ -466,8 +466,10 @@ export async function prepareCliRunContext( const cwd = params.cwd ? resolveUserPath(params.cwd) : workspaceDir; const cwdHash = hashCliSessionText(cwd); + // params.agentId may identify a distinct runtime-policy requester. Backend + // config and managed process reuse must key from the resolved session owner. const backendResolved = resolveCliBackendConfig(params.provider, params.config, { - agentId: params.agentId, + agentId: workspaceResolution.agentId, }); if (!backendResolved) { throw new Error(`Unknown CLI backend: ${params.provider}`); @@ -1414,7 +1416,7 @@ export async function prepareCliRunContext( prepareDeps.getClaudeGeneration({ backendId: backendResolved.id, agentAccountId: params.agentAccountId, - agentId: params.agentId, + agentId: workspaceResolution.agentId, authProfileId: effectiveAuthProfileId, sessionId: params.sessionId, sessionKey: params.sessionKey, From 061c9c2f7f5899255b057f828a1f82a122152f24 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 20:20:18 -0700 Subject: [PATCH 3/6] fix(ai): honor embedded transport policy (#122946) --- .../run/attempt-stream-settle.test.ts | 71 ++++++++++++++++++- .../run/attempt-stream-settle.ts | 1 + 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/agents/embedded-agent-runner/run/attempt-stream-settle.test.ts b/src/agents/embedded-agent-runner/run/attempt-stream-settle.test.ts index f8d8c9e9741c..0ebe08151c20 100644 --- a/src/agents/embedded-agent-runner/run/attempt-stream-settle.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-stream-settle.test.ts @@ -1,10 +1,15 @@ // Settlement liveness: a wedged block-reply flush must not park the turn. import { afterEach, describe, expect, it, vi } from "vitest"; +import { bindStreamLlmRuntime } from "../../../llm/model-runtime-binding.js"; import { SessionManager } from "../../sessions/index.js"; import { RUN_LIVENESS_JOIN_TIMEOUT_MS } from "./abortable.js"; -import { settleEmbeddedAttemptStream } from "./attempt-stream-settle.js"; +import { + prepareEmbeddedAttemptTransport, + settleEmbeddedAttemptStream, +} from "./attempt-stream-settle.js"; type SettleInput = Parameters[0]; +type PrepareTransportInput = Parameters[0]; function createSettleFixture(overrides?: Partial): SettleInput { const sessionManager = SessionManager.inMemory(); @@ -99,3 +104,67 @@ describe("settleEmbeddedAttemptStream liveness", () => { expect(result.sessionIdUsed).toBe("sess-settle-1"); }); }); + +describe("prepareEmbeddedAttemptTransport", () => { + it("applies the prepared transport to the live agent owner", async () => { + const streamFn = vi.fn(); + bindStreamLlmRuntime(streamFn, { + streamSimple: streamFn, + registry: { getApiProvider: () => undefined }, + } as never); + const session = { + agent: { + streamFn, + transport: "auto", + }, + }; + const input = { + attempt: { + config: {}, + model: { + api: "test-api", + provider: "test-provider", + id: "test-model", + }, + modelId: "test-model", + provider: "test-provider", + promptCacheKey: undefined, + resolvedApiKey: undefined, + runId: "run-transport-1", + runtimePlan: { + auth: { forwardedAuthProfileId: undefined }, + transport: { + resolveExtraParams: () => ({ transport: "sse" }), + }, + }, + sessionId: "sess-transport-1", + }, + session, + settingsManager: { + getGlobalSettings: () => ({}), + getProjectSettings: () => ({}), + }, + providerThinkingLevel: undefined, + sessionAgentId: "main", + workspaceDir: "/workspace", + workspaceOnly: false, + agentDir: "/agent", + abortSignal: new AbortController().signal, + getProviderRuntimeHandle: () => ({ + provider: "test-provider", + modelId: "test-model", + }), + sandboxSessionKey: "agent:main:test", + codeModeControlsEnabled: false, + providerPromptState: { + state: {}, + effectiveContextTokenBudget: 128_000, + }, + } as unknown as PrepareTransportInput; + + const result = await prepareEmbeddedAttemptTransport(input); + + expect(result.effectiveAgentTransport).toBe("sse"); + expect(session.agent.transport).toBe("sse"); + }); +}); diff --git a/src/agents/embedded-agent-runner/run/attempt-stream-settle.ts b/src/agents/embedded-agent-runner/run/attempt-stream-settle.ts index cc07d4af66a7..bcbca7aa248c 100644 --- a/src/agents/embedded-agent-runner/run/attempt-stream-settle.ts +++ b/src/agents/embedded-agent-runner/run/attempt-stream-settle.ts @@ -629,6 +629,7 @@ export async function prepareEmbeddedAttemptTransport(input: { `(${attempt.provider}/${attempt.modelId})`, ); } + session.agent.transport = effectiveAgentTransport; return { effectiveAgentTransport, effectiveExtraParams, From ffb2ed9e8955903f9cb0df26633d86155fe5d28d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 20:26:30 -0700 Subject: [PATCH 4/6] refactor: remove residual normalization aliases (#122956) * refactor: remove residual normalization aliases * test(gateway): isolate approval authority handshake --- .../src/harness/prompt-template-arguments.ts | 13 ++++------ src/cli/argv.ts | 6 +---- src/cli/program/helpers.test.ts | 25 +------------------ src/cli/program/helpers.ts | 8 ------ .../register.status-health-sessions.ts | 3 +-- src/commands/message.ts | 4 +-- .../exec-approval.agent-runtime.test.ts | 4 +++ src/model-catalog/provider-index/normalize.ts | 6 +---- src/plugins/installed-plugin-index-store.ts | 10 +++----- ui/src/app/settings-normalizers.ts | 19 -------------- ui/src/app/settings.ts | 4 +-- ui/src/pages/cron/form-suggestions.ts | 15 ++++++----- 12 files changed, 27 insertions(+), 90 deletions(-) delete mode 100644 ui/src/app/settings-normalizers.ts diff --git a/packages/agent-core/src/harness/prompt-template-arguments.ts b/packages/agent-core/src/harness/prompt-template-arguments.ts index 7d288556a31a..b39e7de3b383 100644 --- a/packages/agent-core/src/harness/prompt-template-arguments.ts +++ b/packages/agent-core/src/harness/prompt-template-arguments.ts @@ -1,3 +1,5 @@ +import { parseStrictNonNegativeInteger } from "@openclaw/normalization-core/number-coercion"; + export interface PromptTemplate { name: string; description?: string; @@ -39,11 +41,6 @@ export function parseCommandArgs(argsString: string): string[] { return args; } -function parseSafeNonNegativeInteger(raw: string): number | undefined { - const parsed = Number(raw); - return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined; -} - /** * Substitute prompt template placeholders (`$1`, `$@`, `$ARGUMENTS`, `${@:N}`, `${@:N:L}`) with command arguments. * @@ -53,7 +50,7 @@ function parseSafeNonNegativeInteger(raw: string): number | undefined { export function substituteArgs(content: string, args: string[]): string { let result = content; result = result.replace(/\$(\d+)/g, (_, num: string) => { - const parsed = parseSafeNonNegativeInteger(num); + const parsed = parseStrictNonNegativeInteger(num); if (parsed === undefined || parsed <= 0) { return ""; } @@ -62,7 +59,7 @@ export function substituteArgs(content: string, args: string[]): string { result = result.replace( /\$\{@:(\d+)(?::(\d+))?\}/g, (_, startStr: string, lengthStr?: string) => { - const parsedStart = parseSafeNonNegativeInteger(startStr); + const parsedStart = parseStrictNonNegativeInteger(startStr); if (parsedStart === undefined) { return ""; } @@ -73,7 +70,7 @@ export function substituteArgs(content: string, args: string[]): string { start = 0; } if (lengthStr) { - const length = parseSafeNonNegativeInteger(lengthStr); + const length = parseStrictNonNegativeInteger(lengthStr); if (length === undefined) { return ""; } diff --git a/src/cli/argv.ts b/src/cli/argv.ts index 91314724f873..e62635e71c12 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -78,10 +78,6 @@ export function isHelpOrVersionInvocation(argv: string[]): boolean { return false; } -function parsePositiveInt(value: string): number | undefined { - return parseStrictPositiveInteger(value); -} - export function hasFlag(argv: string[], name: string): boolean { const args = argv.slice(2); for (const arg of args) { @@ -497,7 +493,7 @@ export function getPositiveIntFlagValue(argv: string[], name: string): number | } // Keep absent distinct from present-but-invalid so route-first callers can // defer invalid input to Commander instead of silently applying defaults. - return parsePositiveInt(raw) ?? null; + return parseStrictPositiveInteger(raw) ?? null; } export function getCommandPathWithRootOptions(argv: string[], depth = 2): string[] { diff --git a/src/cli/program/helpers.test.ts b/src/cli/program/helpers.test.ts index cb49bebd45df..de9aa0dd7c88 100644 --- a/src/cli/program/helpers.test.ts +++ b/src/cli/program/helpers.test.ts @@ -1,10 +1,6 @@ // Program helper tests cover shared command registration and help helpers. import { describe, expect, it } from "vitest"; -import { - collectOption, - parsePositiveIntOrUndefined, - parseStrictPositiveIntOption, -} from "./helpers.js"; +import { collectOption, parseStrictPositiveIntOption } from "./helpers.js"; describe("program helpers", () => { it("collectOption appends values in order", () => { @@ -12,25 +8,6 @@ describe("program helpers", () => { expect(collectOption("b", ["a"])).toEqual(["a", "b"]); }); - it.each([ - { value: undefined, expected: undefined }, - { value: null, expected: undefined }, - { value: "", expected: undefined }, - { value: 5, expected: 5 }, - { value: 5.9, expected: undefined }, - { value: 0, expected: undefined }, - { value: -1, expected: undefined }, - { value: Number.NaN, expected: undefined }, - { value: "10", expected: 10 }, - { value: "10ms", expected: undefined }, - { value: "1.5", expected: undefined }, - { value: "0", expected: undefined }, - { value: "nope", expected: undefined }, - { value: true, expected: undefined }, - ])("parsePositiveIntOrUndefined(%j)", ({ value, expected }) => { - expect(parsePositiveIntOrUndefined(value)).toBe(expected); - }); - it("parseStrictPositiveIntOption rejects partial numeric strings", () => { expect(parseStrictPositiveIntOption("10", "--limit")).toBe(10); expect(() => parseStrictPositiveIntOption("10ms", "--limit")).toThrow( diff --git a/src/cli/program/helpers.ts b/src/cli/program/helpers.ts index 7547bc6025b1..59d59c0323c9 100644 --- a/src/cli/program/helpers.ts +++ b/src/cli/program/helpers.ts @@ -7,14 +7,6 @@ export function collectOption(value: string, previous: string[] = []): string[] return [...previous, value]; } -/** Parse an optional positive integer, treating empty values as unset. */ -export function parsePositiveIntOrUndefined(value: unknown): number | undefined { - if (value === undefined || value === null || value === "") { - return undefined; - } - return parseStrictPositiveInteger(value); -} - /** Commander argument parser for required positive integer options. */ export function parseStrictPositiveIntOption(value: string, flag: string): number { const parsed = parseStrictPositiveInteger(value); diff --git a/src/cli/program/register.status-health-sessions.ts b/src/cli/program/register.status-health-sessions.ts index 8b4ced03b83e..554e9316e403 100644 --- a/src/cli/program/register.status-health-sessions.ts +++ b/src/cli/program/register.status-health-sessions.ts @@ -7,7 +7,6 @@ import { setVerbose } from "../../globals.js"; import { defaultRuntime } from "../../runtime.js"; import { runCommandWithRuntime } from "../cli-utils.js"; import { formatHelpExamples } from "../help-format.js"; -import { parsePositiveIntOrUndefined } from "./helpers.js"; function resolveVerbose(opts: { verbose?: boolean; debug?: boolean }): boolean { return Boolean(opts.verbose || opts.debug); @@ -209,7 +208,7 @@ function registerSessionsLifecycleCommand( } function parseTimeoutMs(timeout: unknown): number | null | undefined { - const parsed = parsePositiveIntOrUndefined(timeout); + const parsed = parseStrictPositiveInteger(timeout); if (timeout !== undefined && parsed === undefined) { defaultRuntime.error("--timeout must be a positive integer (milliseconds)"); defaultRuntime.exit(1); diff --git a/src/commands/message.ts b/src/commands/message.ts index e4d8adc33c37..2af22330f4bc 100644 --- a/src/commands/message.ts +++ b/src/commands/message.ts @@ -1,4 +1,5 @@ /** CLI entrypoint for channel message actions. */ +import { parseStrictPositiveInteger } from "@openclaw/normalization-core/number-coercion"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -15,7 +16,6 @@ import { formatCliCommand } from "../cli/command-format.js"; import { getScopedChannelsCommandSecretTargets } from "../cli/command-secret-targets.js"; import { resolveMessageSecretScope } from "../cli/message-secret-scope.js"; import { createOutboundSendDeps, type CliDeps } from "../cli/outbound-send-deps.js"; -import { parsePositiveIntOrUndefined } from "../cli/program/helpers.js"; import { withProgress } from "../cli/progress.js"; import { getRuntimeConfig } from "../config/config.js"; import type { OutboundSendDeps } from "../infra/outbound/deliver.js"; @@ -159,7 +159,7 @@ export async function messageCommand( } const { formatMessageCliText } = await import("./message-format.js"); - const displayLimit = parsePositiveIntOrUndefined(opts.limit); + const displayLimit = parseStrictPositiveInteger(opts.limit); for (const line of formatMessageCliText(result, { displayLimit })) { runtime.log(line); } diff --git a/src/gateway/server-methods/exec-approval.agent-runtime.test.ts b/src/gateway/server-methods/exec-approval.agent-runtime.test.ts index 64c81ff717e9..36f64f6923ac 100644 --- a/src/gateway/server-methods/exec-approval.agent-runtime.test.ts +++ b/src/gateway/server-methods/exec-approval.agent-runtime.test.ts @@ -12,6 +12,10 @@ import { createChatRunState } from "../server-chat-state.js"; import { createExecApprovalHandlers } from "./exec-approval.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; +vi.mock("../../infra/command-analysis/explain.js", () => ({ + resolveCommandAnalysisSummaryForDisplay: vi.fn(async () => null), +})); + const tempDirs = useAutoCleanupTempDirTracker(afterEach); function databaseOptions(): OpenClawStateDatabaseOptions { diff --git a/src/model-catalog/provider-index/normalize.ts b/src/model-catalog/provider-index/normalize.ts index bda0f41ce542..c89da6be0da3 100644 --- a/src/model-catalog/provider-index/normalize.ts +++ b/src/model-catalog/provider-index/normalize.ts @@ -74,10 +74,6 @@ function normalizePlugin(value: unknown): OpenClawProviderIndexPlugin | undefine }; } -function normalizeCategories(value: unknown): readonly string[] { - return normalizeUniqueTrimmedStringList(value); -} - function normalizePreviewCatalog(params: { providerId: string; value: unknown; @@ -192,7 +188,7 @@ function normalizeProvider( return undefined; } const docs = normalizeOptionalString(value.docs) ?? ""; - const categories = normalizeCategories(value.categories); + const categories = normalizeUniqueTrimmedStringList(value.categories); const authChoices = normalizeAuthChoices({ providerId, providerName: name, diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts index 0dd0204baec9..4227809d6398 100644 --- a/src/plugins/installed-plugin-index-store.ts +++ b/src/plugins/installed-plugin-index-store.ts @@ -228,10 +228,6 @@ function assertWritableInstalledPluginIndexStoreOptions( } } -function parseJsonColumn(value: string): unknown { - return safeParseJson(value); -} - function parseInstalledPluginIndexSqliteRow( row: InstalledPluginIndexSqliteRow | undefined, ): InstalledPluginIndex | null { @@ -247,9 +243,9 @@ function parseInstalledPluginIndexSqliteRow( policyHash: row.policy_hash, generatedAtMs: Number(row.generated_at_ms), ...(row.refresh_reason ? { refreshReason: row.refresh_reason } : {}), - installRecords: parseJsonColumn(row.install_records_json), - plugins: parseJsonColumn(row.plugins_json), - diagnostics: parseJsonColumn(row.diagnostics_json), + installRecords: safeParseJson(row.install_records_json), + plugins: safeParseJson(row.plugins_json), + diagnostics: safeParseJson(row.diagnostics_json), }); } diff --git a/ui/src/app/settings-normalizers.ts b/ui/src/app/settings-normalizers.ts deleted file mode 100644 index fb5d65e1bc67..000000000000 --- a/ui/src/app/settings-normalizers.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Persisted settings normalizers shared by the settings storage owner. - -/** Unknown shapes fall back to []; stale and duplicate ids are dropped. */ -export function normalizePinnedAgentIds(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - const pinned: string[] = []; - for (const entry of value) { - if (typeof entry !== "string") { - continue; - } - const agentId = entry.trim(); - if (agentId && !pinned.includes(agentId)) { - pinned.push(agentId); - } - } - return pinned; -} diff --git a/ui/src/app/settings.ts b/ui/src/app/settings.ts index 2ebd30b99e87..a25e54a504f5 100644 --- a/ui/src/app/settings.ts +++ b/ui/src/app/settings.ts @@ -1,6 +1,7 @@ import { gatewayOriginScope } from "@openclaw/gateway-client/browser"; import { safeParseJson } from "@openclaw/normalization-core"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { normalizeUniqueTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; import { DEFAULT_SIDEBAR_ENTRIES, normalizeSidebarEntries, @@ -19,7 +20,6 @@ import { import { normalizeChatSplitLayout, type ChatSplitLayout } from "../pages/chat/split-layout.ts"; import { resolveControlUiBasePath } from "./browser.ts"; import { parseImportedCustomTheme, type ImportedCustomTheme } from "./custom-theme.ts"; -import { normalizePinnedAgentIds } from "./settings-normalizers.ts"; import { parseThemeSelection, type ThemeMode, type ThemeName } from "./theme.ts"; import { normalizeLocalUserIdentity, type LocalUserIdentity } from "./user-identity.ts"; @@ -546,7 +546,7 @@ export function loadSettings(): UiSettings { typeof parsed.showAdvancedSettings === "boolean" ? parsed.showAdvancedSettings : defaults.showAdvancedSettings, - pinnedAgentIds: normalizePinnedAgentIds(parsed.pinnedAgentIds), + pinnedAgentIds: normalizeUniqueTrimmedStringList(parsed.pinnedAgentIds), textScale: typeof parsed.textScale === "number" && normalizeTextScale(parsed.textScale) !== UI_APPEARANCE_DEFAULTS.textScale diff --git a/ui/src/pages/cron/form-suggestions.ts b/ui/src/pages/cron/form-suggestions.ts index a67ffd5a6972..770f5ac2edfe 100644 --- a/ui/src/pages/cron/form-suggestions.ts +++ b/ui/src/pages/cron/form-suggestions.ts @@ -1,4 +1,4 @@ -import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization"; +import { normalizeSortedUniqueTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; import type { AgentsListResult } from "../../api/types.ts"; import type { ApplicationContext } from "../../app/context.ts"; import { listSelectableAgents } from "../../lib/agents/display.ts"; @@ -12,10 +12,6 @@ import { resolveCronTimezoneSuggestions } from "./timezone-suggestions.ts"; export const THINKING_SUGGESTIONS = ["off", "minimal", "low", "medium", "high"]; -function unique(values: string[]): string[] { - return sortUniqueStrings(values.map((value) => value.trim()).filter(Boolean)); -} - export function buildCronSuggestions(params: { channels: ApplicationContext["channels"]["state"]; runtimeConfig: ApplicationContext["runtimeConfig"]["state"]; @@ -30,7 +26,7 @@ export function buildCronSuggestions(params: { .filter((entry) => entry.kind === "system") .map((entry) => entry.id.trim()), ); - const agentSuggestions = unique([ + const agentSuggestions = normalizeSortedUniqueTrimmedStringList([ ...listSelectableAgents(params.agentsList?.agents ?? []).map((entry) => entry.id.trim()), ...params.cron.cronJobs.map((job) => typeof job.agentId === "string" && !systemAgentIds.has(job.agentId.trim()) @@ -38,7 +34,7 @@ export function buildCronSuggestions(params: { : "", ), ]); - const modelSuggestions = unique([ + const modelSuggestions = normalizeSortedUniqueTrimmedStringList([ ...params.modelSuggestions, ...resolveConfiguredCronModelSuggestions(configValue), ...params.cron.cronJobs.map((job) => { @@ -60,7 +56,10 @@ export function buildCronSuggestions(params: { .filter((value): value is string => typeof value === "string") .map((value) => value.trim()) .filter(Boolean); - const deliveryTargets = unique([...jobTargets, ...accountTargets]); + const deliveryTargets = normalizeSortedUniqueTrimmedStringList([ + ...jobTargets, + ...accountTargets, + ]); return { agentSuggestions, modelSuggestions, From 705f043e04a15c239f95be5b17236208d8f28a5e Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:30:46 -0500 Subject: [PATCH 5/6] fix(qa): isolate staged auth state (#122958) --- extensions/qa-lab/src/auth-profile.fixture.ts | 8 +- .../qa-lab/src/codex-plugin-lifecycle.test.ts | 21 ++-- .../src/providers/live-frontier/auth.ts | 8 +- .../src/providers/shared/auth-store.test.ts | 96 ++++++++++++++++--- .../qa-lab/src/providers/shared/auth-store.ts | 90 +++++++---------- .../qa-lab/src/providers/shared/mock-auth.ts | 5 +- 6 files changed, 142 insertions(+), 86 deletions(-) diff --git a/extensions/qa-lab/src/auth-profile.fixture.ts b/extensions/qa-lab/src/auth-profile.fixture.ts index 88524dc9b20a..021992d0e145 100644 --- a/extensions/qa-lab/src/auth-profile.fixture.ts +++ b/extensions/qa-lab/src/auth-profile.fixture.ts @@ -122,13 +122,17 @@ function normalizeAuthProfileSnapshot(value: unknown): QaAuthProfileSnapshot { export async function seedAuthProfiles( shape: QaAuthProfileShape, - agentDir: string, + params: { agentId: string; stateDir: string }, ): Promise { const snapshot = { version: QA_AUTH_PROFILE_STORE_VERSION, profiles: buildProfileMap(shape), }; - await writeQaAuthProfiles({ agentDir, profiles: snapshot.profiles, replace: true }); + await writeQaAuthProfiles({ + ...params, + profiles: snapshot.profiles, + replace: true, + }); return snapshot; } diff --git a/extensions/qa-lab/src/codex-plugin-lifecycle.test.ts b/extensions/qa-lab/src/codex-plugin-lifecycle.test.ts index 5c2893249c31..c32bb3246060 100644 --- a/extensions/qa-lab/src/codex-plugin-lifecycle.test.ts +++ b/extensions/qa-lab/src/codex-plugin-lifecycle.test.ts @@ -20,11 +20,12 @@ import { createTempDirHarness } from "./temp-dir.test-helper.js"; const tempDirs = createTempDirHarness(); -async function createAgentDir(prefix: string) { - const root = await tempDirs.makeTempDir(prefix); - const agentDir = path.join(root, "agents", "qa", "agent"); +async function createAgentState(prefix: string) { + const stateDir = await tempDirs.makeTempDir(prefix); + const agentId = "qa"; + const agentDir = path.join(stateDir, "agents", agentId, "agent"); await fs.mkdir(agentDir, { recursive: true }); - return agentDir; + return { agentDir, agentId, stateDir }; } afterEach(async () => { @@ -33,9 +34,9 @@ afterEach(async () => { describe("codex plugin lifecycle: cold install", () => { it("repairs a missing codex plugin before the retry succeeds without leaking to the API-key path", async () => { - const agentDir = await createAgentDir("qa-codex-plugin-cold-"); + const { agentDir, agentId, stateDir } = await createAgentState("qa-codex-plugin-cold-"); await removeCodexPluginFixture(agentDir); - await seedAuthProfiles("mixed", agentDir); + await seedAuthProfiles("mixed", { agentId, stateDir }); const missing = evaluateCodexPluginLifecycle({ plugin: await snapshotCodexPluginState(agentDir), @@ -61,8 +62,8 @@ describe("codex plugin lifecycle: cold install", () => { describe("codex plugin lifecycle: OAuth-only with mixed profiles", () => { it("selects openai OAuth when openai API-key profiles are present", async () => { - const agentDir = await createAgentDir("qa-codex-auth-mixed-"); - await seedAuthProfiles("mixed", agentDir); + const { agentDir, agentId, stateDir } = await createAgentState("qa-codex-auth-mixed-"); + await seedAuthProfiles("mixed", { agentId, stateDir }); const selection = resolveCodexAuthProfile(await snapshotAuthProfiles(agentDir)); @@ -104,9 +105,9 @@ describe("codex plugin lifecycle: doctor migration safety matrix", () => { ])( "keeps codex auth and strips stale OpenClaw runtime pins for $name", async ({ profileShape, config, expectedRemovedRuntimePins = [] }) => { - const agentDir = await createAgentDir("qa-codex-doctor-matrix-"); + const { agentDir, agentId, stateDir } = await createAgentState("qa-codex-doctor-matrix-"); await installCodexPluginFixture(agentDir); - await seedAuthProfiles(profileShape, agentDir); + await seedAuthProfiles(profileShape, { agentId, stateDir }); const result = evaluateCodexPluginLifecycle({ plugin: await snapshotCodexPluginState(agentDir), diff --git a/extensions/qa-lab/src/providers/live-frontier/auth.ts b/extensions/qa-lab/src/providers/live-frontier/auth.ts index ed61a4fc582f..1bdeeb1879cb 100644 --- a/extensions/qa-lab/src/providers/live-frontier/auth.ts +++ b/extensions/qa-lab/src/providers/live-frontier/auth.ts @@ -11,7 +11,7 @@ import { validateAnthropicSetupToken, } from "openclaw/plugin-sdk/provider-auth"; import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { resolveQaAgentAuthDir, writeQaAuthProfiles } from "../shared/auth-store.js"; +import { writeQaAuthProfiles } from "../shared/auth-store.js"; export const QA_LIVE_ANTHROPIC_SETUP_TOKEN_ENV = "OPENCLAW_QA_LIVE_ANTHROPIC_SETUP_TOKEN"; export const QA_LIVE_SETUP_TOKEN_VALUE_ENV = "OPENCLAW_LIVE_SETUP_TOKEN_VALUE"; @@ -197,7 +197,7 @@ export async function stageQaLiveAnthropicSetupToken(params: { return params.cfg; } await writeQaAuthProfiles({ - agentDir: resolveQaAgentAuthDir({ stateDir: params.stateDir, agentId: "main" }), + agentId: "main", profiles: { [resolved.profileId]: { type: "token", @@ -205,6 +205,7 @@ export async function stageQaLiveAnthropicSetupToken(params: { token: resolved.token, }, }, + stateDir: params.stateDir, }); return applyAuthProfileConfig(params.cfg, { profileId: resolved.profileId, @@ -260,8 +261,9 @@ export async function stageQaLiveApiKeyProfiles(params: { await Promise.all( agentIds.map((agentId) => writeQaAuthProfiles({ - agentDir: resolveQaAgentAuthDir({ stateDir: params.stateDir, agentId }), + agentId, profiles, + stateDir: params.stateDir, }), ), ); diff --git a/extensions/qa-lab/src/providers/shared/auth-store.test.ts b/extensions/qa-lab/src/providers/shared/auth-store.test.ts index 4bb72c505b75..0d0b7a08f3b4 100644 --- a/extensions/qa-lab/src/providers/shared/auth-store.test.ts +++ b/extensions/qa-lab/src/providers/shared/auth-store.test.ts @@ -1,26 +1,59 @@ // Qa Lab tests cover the SQLite-backed auth store plugin behavior. import fs from "node:fs/promises"; import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; import { loadAuthProfileStoreWithoutExternalProfiles, saveAuthProfileStore, } from "openclaw/plugin-sdk/agent-runtime"; -import { afterEach, describe, expect, it } from "vitest"; +import { + closeOpenClawAgentDatabasesForTest, + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "openclaw/plugin-sdk/sqlite-runtime-testing"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createTempDirHarness } from "../../temp-dir.test-helper.js"; import { readQaAuthProfiles, writeQaAuthProfiles } from "./auth-store.js"; const tempDirs = createTempDirHarness(); +async function createQaAuthState(prefix = "openclaw-qa-auth-store-") { + const stateDir = await tempDirs.makeTempDir(prefix); + const agentId = "main"; + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + return { + agentDir: path.join(stateDir, "agents", agentId, "agent"), + agentId, + stateDir, + }; +} + describe("QA auth profile store", () => { afterEach(async () => { + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + vi.unstubAllEnvs(); await tempDirs.cleanup(); }); - it("writes new auth profiles to SQLite without creating legacy JSON", async () => { - const agentDir = await tempDirs.makeTempDir("openclaw-qa-auth-store-"); + it("keeps inherited host shared state unchanged while staging isolated profiles", async () => { + const hostStateDir = await tempDirs.makeTempDir("openclaw-qa-auth-host-state-"); + const qaStateDir = await tempDirs.makeTempDir("openclaw-qa-auth-isolated-state-"); + const hostDatabase = openOpenClawStateDatabase({ + env: { ...process.env, OPENCLAW_STATE_DIR: hostStateDir }, + }); + const hostDatabasePath = hostDatabase.path; + closeOpenClawStateDatabaseForTest(); + const legacyHostDatabase = new DatabaseSync(hostDatabasePath); + legacyHostDatabase.exec(` + PRAGMA user_version = 6; + UPDATE schema_meta SET schema_version = 6 WHERE meta_key = 'primary'; + `); + legacyHostDatabase.close(); + vi.stubEnv("OPENCLAW_STATE_DIR", hostStateDir); await writeQaAuthProfiles({ - agentDir, + agentId: "main", profiles: { "qa-mock-openai": { type: "api_key", @@ -28,6 +61,41 @@ describe("QA auth profile store", () => { key: "qa-mock-not-a-real-key", }, }, + stateDir: qaStateDir, + }); + + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + const preservedHostDatabase = new DatabaseSync(hostDatabasePath, { readOnly: true }); + expect(preservedHostDatabase.prepare("PRAGMA user_version").get()).toEqual({ + user_version: 6, + }); + expect( + preservedHostDatabase + .prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary'") + .get(), + ).toEqual({ schema_version: 6 }); + preservedHostDatabase.close(); + vi.stubEnv("OPENCLAW_STATE_DIR", qaStateDir); + const qaAgentDir = path.join(qaStateDir, "agents", "main", "agent"); + expect(readQaAuthProfiles(qaAgentDir).profiles).toMatchObject({ + "qa-mock-openai": { provider: "openai" }, + }); + }); + + it("writes new auth profiles to SQLite without creating legacy JSON", async () => { + const { agentDir, agentId, stateDir } = await createQaAuthState(); + + await writeQaAuthProfiles({ + agentId, + profiles: { + "qa-mock-openai": { + type: "api_key", + provider: "openai", + key: "qa-mock-not-a-real-key", + }, + }, + stateDir, }); expect(readQaAuthProfiles(agentDir).profiles["qa-mock-openai"]).toMatchObject({ @@ -39,13 +107,14 @@ describe("QA auth profile store", () => { }); it("refuses to bypass a pending legacy auth source", async () => { - const agentDir = await tempDirs.makeTempDir("openclaw-qa-auth-store-"); + const { agentDir, agentId, stateDir } = await createQaAuthState(); const authPath = path.join(agentDir, "auth-profiles.json"); + await fs.mkdir(agentDir, { recursive: true }); await fs.writeFile(authPath, "{not-json", "utf8"); await expect( writeQaAuthProfiles({ - agentDir, + agentId, profiles: { "qa-mock-openai": { type: "api_key", @@ -53,15 +122,16 @@ describe("QA auth profile store", () => { key: "qa-mock-not-a-real-key", }, }, + stateDir, }), ).rejects.toThrow("requires legacy credential migration"); await expect(fs.readFile(authPath, "utf8")).resolves.toBe("{not-json"); }); it("merges canonical API-key, token, and OAuth profile shapes", async () => { - const agentDir = await tempDirs.makeTempDir("openclaw-qa-auth-store-"); + const { agentDir, agentId, stateDir } = await createQaAuthState(); await writeQaAuthProfiles({ - agentDir, + agentId, profiles: { existing: { type: "api_key", @@ -81,10 +151,11 @@ describe("QA auth profile store", () => { expires: 1_900_000_000_000, }, }, + stateDir, }); await writeQaAuthProfiles({ - agentDir, + agentId, profiles: { "qa-mock-anthropic": { type: "api_key", @@ -92,6 +163,7 @@ describe("QA auth profile store", () => { key: "qa-mock-not-a-real-key", }, }, + stateDir, }); expect(readQaAuthProfiles(agentDir).profiles).toMatchObject({ @@ -103,7 +175,8 @@ describe("QA auth profile store", () => { }); it("can replace an existing profile set for deterministic fixture seeding", async () => { - const agentDir = await tempDirs.makeTempDir("openclaw-qa-auth-store-"); + const { agentDir, agentId, stateDir } = await createQaAuthState(); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); saveAuthProfileStore( { version: 1, @@ -119,11 +192,12 @@ describe("QA auth profile store", () => { ); await writeQaAuthProfiles({ - agentDir, + agentId, profiles: { current: { type: "api_key", provider: "anthropic", key: "qa-current-not-a-real-key" }, }, replace: true, + stateDir, }); expect(Object.keys(readQaAuthProfiles(agentDir).profiles)).toEqual(["current"]); diff --git a/extensions/qa-lab/src/providers/shared/auth-store.ts b/extensions/qa-lab/src/providers/shared/auth-store.ts index 01022d71082c..e1b46ac5d7fa 100644 --- a/extensions/qa-lab/src/providers/shared/auth-store.ts +++ b/extensions/qa-lab/src/providers/shared/auth-store.ts @@ -2,75 +2,49 @@ import path from "node:path"; import { loadAuthProfileStoreWithoutExternalProfiles, - saveAuthProfileStore, - type AuthProfileStore, + type AuthProfileCredential, } from "openclaw/plugin-sdk/agent-runtime"; +import { updateAuthProfileStoreWithLock } from "openclaw/plugin-sdk/provider-auth"; -type QaAuthProfileCredential = - | { - type: "api_key"; - provider: string; - key?: string; - keyRef?: QaSecretRef; - displayName?: string; - } - | { - type: "token"; - provider: string; - token?: string; - tokenRef?: QaSecretRef; - expires?: number; - } - | { - type: "oauth"; - provider: string; - access?: string; - refresh?: string; - expires?: number; - idToken?: string; - clientId?: string; - enterpriseUrl?: string; - projectId?: string; - accountId?: string; - chatgptPlanType?: string; - oauthRef?: QaLegacyOAuthRef; - }; +type QaAuthProfileCredential = AuthProfileCredential; -type QaSecretRef = { - source: "env" | "file" | "exec" | "store"; - provider?: string; - id: string; -}; - -type QaLegacyOAuthRef = { - source: "openclaw-credentials"; - provider: "openai"; - id: string; -}; - -export function resolveQaAgentAuthDir(params: { stateDir: string; agentId: string }): string { +function resolveQaAgentAuthDir(params: { stateDir: string; agentId: string }): string { return path.join(params.stateDir, "agents", params.agentId, "agent"); } export async function writeQaAuthProfiles(params: { - agentDir: string; + agentId: string; profiles: Record; replace?: boolean; + stateDir: string; }): Promise { - const existing = loadAuthProfileStoreWithoutExternalProfiles(params.agentDir, { - inheritedAuthDir: params.agentDir, - }); - const nextStore: AuthProfileStore = params.replace - ? { version: 1, profiles: params.profiles as AuthProfileStore["profiles"] } - : { - ...existing, - version: 1, - profiles: { ...existing.profiles, ...params.profiles } as AuthProfileStore["profiles"], - }; - saveAuthProfileStore(nextStore, params.agentDir, { - filterExternalAuthProfiles: false, - syncExternalCli: false, + const agentDir = resolveQaAgentAuthDir(params); + // Surface pending legacy-source errors before the locked updater, whose + // public failure contract is intentionally nullable. + loadAuthProfileStoreWithoutExternalProfiles(agentDir, { inheritedAuthDir: agentDir }); + const updated = await updateAuthProfileStoreWithLock({ + agentDir, + stateDir: params.stateDir, + saveOptions: { + filterExternalAuthProfiles: false, + syncExternalCli: false, + }, + updater: (store) => { + store.version = 1; + store.profiles = params.replace + ? { ...params.profiles } + : { ...store.profiles, ...params.profiles }; + if (params.replace) { + delete store.order; + delete store.lastGood; + delete store.usageStats; + } + return true; + }, }); + if (!updated) { + throw new Error("Failed to stage the isolated QA auth profile store."); + } } export function readQaAuthProfiles(agentDir: string): { diff --git a/extensions/qa-lab/src/providers/shared/mock-auth.ts b/extensions/qa-lab/src/providers/shared/mock-auth.ts index c27054966450..7d349e9df4e6 100644 --- a/extensions/qa-lab/src/providers/shared/mock-auth.ts +++ b/extensions/qa-lab/src/providers/shared/mock-auth.ts @@ -2,7 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { applyAuthProfileConfig } from "openclaw/plugin-sdk/provider-auth-api-key"; import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { resolveQaAgentAuthDir, writeQaAuthProfiles } from "./auth-store.js"; +import { writeQaAuthProfiles } from "./auth-store.js"; /** Providers the mock harness stages placeholder credentials for by default. */ const QA_MOCK_AUTH_PROVIDERS = Object.freeze(["openai", "anthropic"] as const); @@ -44,7 +44,7 @@ export async function stageQaMockAuthProfiles(params: { let next = params.cfg; for (const agentId of agentIds) { await writeQaAuthProfiles({ - agentDir: resolveQaAgentAuthDir({ stateDir: params.stateDir, agentId }), + agentId, profiles: Object.fromEntries( providers.map((provider) => [ buildQaMockProfileId(provider), @@ -56,6 +56,7 @@ export async function stageQaMockAuthProfiles(params: { }, ]), ), + stateDir: params.stateDir, }); } for (const provider of providers) { From 5db09954c76b06553aa67d1b295674df8a9f9c23 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 20:34:00 -0700 Subject: [PATCH 6/6] fix(wizard): honor gateway overrides in manual flow (#122961) --- src/wizard/setup.gateway-config.test.ts | 89 ++++++++++++++++++++++++- src/wizard/setup.gateway-config.ts | 52 ++++++++++----- src/wizard/setup.test.ts | 72 ++++++++++++++++++++ src/wizard/setup.ts | 10 +-- 4 files changed, 200 insertions(+), 23 deletions(-) diff --git a/src/wizard/setup.gateway-config.test.ts b/src/wizard/setup.gateway-config.test.ts index e5d998515d15..08c177f0e1a6 100644 --- a/src/wizard/setup.gateway-config.test.ts +++ b/src/wizard/setup.gateway-config.test.ts @@ -41,7 +41,8 @@ describe("configureGatewayForSetup", () => { return buildWizardPrompter({ select, text: vi.fn(async (paramsLocal) => { - const value = textQueue.shift() as string; + const hasQueuedValue = textQueue.length > 0; + const value = hasQueuedValue ? textQueue.shift() : paramsLocal.initialValue; const error = typeof value === "string" ? paramsLocal.validate?.(value) : undefined; if (error) { throw new Error(error); @@ -106,6 +107,56 @@ describe("configureGatewayForSetup", () => { expect(result.nextConfig.gateway?.nodes?.commands).toBeUndefined(); }); + it("seeds advanced gateway prompts from explicit classic options", async () => { + const gatewayDefaults = resolveQuickstartGatewayDefaults( + {}, + { + gatewayPort: 19511, + gatewayBind: "lan", + gatewayAuth: "password", + gatewayPassword: "manual-gateway-password-placeholder", + tailscale: "off", + }, + ); + const select = vi.fn(async (params: WizardSelectParams) => { + return params.initialValue ?? params.options[0]?.value; + }) as unknown as WizardPrompter["select"]; + const text = vi.fn(async (params: { initialValue?: string }) => params.initialValue ?? ""); + const confirm = vi.fn( + async (params: { initialValue?: boolean }) => params.initialValue ?? false, + ); + const prompter = buildWizardPrompter({ select, text, confirm }); + + const result = await configureGatewayForSetup({ + flow: "advanced", + baseConfig: {}, + nextConfig: {}, + localPort: gatewayDefaults.port, + quickstartGateway: gatewayDefaults, + prompter, + runtime: createRuntime(), + }); + + expect(text).toHaveBeenCalledWith( + expect.objectContaining({ message: "Gateway port", initialValue: "19511" }), + ); + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ message: "Gateway bind address", initialValue: "lan" }), + ); + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ message: "Gateway access protection", initialValue: "password" }), + ); + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ message: "Tailscale exposure", initialValue: "off" }), + ); + expect(result.nextConfig.gateway).toMatchObject({ + port: 19511, + bind: "lan", + auth: { mode: "password", password: "manual-gateway-password-placeholder" }, + tailscale: { mode: "off" }, + }); + }); + it.each(["1e3", "0x1000"])("rejects loose gateway port input: %s", async (port) => { mocks.randomToken.mockReturnValue("generated-token"); @@ -374,6 +425,42 @@ describe("configureGatewayForSetup", () => { } }); + it("seeds an explicit env token ref into advanced gateway setup", async () => { + const previous = process.env.OPENCLAW_GATEWAY_TOKEN; + process.env.OPENCLAW_GATEWAY_TOKEN = "token-from-env-ref"; + try { + const gatewayDefaults = resolveQuickstartGatewayDefaults( + {}, + { gatewayPort: 19511, gatewayTokenRefEnv: "OPENCLAW_GATEWAY_TOKEN" }, + ); + const result = await configureGatewayForSetup({ + flow: "advanced", + baseConfig: {}, + nextConfig: {}, + localPort: gatewayDefaults.port, + quickstartGateway: gatewayDefaults, + prompter: createPrompter({ selectQueue: [], textQueue: [] }), + runtime: createRuntime(), + }); + + expect(result.nextConfig.gateway?.auth).toEqual({ + mode: "token", + token: { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN", + }, + }); + expect(result.settings.gatewayToken).toBe("token-from-env-ref"); + } finally { + if (previous === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = previous; + } + } + }); + it("persists classic quickstart overrides through gateway safety normalization", async () => { const password = ["classic", "gateway", "placeholder"].join("-"); mocks.getTailnetHostname.mockResolvedValue("test-tailnet.ts.net"); diff --git a/src/wizard/setup.gateway-config.ts b/src/wizard/setup.gateway-config.ts index 234e413d0db8..ae42316755d5 100644 --- a/src/wizard/setup.gateway-config.ts +++ b/src/wizard/setup.gateway-config.ts @@ -120,6 +120,7 @@ export async function configureGatewayForSetup( hint: t("wizard.gateway.bindCustomHint"), }, ], + initialValue: quickstartGateway.bind, }); let customBindHost = quickstartGateway.customBindHost; @@ -149,7 +150,7 @@ export async function configureGatewayForSetup( }, { value: "password", label: t("common.password") }, ], - initialValue: "token", + initialValue: quickstartGateway.authMode, })) as GatewayAuthChoice); const tailscaleMode: GatewayWizardSettings["tailscaleMode"] = @@ -158,6 +159,7 @@ export async function configureGatewayForSetup( : await prompter.select({ message: t("wizard.gateway.tailscaleExposure"), options: getLocalizedTailscaleExposureOptions(), + initialValue: quickstartGateway.tailscaleMode, }); // Detect Tailscale binary before proceeding with serve/funnel setup. @@ -173,12 +175,12 @@ export async function configureGatewayForSetup( } } - let tailscaleResetOnExit = flow === "quickstart" ? quickstartGateway.tailscaleResetOnExit : false; + let tailscaleResetOnExit = quickstartGateway.tailscaleResetOnExit; if (tailscaleMode !== "off" && flow !== "quickstart") { await prompter.note(t("wizard.gatewayTailscale.docsNote"), "Tailscale"); tailscaleResetOnExit = await prompter.confirm({ message: t("wizard.gateway.tailscaleReset"), - initialValue: false, + initialValue: tailscaleResetOnExit, }); } @@ -207,11 +209,10 @@ export async function configureGatewayForSetup( value: quickstartGateway.token, defaults: nextConfig.secrets?.defaults, }).ref; - const tokenMode = - flow === "quickstart" && opts.secretInputMode !== "ref" // pragma: allowlist secret - ? quickstartTokenRef - ? "ref" - : "plaintext" + const tokenMode = quickstartTokenRef + ? "ref" + : flow === "quickstart" && opts.secretInputMode !== "ref" // pragma: allowlist secret + ? "plaintext" : await resolveSecretInputModeForEnvSelection({ prompter, explicitMode: opts.secretInputMode, @@ -224,7 +225,7 @@ export async function configureGatewayForSetup( }, }); if (tokenMode === "ref") { - if (flow === "quickstart" && quickstartTokenRef) { + if (quickstartTokenRef) { gatewayTokenInput = quickstartTokenRef; gatewayToken = await resolveSetupSecretInputString({ config: nextConfig, @@ -280,8 +281,13 @@ export async function configureGatewayForSetup( } if (authMode === "password") { + const existingPassword = normalizeSecretInputString(quickstartGateway.password); + const existingPasswordRef = resolveSecretInputRef({ + value: quickstartGateway.password, + defaults: nextConfig.secrets?.defaults, + }).ref; let password: SecretInput | undefined = - flow === "quickstart" && quickstartGateway.password ? quickstartGateway.password : undefined; + flow === "quickstart" ? quickstartGateway.password : (existingPasswordRef ?? undefined); if (!password) { const selectedMode = await resolveSecretInputModeForEnvSelection({ prompter, @@ -305,13 +311,25 @@ export async function configureGatewayForSetup( }); password = resolved.ref; } else { - password = normalizeWizardTextInput( - await prompter.text({ - message: t("wizard.gateway.passwordPrompt"), - validate: validateGatewayPasswordInput, - sensitive: true, - }), - ); + let passwordInput: string | undefined; + if (existingPassword) { + const keep = await prompter.confirm({ + message: t("wizard.gateway.existingPasswordConfirm", { + password: maskApiKey(existingPassword), + }), + initialValue: true, + }); + passwordInput = keep ? existingPassword : undefined; + } + password = + passwordInput ?? + normalizeWizardTextInput( + await prompter.text({ + message: t("wizard.gateway.passwordPrompt"), + validate: validateGatewayPasswordInput, + sensitive: true, + }), + ); } } nextConfig = { diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 5eeac8ac9128..e24758654f70 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -2544,6 +2544,78 @@ describe("runSetupWizard", () => { ); }); + it.each([ + { + label: "explicit CLI gateway values", + gatewayOptions: { + gatewayPort: 19511, + gatewayBind: "lan" as const, + gatewayAuth: "password" as const, + gatewayToken: "manual-gateway-token-placeholder", + gatewayPassword: "manual-gateway-password-placeholder", + tailscale: "off" as const, + tailscaleResetOnExit: false, + }, + expectedPort: 19511, + expectedProbeAuth: { + token: "manual-gateway-token-placeholder", + password: "manual-gateway-password-placeholder", + }, + }, + { + label: "derived port when gateway values are omitted", + gatewayOptions: {}, + expectedPort: 18789, + expectedProbeAuth: {}, + }, + ])( + "uses the $label for the manual probe and port prompt", + async ({ gatewayOptions, expectedPort, expectedProbeAuth }) => { + const prompter = buildWizardPrompter({}); + const runtime = createRuntime(); + + await runSetupWizard( + { + acceptRisk: true, + flow: "advanced", + mode: "local", + authChoice: "skip", + ...gatewayOptions, + installDaemon: false, + skipChannels: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + }, + runtime, + prompter, + ); + + expectRecordFields( + getMockCallArg(probeGatewayReachable, 0, 0, "gateway probe"), + { url: `ws://127.0.0.1:${expectedPort}`, ...expectedProbeAuth }, + "gateway probe params", + ); + const gatewaySetup = expectRecordFields( + getMockCallArg(configureGatewayForSetup, 0, 0, "gateway setup"), + { localPort: expectedPort }, + "gateway setup params", + ); + if (gatewayOptions.gatewayPort !== undefined) { + expect(gatewaySetup.quickstartGateway).toMatchObject({ + port: 19511, + bind: "lan", + authMode: "password", + token: "manual-gateway-token-placeholder", + password: "manual-gateway-password-placeholder", + tailscaleMode: "off", + tailscaleResetOnExit: false, + }); + } + }, + ); + it("passes secretInputMode through to local gateway config step", async () => { configureGatewayForSetup.mockClear(); const prompter = buildWizardPrompter({}); diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 215d040f011b..5abcec8a56aa 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -4,7 +4,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import { resolveOnboardingAgentTarget } from "../commands/onboard-agent-target.js"; import type { GatewayAuthChoice, OnboardMode, OnboardOptions } from "../commands/onboard-types.js"; import { hasResolvedRosterBeforeMigrations } from "../config/agent-roster-provenance.js"; -import { ConfigMutationConflictError, resolveGatewayPort } from "../config/config.js"; +import { ConfigMutationConflictError } from "../config/config.js"; import { createMergePatch } from "../config/merge-patch.js"; import { applyMergePatch } from "../config/merge-patch.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; @@ -289,7 +289,7 @@ async function runSetupWizardOnce( const quickstartGateway: QuickstartGatewayDefaults = resolveQuickstartGatewayDefaults( baseConfig, - wizardFlow === "quickstart" ? opts : undefined, + opts, ); if (flow === "quickstart") { @@ -341,13 +341,13 @@ async function runSetupWizardOnce( await prompter.note(quickstartLines.join("\n"), "QuickStart"); } - const localPort = resolveGatewayPort(baseConfig); + const localPort = quickstartGateway.port; const localUrl = `ws://127.0.0.1:${localPort}`; let localGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN; try { const resolvedGatewayToken = await resolveSetupSecretInputString({ config: baseConfig, - value: baseConfig.gateway?.auth?.token, + value: quickstartGateway.token, path: "gateway.auth.token", env: process.env, }); @@ -367,7 +367,7 @@ async function runSetupWizardOnce( try { const resolvedGatewayPassword = await resolveSetupSecretInputString({ config: baseConfig, - value: baseConfig.gateway?.auth?.password, + value: quickstartGateway.password, path: "gateway.auth.password", env: process.env, });