mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
chore: merge main for landing
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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-"),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -146,6 +146,7 @@ export async function finalizeEmbeddedAgentCommand(params: {
|
||||
const { updateSessionStoreAfterAgentRun } = await loadSessionStoreRuntime();
|
||||
await updateSessionStoreAfterAgentRun({
|
||||
cfg,
|
||||
agentDir,
|
||||
contextTokensOverride: agentCfg?.contextTokens,
|
||||
sessionId: effectiveSessionId,
|
||||
sessionKey,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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,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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user