chore: merge main for landing

This commit is contained in:
Patrick Erichsen
2026-08-12 20:36:32 -07:00
46 changed files with 755 additions and 235 deletions
@@ -122,13 +122,17 @@ function normalizeAuthProfileSnapshot(value: unknown): QaAuthProfileSnapshot {
export async function seedAuthProfiles(
shape: QaAuthProfileShape,
agentDir: string,
params: { agentId: string; stateDir: string },
): Promise<QaAuthProfileSnapshot> {
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;
}
@@ -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),
@@ -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,
}),
),
);
@@ -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"]);
@@ -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<string, QaAuthProfileCredential>;
replace?: boolean;
stateDir: string;
}): Promise<void> {
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): {
@@ -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) {
@@ -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 "";
}
@@ -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<string, unknown>) {
};
}
function emitIngressModelUsageDiagnostic(
result: Parameters<typeof emitIngressModelUsageDiagnosticBase>[0],
opts: Parameters<typeof emitIngressModelUsageDiagnosticBase>[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);
+8 -5
View File
@@ -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;
+50
View File
@@ -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-"),
+27
View File
@@ -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");
+2
View File
@@ -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,
+2
View File
@@ -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) {
+30 -1
View File
@@ -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");
+5 -3
View File
@@ -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,
@@ -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 })
+1
View File
@@ -146,6 +146,7 @@ export async function finalizeEmbeddedAgentCommand(params: {
const { updateSessionStoreAfterAgentRun } = await loadSessionStoreRuntime();
await updateSessionStoreAfterAgentRun({
cfg,
agentDir,
contextTokensOverride: agentCfg?.contextTokens,
sessionId: effectiveSessionId,
sessionKey,
+74 -2
View File
@@ -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<typeof updateSessionStoreAfterAgentRunBase>[0];
async function updateSessionStoreAfterAgentRun(
params: Omit<SessionStoreUpdateParams, "agentDir"> & { 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<string, SessionEntry> = {};
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";
+2
View File
@@ -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,
}),
}),
);
@@ -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);
@@ -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 } : {}),
@@ -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<typeof settleEmbeddedAttemptStream>[0];
type PrepareTransportInput = Parameters<typeof prepareEmbeddedAttemptTransport>[0];
function createSettleFixture(overrides?: Partial<SettleInput>): 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");
});
});
@@ -629,6 +629,7 @@ export async function prepareEmbeddedAttemptTransport(input: {
`(${attempt.provider}/${attempt.modelId})`,
);
}
session.agent.transport = effectiveAgentTransport;
return {
effectiveAgentTransport,
effectiveExtraParams,
+1 -5
View File
@@ -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[] {
+1 -24
View File
@@ -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(
-8
View File
@@ -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);
@@ -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);
+2 -2
View File
@@ -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);
}
@@ -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 {
@@ -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,
+3 -7
View File
@@ -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),
});
}
+88 -1
View File
@@ -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<unknown>) => {
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");
+35 -17
View File
@@ -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<GatewayWizardSettings["tailscaleMode"]>({
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 = {
+72
View File
@@ -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({});
+5 -5
View File
@@ -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,
});
-19
View File
@@ -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;
}
+2 -2
View File
@@ -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
+4 -1
View File
@@ -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));
+6 -1
View File
@@ -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) {
+1 -1
View File
@@ -70,7 +70,7 @@ suite.define(() => {
await expect.poll(() => composer.inputValue()).toBe("/pair ");
await page.getByRole("button", { name: "Send message" }).click();
const dialog = page.getByRole("dialog", { name: "OpenClaw mobile" });
const dialog = page.getByRole("dialog", { name: "Pair a device" });
await dialog.waitFor();
expect(await gateway.getRequests("chat.send")).toEqual([]);
expect(await gateway.getRequests("device.pair.setupCode")).toEqual([]);
+14 -13
View File
@@ -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:
+2 -2
View File
@@ -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) {
@@ -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();
});
+4 -1
View File
@@ -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;
}
+17
View File
@@ -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) => {
+7 -8
View File
@@ -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,