mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-12 09:11:14 +08:00
Compare commits
31 Commits
codex/work
...
fix/ci-tes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85b0c5aea5 | ||
|
|
6edfb61d29 | ||
|
|
eb5caaf5b9 | ||
|
|
89d3117ad0 | ||
|
|
42817a1707 | ||
|
|
8c249a8cca | ||
|
|
8d7a722487 | ||
|
|
f8a0ae0b08 | ||
|
|
06e3d53c8a | ||
|
|
7815d25eef | ||
|
|
8caad53f57 | ||
|
|
769198e67e | ||
|
|
41ef752dd8 | ||
|
|
c580933623 | ||
|
|
b9d5c1a58b | ||
|
|
1d26f0cc6e | ||
|
|
75e09e21f2 | ||
|
|
a027a40c90 | ||
|
|
97f713f459 | ||
|
|
c0a16650d5 | ||
|
|
a71b810e43 | ||
|
|
ccc23f6cb6 | ||
|
|
c66703300a | ||
|
|
79cd5ed368 | ||
|
|
54d9a09912 | ||
|
|
24f8d6470e | ||
|
|
73d8d3b2eb | ||
|
|
d851f9e816 | ||
|
|
d7f9f67296 | ||
|
|
14c4d6457a | ||
|
|
1fad8efa12 |
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@@ -36,6 +36,7 @@ jobs:
|
||||
run_windows: ${{ steps.manifest.outputs.run_windows }}
|
||||
has_changed_extensions: ${{ steps.manifest.outputs.has_changed_extensions }}
|
||||
changed_extensions_matrix: ${{ steps.manifest.outputs.changed_extensions_matrix }}
|
||||
changed_paths_json: ${{ steps.manifest.outputs.changed_paths_json }}
|
||||
run_build_artifacts: ${{ steps.manifest.outputs.run_build_artifacts }}
|
||||
run_checks_fast: ${{ steps.manifest.outputs.run_checks_fast }}
|
||||
checks_fast_core_matrix: ${{ steps.manifest.outputs.checks_fast_core_matrix }}
|
||||
@@ -108,8 +109,16 @@ jobs:
|
||||
run: |
|
||||
node --input-type=module <<'EOF'
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { listChangedExtensionIds } from "./scripts/lib/changed-extensions.mjs";
|
||||
import {
|
||||
listChangedExtensionIds,
|
||||
listChangedPathsForScope,
|
||||
} from "./scripts/lib/changed-extensions.mjs";
|
||||
|
||||
const changedPaths = listChangedPathsForScope({
|
||||
base: process.env.BASE_SHA,
|
||||
head: "HEAD",
|
||||
fallbackBaseRef: process.env.BASE_REF,
|
||||
});
|
||||
const extensionIds = listChangedExtensionIds({
|
||||
base: process.env.BASE_SHA,
|
||||
head: "HEAD",
|
||||
@@ -117,9 +126,11 @@ jobs:
|
||||
unavailableBaseBehavior: "all",
|
||||
});
|
||||
const matrix = JSON.stringify({ include: extensionIds.map((extension) => ({ extension })) });
|
||||
const changedPathsJson = JSON.stringify(changedPaths);
|
||||
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_extensions=${extensionIds.length > 0}\n`, "utf8");
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8");
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `changed_paths_json=${changedPathsJson}\n`, "utf8");
|
||||
EOF
|
||||
|
||||
- name: Build CI manifest
|
||||
@@ -135,6 +146,7 @@ jobs:
|
||||
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
|
||||
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: ${{ steps.changed_extensions.outputs.has_changed_extensions || 'false' }}
|
||||
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: ${{ steps.changed_extensions.outputs.changed_extensions_matrix || '{"include":[]}' }}
|
||||
OPENCLAW_CI_CHANGED_PATHS_JSON: ${{ steps.changed_extensions.outputs.changed_paths_json || '[]' }}
|
||||
run: |
|
||||
node --input-type=module <<'EOF'
|
||||
import { appendFileSync } from "node:fs";
|
||||
@@ -203,6 +215,7 @@ jobs:
|
||||
run_windows: runWindows,
|
||||
has_changed_extensions: hasChangedExtensions,
|
||||
changed_extensions_matrix: changedExtensionsMatrix,
|
||||
changed_paths_json: process.env.OPENCLAW_CI_CHANGED_PATHS_JSON ?? "[]",
|
||||
run_build_artifacts: runNode,
|
||||
run_checks_fast: runNode,
|
||||
checks_fast_core_matrix: createMatrix(
|
||||
@@ -956,6 +969,8 @@ jobs:
|
||||
continue-on-error: true
|
||||
env:
|
||||
OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY: 4
|
||||
OPENCLAW_EXTENSION_BOUNDARY_CHANGED_EXTENSIONS_MATRIX: ${{ needs.preflight.outputs.changed_extensions_matrix }}
|
||||
OPENCLAW_EXTENSION_BOUNDARY_CHANGED_PATHS_JSON: ${{ needs.preflight.outputs.changed_paths_json }}
|
||||
run: pnpm run test:extensions:package-boundary
|
||||
|
||||
- name: Enforce safe external URL opening policy
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GoogleAuth, OAuth2Client } from "google-auth-library";
|
||||
import { fetchWithSsrFGuard } from "../runtime-api.js";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
|
||||
const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot";
|
||||
@@ -83,13 +84,20 @@ async function fetchChatCerts(): Promise<Record<string, string>> {
|
||||
if (cachedCerts && now - cachedCerts.fetchedAt < 10 * 60 * 1000) {
|
||||
return cachedCerts.certs;
|
||||
}
|
||||
const res = await fetch(CHAT_CERTS_URL);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch Chat certs (${res.status})`);
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: CHAT_CERTS_URL,
|
||||
auditContext: "googlechat.auth.certs",
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch Chat certs (${response.status})`);
|
||||
}
|
||||
const certs = (await response.json()) as Record<string, string>;
|
||||
cachedCerts = { fetchedAt: now, certs };
|
||||
return certs;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
const certs = (await res.json()) as Record<string, string>;
|
||||
cachedCerts = { fetchedAt: now, certs };
|
||||
return certs;
|
||||
}
|
||||
|
||||
export type GoogleChatAudienceType = "app-url" | "project-number";
|
||||
|
||||
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
|
||||
response: await fetch(params.url, params.init),
|
||||
release: async () => {},
|
||||
})),
|
||||
verifySignedJwtWithCertsAsync: vi.fn(),
|
||||
verifyIdToken: vi.fn(),
|
||||
getGoogleChatAccessToken: vi.fn().mockResolvedValue("token"),
|
||||
}));
|
||||
@@ -28,6 +29,7 @@ vi.mock("google-auth-library", () => ({
|
||||
GoogleAuth: function GoogleAuth() {},
|
||||
OAuth2Client: class {
|
||||
verifyIdToken = mocks.verifyIdToken;
|
||||
verifySignedJwtWithCertsAsync = mocks.verifySignedJwtWithCertsAsync;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -293,4 +295,34 @@ describe("verifyGoogleChatRequest", () => {
|
||||
reason: "unexpected add-on principal: principal-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches Chat certs through the guarded fetch for project-number tokens", async () => {
|
||||
const release = vi.fn();
|
||||
mocks.fetchWithSsrFGuard.mockClear();
|
||||
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: new Response(JSON.stringify({ "kid-1": "cert-body" }), { status: 200 }),
|
||||
release,
|
||||
});
|
||||
mocks.verifySignedJwtWithCertsAsync.mockReset().mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "project-number",
|
||||
audience: "123456789",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(mocks.fetchWithSsrFGuard).toHaveBeenCalledWith({
|
||||
url: "https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com",
|
||||
auditContext: "googlechat.auth.certs",
|
||||
});
|
||||
expect(mocks.verifySignedJwtWithCertsAsync).toHaveBeenCalledWith(
|
||||
"token",
|
||||
{ "kid-1": "cert-body" },
|
||||
"123456789",
|
||||
["chat@system.gserviceaccount.com"],
|
||||
);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
listConfiguredAccountIds,
|
||||
resolveMergedAccountConfig,
|
||||
resolveNormalizedAccountEntry,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
} from "openclaw/plugin-sdk/account-resolution-runtime";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
|
||||
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js";
|
||||
|
||||
type MatrixRoomEntries = Record<string, NonNullable<MatrixConfig["groups"]>[string]>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { LookupFn } from "../../runtime-api.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { installMatrixTestRuntime } from "../test-runtime.js";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
|
||||
function createLookupFn(addresses: Array<{ address: string; family: number }>): LookupFn {
|
||||
return vi.fn(async (_hostname: string, options?: unknown) => {
|
||||
if (typeof options === "number" || !options || !(options as { all?: boolean }).all) {
|
||||
return addresses[0];
|
||||
}
|
||||
return addresses;
|
||||
}) as unknown as LookupFn;
|
||||
}
|
||||
import {
|
||||
backfillMatrixAuthDeviceIdAfterStartup,
|
||||
resolveMatrixAuth,
|
||||
setMatrixAuthClientDepsForTest,
|
||||
} from "./client/config.js";
|
||||
import * as credentialsReadModule from "./credentials-read.js";
|
||||
|
||||
const saveMatrixCredentialsMock = vi.hoisted(() => vi.fn());
|
||||
const saveBackfilledMatrixDeviceIdMock = vi.hoisted(() => vi.fn(async () => "saved"));
|
||||
const touchMatrixCredentialsMock = vi.hoisted(() => vi.fn());
|
||||
const repairCurrentTokenStorageMetaDeviceIdMock = vi.hoisted(() => vi.fn());
|
||||
const resolveConfiguredSecretInputStringMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./credentials-read.js", () => ({
|
||||
loadMatrixCredentials: vi.fn(() => null),
|
||||
@@ -39,18 +33,10 @@ vi.mock("./client/storage.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const {
|
||||
backfillMatrixAuthDeviceIdAfterStartup,
|
||||
getMatrixScopedEnvVarNames,
|
||||
resolveMatrixConfigForAccount,
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixAuthContext,
|
||||
setMatrixAuthClientDepsForTest,
|
||||
resolveValidatedMatrixHomeserverUrl,
|
||||
validateMatrixHomeserverUrl,
|
||||
} = await import("./client/config.js");
|
||||
vi.mock("./client/config-secret-input.runtime.js", () => ({
|
||||
resolveConfiguredSecretInputString: resolveConfiguredSecretInputStringMock,
|
||||
}));
|
||||
|
||||
let credentialsReadModule: typeof import("./credentials-read.js") | undefined;
|
||||
const ensureMatrixSdkLoggingConfiguredMock = vi.fn();
|
||||
const matrixDoRequestMock = vi.fn();
|
||||
|
||||
@@ -60,721 +46,17 @@ class MockMatrixClient {
|
||||
}
|
||||
}
|
||||
|
||||
function requireCredentialsReadModule(): typeof import("./credentials-read.js") {
|
||||
if (!credentialsReadModule) {
|
||||
throw new Error("credentials-read test module not initialized");
|
||||
}
|
||||
return credentialsReadModule;
|
||||
}
|
||||
|
||||
function resolveDefaultMatrixAuthContext(
|
||||
cfg: CoreConfig,
|
||||
env: NodeJS.ProcessEnv = {} as NodeJS.ProcessEnv,
|
||||
) {
|
||||
return resolveMatrixAuthContext({ cfg, env });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
installMatrixTestRuntime();
|
||||
});
|
||||
|
||||
describe("Matrix auth/config live surfaces", () => {
|
||||
it("prefers config over env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
userId: "@cfg:example.org",
|
||||
accessToken: "cfg-token",
|
||||
password: "cfg-pass",
|
||||
deviceName: "CfgDevice",
|
||||
initialSyncLimit: 5,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://env.example.org",
|
||||
MATRIX_USER_ID: "@env:example.org",
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
MATRIX_DEVICE_NAME: "EnvDevice",
|
||||
} as NodeJS.ProcessEnv;
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
|
||||
expect(resolved).toEqual({
|
||||
homeserver: "https://cfg.example.org",
|
||||
userId: "@cfg:example.org",
|
||||
accessToken: "cfg-token",
|
||||
password: "cfg-pass",
|
||||
deviceId: undefined,
|
||||
deviceName: "CfgDevice",
|
||||
initialSyncLimit: 5,
|
||||
encryption: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses env when config is missing", () => {
|
||||
const cfg = {} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://env.example.org",
|
||||
MATRIX_USER_ID: "@env:example.org",
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
MATRIX_DEVICE_ID: "ENVDEVICE",
|
||||
MATRIX_DEVICE_NAME: "EnvDevice",
|
||||
} as NodeJS.ProcessEnv;
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
|
||||
expect(resolved.homeserver).toBe("https://env.example.org");
|
||||
expect(resolved.userId).toBe("@env:example.org");
|
||||
expect(resolved.accessToken).toBe("env-token");
|
||||
expect(resolved.password).toBe("env-pass");
|
||||
expect(resolved.deviceId).toBe("ENVDEVICE");
|
||||
expect(resolved.deviceName).toBe("EnvDevice");
|
||||
expect(resolved.initialSyncLimit).toBeUndefined();
|
||||
expect(resolved.encryption).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves accessToken SecretRef against the provided env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
|
||||
expect(resolved.accessToken).toBe("env-token");
|
||||
});
|
||||
|
||||
it("resolves password SecretRef against the provided env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
userId: "@cfg:example.org",
|
||||
password: { source: "env", provider: "default", id: "MATRIX_PASSWORD" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
|
||||
expect(resolved.password).toBe("env-pass");
|
||||
});
|
||||
|
||||
it("resolves account accessToken SecretRef against the provided env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: { source: "env", provider: "default", id: "MATRIX_OPS_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.accessToken).toBe("ops-token");
|
||||
});
|
||||
|
||||
it("does not resolve account password SecretRefs when scoped token auth is configured", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
password: { source: "env", provider: "default", id: "MATRIX_OPS_PASSWORD" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.accessToken).toBe("ops-token");
|
||||
expect(resolved.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps unresolved accessToken SecretRef errors when env fallback is missing", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() => resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv)).toThrow(
|
||||
/channels\.matrix\.accessToken: unresolved SecretRef "env:default:MATRIX_ACCESS_TOKEN"/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not bypass env provider allowlists during startup fallback", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "env", provider: "matrix-env", id: "MATRIX_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
"matrix-env": {
|
||||
source: "env",
|
||||
allowlist: ["OTHER_MATRIX_ACCESS_TOKEN"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() =>
|
||||
resolveDefaultMatrixAuthContext(cfg, {
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toThrow(/not allowlisted in secrets\.providers\.matrix-env\.allowlist/i);
|
||||
});
|
||||
|
||||
it("does not throw when accessToken uses a non-env SecretRef", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "file", provider: "matrix-file", id: "value" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
"matrix-file": {
|
||||
source: "file",
|
||||
path: "/tmp/matrix-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(
|
||||
resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv).resolved.accessToken,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses account-scoped env vars for non-default accounts before global env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://global.example.org",
|
||||
MATRIX_ACCESS_TOKEN: "global-token",
|
||||
MATRIX_OPS_HOMESERVER: "https://ops.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
MATRIX_OPS_DEVICE_NAME: "Ops Device",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.homeserver).toBe("https://ops.example.org");
|
||||
expect(resolved.accessToken).toBe("ops-token");
|
||||
expect(resolved.deviceName).toBe("Ops Device");
|
||||
});
|
||||
|
||||
it("uses collision-free scoped env var names for normalized account ids", () => {
|
||||
expect(getMatrixScopedEnvVarNames("ops-prod").accessToken).toBe(
|
||||
"MATRIX_OPS_X2D_PROD_ACCESS_TOKEN",
|
||||
);
|
||||
expect(getMatrixScopedEnvVarNames("ops_prod").accessToken).toBe(
|
||||
"MATRIX_OPS_X5F_PROD_ACCESS_TOKEN",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers channels.matrix.accounts.default over global env for the default account", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
default: {
|
||||
homeserver: "https://matrix.gumadeiras.com",
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
password: "cfg-pass", // pragma: allowlist secret
|
||||
deviceName: "OpenClaw Gateway Pinguini",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://env.example.org",
|
||||
MATRIX_USER_ID: "@env:example.org",
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
MATRIX_DEVICE_NAME: "EnvDevice",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixAuthContext({ cfg, env });
|
||||
expect(resolved.accountId).toBe("default");
|
||||
expect(resolved.resolved).toMatchObject({
|
||||
homeserver: "https://matrix.gumadeiras.com",
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
password: "cfg-pass",
|
||||
deviceName: "OpenClaw Gateway Pinguini",
|
||||
encryption: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores typoed defaultAccount values that do not map to a real Matrix account", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "ops",
|
||||
homeserver: "https://legacy.example.org",
|
||||
accessToken: "legacy-token",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe(
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires explicit defaultAccount selection when multiple named Matrix accounts exist", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.assistant.example.org",
|
||||
accessToken: "assistant-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() => resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv })).toThrow(
|
||||
/channels\.matrix\.defaultAccount.*--account <id>/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a named "default" account implicitly when multiple Matrix accounts exist', () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
default: {
|
||||
homeserver: "https://matrix.default.example.org",
|
||||
accessToken: "default-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe(
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not materialize a default account from shared top-level defaults alone", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
name: "Shared Defaults",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("does not materialize a default account from partial top-level auth defaults", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accessToken: "shared-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it('uses the injected env-backed "default" Matrix account when implicit selection is available', () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_ACCESS_TOKEN: "default-token",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("default");
|
||||
});
|
||||
|
||||
it("does not materialize a default env account from partial global auth fields", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_ACCESS_TOKEN: "shared-token",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("does not materialize a default account from top-level homeserver plus userId alone", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@default:example.org",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("does not materialize a default env account from global homeserver plus userId alone", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_USER_ID: "@default:example.org",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("keeps implicit selection for env-backed accounts that can use cached credentials", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_USER_ID: "@ops:example.org",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("rejects explicit non-default account ids that are neither configured nor scoped in env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://legacy.example.org",
|
||||
accessToken: "legacy-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() =>
|
||||
resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv, accountId: "typo" }),
|
||||
).toThrow(/Matrix account "typo" is not configured/i);
|
||||
});
|
||||
|
||||
it("allows explicit non-default account ids backed only by scoped env vars", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://legacy.example.org",
|
||||
accessToken: "legacy-token",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_HOMESERVER: "https://ops.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env, accountId: "ops" }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("does not inherit the base deviceId for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
accessToken: "base-token",
|
||||
deviceId: "BASEDEVICE",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
|
||||
expect(resolved.deviceId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not inherit the base userId for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
userId: "@base:example.org",
|
||||
accessToken: "base-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
|
||||
expect(resolved.userId).toBe("");
|
||||
});
|
||||
|
||||
it("does not inherit base or global auth secrets for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
accessToken: "base-token",
|
||||
password: "base-pass", // pragma: allowlist secret
|
||||
deviceId: "BASEDEVICE",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
password: "ops-pass", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_ACCESS_TOKEN: "global-token",
|
||||
MATRIX_PASSWORD: "global-pass",
|
||||
MATRIX_DEVICE_ID: "GLOBALDEVICE",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.accessToken).toBeUndefined();
|
||||
expect(resolved.password).toBe("ops-pass");
|
||||
expect(resolved.deviceId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not inherit a base password for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
password: "base-pass", // pragma: allowlist secret
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_PASSWORD: "global-pass",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects insecure public http Matrix homeservers", () => {
|
||||
expect(() => validateMatrixHomeserverUrl("http://matrix.example.org")).toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
expect(validateMatrixHomeserverUrl("http://127.0.0.1:8008")).toBe("http://127.0.0.1:8008");
|
||||
});
|
||||
|
||||
it("accepts internal http homeservers only when private-network access is enabled", () => {
|
||||
expect(() => validateMatrixHomeserverUrl("http://matrix-synapse:8008")).toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
expect(
|
||||
validateMatrixHomeserverUrl("http://matrix-synapse:8008", {
|
||||
allowPrivateNetwork: true,
|
||||
}),
|
||||
).toBe("http://matrix-synapse:8008");
|
||||
});
|
||||
|
||||
it("resolves an explicit proxy dispatcher from top-level Matrix config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok-123",
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv).resolved;
|
||||
|
||||
expect(resolved.dispatcherPolicy).toEqual({
|
||||
mode: "explicit-proxy",
|
||||
proxyUrl: "http://127.0.0.1:7890",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers account proxy overrides over top-level Matrix proxy config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "base-token",
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
proxy: "http://127.0.0.1:7891",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
|
||||
|
||||
expect(resolved.dispatcherPolicy).toEqual({
|
||||
mode: "explicit-proxy",
|
||||
proxyUrl: "http://127.0.0.1:7891",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects public http homeservers even when private-network access is enabled", async () => {
|
||||
await expect(
|
||||
resolveValidatedMatrixHomeserverUrl("http://matrix.example.org:8008", {
|
||||
allowPrivateNetwork: true,
|
||||
lookupFn: createLookupFn([{ address: "93.184.216.34", family: 4 }]),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts internal http hostnames when the private-network opt-in is explicit", async () => {
|
||||
await expect(
|
||||
resolveValidatedMatrixHomeserverUrl("http://localhost.localdomain:8008", {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
lookupFn: createLookupFn([{ address: "127.0.0.1", family: 4 }]),
|
||||
}),
|
||||
).resolves.toBe("http://localhost.localdomain:8008");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMatrixAuth", () => {
|
||||
beforeAll(async () => {
|
||||
credentialsReadModule = await import("./credentials-read.js");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const readModule = requireCredentialsReadModule();
|
||||
vi.mocked(readModule.loadMatrixCredentials).mockReset();
|
||||
vi.mocked(readModule.loadMatrixCredentials).mockReturnValue(null);
|
||||
vi.mocked(readModule.credentialsMatchConfig).mockReset();
|
||||
vi.mocked(readModule.credentialsMatchConfig).mockReturnValue(false);
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReset();
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue(null);
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReset();
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(false);
|
||||
saveMatrixCredentialsMock.mockReset();
|
||||
saveBackfilledMatrixDeviceIdMock.mockReset().mockResolvedValue("saved");
|
||||
touchMatrixCredentialsMock.mockReset();
|
||||
repairCurrentTokenStorageMetaDeviceIdMock.mockReset().mockReturnValue(true);
|
||||
resolveConfiguredSecretInputStringMock.mockReset().mockResolvedValue({});
|
||||
ensureMatrixSdkLoggingConfiguredMock.mockReset();
|
||||
matrixDoRequestMock.mockReset();
|
||||
setMatrixAuthClientDepsForTest({
|
||||
@@ -873,14 +155,14 @@ describe("resolveMatrixAuth", () => {
|
||||
});
|
||||
|
||||
it("uses cached matching credentials when access token is not configured", async () => {
|
||||
vi.mocked(credentialsReadModule!.loadMatrixCredentials).mockReturnValue({
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "cached-token",
|
||||
deviceId: "CACHEDDEVICE",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
vi.mocked(credentialsReadModule!.credentialsMatchConfig).mockReturnValue(true);
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
@@ -908,14 +190,14 @@ describe("resolveMatrixAuth", () => {
|
||||
});
|
||||
|
||||
it("uses cached matching credentials for env-backed named accounts without fresh auth", async () => {
|
||||
vi.mocked(credentialsReadModule!.loadMatrixCredentials).mockReturnValue({
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "cached-token",
|
||||
deviceId: "CACHEDDEVICE",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
vi.mocked(credentialsReadModule!.credentialsMatchConfig).mockReturnValue(true);
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
@@ -960,13 +242,13 @@ describe("resolveMatrixAuth", () => {
|
||||
});
|
||||
|
||||
it("falls back to config deviceId when cached credentials are missing it", async () => {
|
||||
vi.mocked(credentialsReadModule!.loadMatrixCredentials).mockReturnValue({
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
vi.mocked(credentialsReadModule!.credentialsMatchConfig).mockReturnValue(true);
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
@@ -1051,8 +333,8 @@ describe("resolveMatrixAuth", () => {
|
||||
});
|
||||
|
||||
it("uses named-account password auth instead of inheriting the base access token", async () => {
|
||||
vi.mocked(credentialsReadModule!.loadMatrixCredentials).mockReturnValue(null);
|
||||
vi.mocked(credentialsReadModule!.credentialsMatchConfig).mockReturnValue(false);
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue(null);
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(false);
|
||||
matrixDoRequestMock.mockResolvedValue({
|
||||
access_token: "ops-token",
|
||||
user_id: "@ops:example.org",
|
||||
@@ -1320,7 +602,7 @@ describe("resolveMatrixAuth", () => {
|
||||
user_id: "@bot:example.org",
|
||||
device_id: "DEVICE123",
|
||||
});
|
||||
vi.mocked(requireCredentialsReadModule().loadMatrixCredentials).mockReturnValue({
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-new",
|
||||
@@ -1377,52 +659,51 @@ describe("resolveMatrixAuth", () => {
|
||||
expect(saveBackfilledMatrixDeviceIdMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves file-backed accessToken SecretRefs during Matrix auth", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "matrix-secret-ref-"));
|
||||
const secretPath = path.join(tempDir, "token.txt");
|
||||
await fs.writeFile(secretPath, "file-token\n", "utf8");
|
||||
await fs.chmod(secretPath, 0o600);
|
||||
|
||||
it("resolves configured accessToken SecretRefs during Matrix auth", async () => {
|
||||
matrixDoRequestMock.mockResolvedValue({
|
||||
user_id: "@bot:example.org",
|
||||
device_id: "DEVICE123",
|
||||
});
|
||||
resolveConfiguredSecretInputStringMock.mockResolvedValue({ value: "resolved-token" });
|
||||
|
||||
try {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: { source: "file", provider: "matrix-file", id: "value" },
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: { source: "file", provider: "matrix-file", id: "value" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
"matrix-file": {
|
||||
source: "file",
|
||||
path: "/tmp/matrix-token.txt",
|
||||
mode: "singleValue",
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
"matrix-file": {
|
||||
source: "file",
|
||||
path: secretPath,
|
||||
mode: "singleValue",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(matrixDoRequestMock).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
|
||||
expect(auth).toMatchObject({
|
||||
accountId: "default",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "file-token",
|
||||
deviceId: "DEVICE123",
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(resolveConfiguredSecretInputStringMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: cfg,
|
||||
value: { source: "file", provider: "matrix-file", id: "value" },
|
||||
path: "channels.matrix.accessToken",
|
||||
}),
|
||||
);
|
||||
expect(matrixDoRequestMock).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
|
||||
expect(auth).toMatchObject({
|
||||
accountId: "default",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "resolved-token",
|
||||
deviceId: "DEVICE123",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not resolve inactive password SecretRefs when scoped token auth wins", async () => {
|
||||
@@ -1471,13 +752,13 @@ describe("resolveMatrixAuth", () => {
|
||||
});
|
||||
|
||||
it("uses config deviceId with cached credentials when token is loaded from cache", async () => {
|
||||
vi.mocked(credentialsReadModule!.loadMatrixCredentials).mockReturnValue({
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
vi.mocked(credentialsReadModule!.credentialsMatchConfig).mockReturnValue(true);
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
713
extensions/matrix/src/matrix/client/config.test.ts
Normal file
713
extensions/matrix/src/matrix/client/config.test.ts
Normal file
@@ -0,0 +1,713 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { LookupFn } from "../../runtime-api.js";
|
||||
import { installMatrixTestRuntime } from "../../test-runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import {
|
||||
getMatrixScopedEnvVarNames,
|
||||
resolveMatrixConfigForAccount,
|
||||
resolveMatrixAuthContext,
|
||||
resolveValidatedMatrixHomeserverUrl,
|
||||
validateMatrixHomeserverUrl,
|
||||
} from "./config.js";
|
||||
|
||||
function createLookupFn(addresses: Array<{ address: string; family: number }>): LookupFn {
|
||||
return vi.fn(async (_hostname: string, options?: unknown) => {
|
||||
if (typeof options === "number" || !options || !(options as { all?: boolean }).all) {
|
||||
return addresses[0];
|
||||
}
|
||||
return addresses;
|
||||
}) as unknown as LookupFn;
|
||||
}
|
||||
|
||||
function resolveDefaultMatrixAuthContext(
|
||||
cfg: CoreConfig,
|
||||
env: NodeJS.ProcessEnv = {} as NodeJS.ProcessEnv,
|
||||
) {
|
||||
return resolveMatrixAuthContext({ cfg, env });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
installMatrixTestRuntime();
|
||||
});
|
||||
|
||||
describe("Matrix auth/config live surfaces", () => {
|
||||
it("prefers config over env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
userId: "@cfg:example.org",
|
||||
accessToken: "cfg-token",
|
||||
password: "cfg-pass",
|
||||
deviceName: "CfgDevice",
|
||||
initialSyncLimit: 5,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://env.example.org",
|
||||
MATRIX_USER_ID: "@env:example.org",
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
MATRIX_DEVICE_NAME: "EnvDevice",
|
||||
} as NodeJS.ProcessEnv;
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
|
||||
expect(resolved).toEqual({
|
||||
homeserver: "https://cfg.example.org",
|
||||
userId: "@cfg:example.org",
|
||||
accessToken: "cfg-token",
|
||||
password: "cfg-pass",
|
||||
deviceId: undefined,
|
||||
deviceName: "CfgDevice",
|
||||
initialSyncLimit: 5,
|
||||
encryption: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses env when config is missing", () => {
|
||||
const cfg = {} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://env.example.org",
|
||||
MATRIX_USER_ID: "@env:example.org",
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
MATRIX_DEVICE_ID: "ENVDEVICE",
|
||||
MATRIX_DEVICE_NAME: "EnvDevice",
|
||||
} as NodeJS.ProcessEnv;
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
|
||||
expect(resolved.homeserver).toBe("https://env.example.org");
|
||||
expect(resolved.userId).toBe("@env:example.org");
|
||||
expect(resolved.accessToken).toBe("env-token");
|
||||
expect(resolved.password).toBe("env-pass");
|
||||
expect(resolved.deviceId).toBe("ENVDEVICE");
|
||||
expect(resolved.deviceName).toBe("EnvDevice");
|
||||
expect(resolved.initialSyncLimit).toBeUndefined();
|
||||
expect(resolved.encryption).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves accessToken SecretRef against the provided env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
|
||||
expect(resolved.accessToken).toBe("env-token");
|
||||
});
|
||||
|
||||
it("resolves password SecretRef against the provided env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
userId: "@cfg:example.org",
|
||||
password: { source: "env", provider: "default", id: "MATRIX_PASSWORD" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
|
||||
expect(resolved.password).toBe("env-pass");
|
||||
});
|
||||
|
||||
it("resolves account accessToken SecretRef against the provided env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: { source: "env", provider: "default", id: "MATRIX_OPS_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.accessToken).toBe("ops-token");
|
||||
});
|
||||
|
||||
it("does not resolve account password SecretRefs when scoped token auth is configured", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
password: { source: "env", provider: "default", id: "MATRIX_OPS_PASSWORD" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.accessToken).toBe("ops-token");
|
||||
expect(resolved.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps unresolved accessToken SecretRef errors when env fallback is missing", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() => resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv)).toThrow(
|
||||
/channels\.matrix\.accessToken: unresolved SecretRef "env:default:MATRIX_ACCESS_TOKEN"/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not bypass env provider allowlists during startup fallback", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "env", provider: "matrix-env", id: "MATRIX_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
"matrix-env": {
|
||||
source: "env",
|
||||
allowlist: ["OTHER_MATRIX_ACCESS_TOKEN"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() =>
|
||||
resolveDefaultMatrixAuthContext(cfg, {
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toThrow(/not allowlisted in secrets\.providers\.matrix-env\.allowlist/i);
|
||||
});
|
||||
|
||||
it("does not throw when accessToken uses a non-env SecretRef", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "file", provider: "matrix-file", id: "value" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
"matrix-file": {
|
||||
source: "file",
|
||||
path: "/tmp/matrix-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(
|
||||
resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv).resolved.accessToken,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses account-scoped env vars for non-default accounts before global env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://global.example.org",
|
||||
MATRIX_ACCESS_TOKEN: "global-token",
|
||||
MATRIX_OPS_HOMESERVER: "https://ops.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
MATRIX_OPS_DEVICE_NAME: "Ops Device",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.homeserver).toBe("https://ops.example.org");
|
||||
expect(resolved.accessToken).toBe("ops-token");
|
||||
expect(resolved.deviceName).toBe("Ops Device");
|
||||
});
|
||||
|
||||
it("uses collision-free scoped env var names for normalized account ids", () => {
|
||||
expect(getMatrixScopedEnvVarNames("ops-prod").accessToken).toBe(
|
||||
"MATRIX_OPS_X2D_PROD_ACCESS_TOKEN",
|
||||
);
|
||||
expect(getMatrixScopedEnvVarNames("ops_prod").accessToken).toBe(
|
||||
"MATRIX_OPS_X5F_PROD_ACCESS_TOKEN",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers channels.matrix.accounts.default over global env for the default account", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
default: {
|
||||
homeserver: "https://matrix.gumadeiras.com",
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
password: "cfg-pass", // pragma: allowlist secret
|
||||
deviceName: "OpenClaw Gateway Pinguini",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://env.example.org",
|
||||
MATRIX_USER_ID: "@env:example.org",
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
MATRIX_DEVICE_NAME: "EnvDevice",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixAuthContext({ cfg, env });
|
||||
expect(resolved.accountId).toBe("default");
|
||||
expect(resolved.resolved).toMatchObject({
|
||||
homeserver: "https://matrix.gumadeiras.com",
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
password: "cfg-pass",
|
||||
deviceName: "OpenClaw Gateway Pinguini",
|
||||
encryption: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores typoed defaultAccount values that do not map to a real Matrix account", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "ops",
|
||||
homeserver: "https://legacy.example.org",
|
||||
accessToken: "legacy-token",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe(
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires explicit defaultAccount selection when multiple named Matrix accounts exist", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.assistant.example.org",
|
||||
accessToken: "assistant-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() => resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv })).toThrow(
|
||||
/channels\.matrix\.defaultAccount.*--account <id>/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a named "default" account implicitly when multiple Matrix accounts exist', () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
default: {
|
||||
homeserver: "https://matrix.default.example.org",
|
||||
accessToken: "default-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe(
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not materialize a default account from shared top-level defaults alone", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
name: "Shared Defaults",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("does not materialize a default account from partial top-level auth defaults", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accessToken: "shared-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it('uses the injected env-backed "default" Matrix account when implicit selection is available', () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_ACCESS_TOKEN: "default-token",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("default");
|
||||
});
|
||||
|
||||
it("does not materialize a default env account from partial global auth fields", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_ACCESS_TOKEN: "shared-token",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("does not materialize a default account from top-level homeserver plus userId alone", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@default:example.org",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("does not materialize a default env account from global homeserver plus userId alone", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_USER_ID: "@default:example.org",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("keeps implicit selection for env-backed accounts that can use cached credentials", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_USER_ID: "@ops:example.org",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("rejects explicit non-default account ids that are neither configured nor scoped in env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://legacy.example.org",
|
||||
accessToken: "legacy-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() =>
|
||||
resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv, accountId: "typo" }),
|
||||
).toThrow(/Matrix account "typo" is not configured/i);
|
||||
});
|
||||
|
||||
it("allows explicit non-default account ids backed only by scoped env vars", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://legacy.example.org",
|
||||
accessToken: "legacy-token",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_HOMESERVER: "https://ops.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env, accountId: "ops" }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("does not inherit the base deviceId for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
accessToken: "base-token",
|
||||
deviceId: "BASEDEVICE",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
|
||||
expect(resolved.deviceId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not inherit the base userId for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
userId: "@base:example.org",
|
||||
accessToken: "base-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
|
||||
expect(resolved.userId).toBe("");
|
||||
});
|
||||
|
||||
it("does not inherit base or global auth secrets for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
accessToken: "base-token",
|
||||
password: "base-pass", // pragma: allowlist secret
|
||||
deviceId: "BASEDEVICE",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
password: "ops-pass", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_ACCESS_TOKEN: "global-token",
|
||||
MATRIX_PASSWORD: "global-pass",
|
||||
MATRIX_DEVICE_ID: "GLOBALDEVICE",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.accessToken).toBeUndefined();
|
||||
expect(resolved.password).toBe("ops-pass");
|
||||
expect(resolved.deviceId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not inherit a base password for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
password: "base-pass", // pragma: allowlist secret
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_PASSWORD: "global-pass",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects insecure public http Matrix homeservers", () => {
|
||||
expect(() => validateMatrixHomeserverUrl("http://matrix.example.org")).toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
expect(validateMatrixHomeserverUrl("http://127.0.0.1:8008")).toBe("http://127.0.0.1:8008");
|
||||
});
|
||||
|
||||
it("accepts internal http homeservers only when private-network access is enabled", () => {
|
||||
expect(() => validateMatrixHomeserverUrl("http://matrix-synapse:8008")).toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
expect(
|
||||
validateMatrixHomeserverUrl("http://matrix-synapse:8008", {
|
||||
allowPrivateNetwork: true,
|
||||
}),
|
||||
).toBe("http://matrix-synapse:8008");
|
||||
});
|
||||
|
||||
it("resolves an explicit proxy dispatcher from top-level Matrix config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok-123",
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv).resolved;
|
||||
|
||||
expect(resolved.dispatcherPolicy).toEqual({
|
||||
mode: "explicit-proxy",
|
||||
proxyUrl: "http://127.0.0.1:7890",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers account proxy overrides over top-level Matrix proxy config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "base-token",
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
proxy: "http://127.0.0.1:7891",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
|
||||
|
||||
expect(resolved.dispatcherPolicy).toEqual({
|
||||
mode: "explicit-proxy",
|
||||
proxyUrl: "http://127.0.0.1:7891",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects public http homeservers even when private-network access is enabled", async () => {
|
||||
await expect(
|
||||
resolveValidatedMatrixHomeserverUrl("http://matrix.example.org:8008", {
|
||||
allowPrivateNetwork: true,
|
||||
lookupFn: createLookupFn([{ address: "93.184.216.34", family: 4 }]),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts internal http hostnames when the private-network opt-in is explicit", async () => {
|
||||
await expect(
|
||||
resolveValidatedMatrixHomeserverUrl("http://localhost.localdomain:8008", {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
lookupFn: createLookupFn([{ address: "127.0.0.1", family: 4 }]),
|
||||
}),
|
||||
).resolves.toBe("http://localhost.localdomain:8008");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { retryAsync } from "openclaw/plugin-sdk/retry-runtime";
|
||||
import {
|
||||
coerceSecretRef,
|
||||
normalizeResolvedSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
} from "openclaw/plugin-sdk/secret-input-runtime";
|
||||
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher";
|
||||
import {
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
@@ -351,6 +351,15 @@ async function resolveConfiguredMatrixAuthSecretInput(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const ref = coerceSecretRef(configured.value, params.cfg.secrets?.defaults);
|
||||
if (!ref) {
|
||||
return normalizeResolvedSecretInputString({
|
||||
value: configured.value,
|
||||
path: configured.path,
|
||||
defaults: params.cfg.secrets?.defaults,
|
||||
});
|
||||
}
|
||||
|
||||
const { resolveConfiguredSecretInputString } = await loadMatrixSecretInputDeps();
|
||||
const resolved = await resolveConfiguredSecretInputString({
|
||||
config: params.cfg,
|
||||
@@ -363,13 +372,9 @@ async function resolveConfiguredMatrixAuthSecretInput(params: {
|
||||
return resolved.value;
|
||||
}
|
||||
|
||||
if (coerceSecretRef(configured.value, params.cfg.secrets?.defaults)) {
|
||||
throw new Error(
|
||||
resolved.unresolvedRefReason ?? `${configured.path} SecretRef could not be resolved.`,
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
throw new Error(
|
||||
resolved.unresolvedRefReason ?? `${configured.path} SecretRef could not be resolved.`,
|
||||
);
|
||||
}
|
||||
|
||||
function readMatrixBaseConfigField(
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveMatrixAccountStorageRoot } from "../../../runtime-api.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "../../storage-paths.js";
|
||||
import { installMatrixTestRuntime } from "../../test-runtime.js";
|
||||
import {
|
||||
claimCurrentTokenStorageState,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { z } from "openclaw/plugin-sdk/zod";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadRuntimeApiExportTypesViaJiti } from "../../../../../test/helpers/plugins/jiti-runtime-api.ts";
|
||||
import type { MatrixRoomInfo } from "./room-info.js";
|
||||
|
||||
type DirectRoomTrackerOptions = {
|
||||
@@ -949,26 +947,3 @@ describe("monitorMatrixProvider", () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("matrix plugin registration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("loads the matrix runtime api through Jiti", () => {
|
||||
const runtimeApiPath = path.join(process.cwd(), "extensions", "matrix", "runtime-api.ts");
|
||||
expect(
|
||||
loadRuntimeApiExportTypesViaJiti({
|
||||
modulePath: runtimeApiPath,
|
||||
exportNames: [
|
||||
"requiresExplicitMatrixDefaultAccount",
|
||||
"resolveMatrixDefaultOrOnlyAccountId",
|
||||
],
|
||||
realPluginSdkSpecifiers: [],
|
||||
}),
|
||||
).toEqual({
|
||||
requiresExplicitMatrixDefaultAccount: "function",
|
||||
resolveMatrixDefaultOrOnlyAccountId: "function",
|
||||
});
|
||||
}, 240_000);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "../../../../../test/helpers/temp-home.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "../../../runtime-api.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "../../storage-paths.js";
|
||||
import type { MatrixRoomKeyBackupRestoreResult } from "../sdk.js";
|
||||
import { maybeRestoreLegacyMatrixBackup } from "./legacy-crypto-restore.js";
|
||||
|
||||
|
||||
@@ -15,8 +15,10 @@ export {
|
||||
patchAllowlistUsersInConfigEntries,
|
||||
summarizeMapping,
|
||||
} from "openclaw/plugin-sdk/allow-from";
|
||||
export { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
export { createTypingCallbacks } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
export {
|
||||
createReplyPrefixOptions,
|
||||
createTypingCallbacks,
|
||||
} from "openclaw/plugin-sdk/channel-reply-options-runtime";
|
||||
export { formatLocationText, toLocationContext } from "openclaw/plugin-sdk/channel-location";
|
||||
export { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/agent-media-payload";
|
||||
export { logInboundDrop, logTypingFailure } from "openclaw/plugin-sdk/channel-logging";
|
||||
|
||||
@@ -18,6 +18,21 @@ function requestUrl(input: RequestInfo | URL | undefined): string {
|
||||
return input.url;
|
||||
}
|
||||
|
||||
const TEST_UNDICI_RUNTIME_DEPS_KEY = "__OPENCLAW_TEST_UNDICI_RUNTIME_DEPS__";
|
||||
|
||||
function clearTestUndiciRuntimeDepsOverride(): void {
|
||||
Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY);
|
||||
}
|
||||
|
||||
function stubRuntimeFetch(fetchImpl: typeof fetch): void {
|
||||
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
|
||||
Agent: function MockAgent() {},
|
||||
EnvHttpProxyAgent: function MockEnvHttpProxyAgent() {},
|
||||
ProxyAgent: function MockProxyAgent() {},
|
||||
fetch: fetchImpl,
|
||||
};
|
||||
}
|
||||
|
||||
class FakeMatrixEvent extends EventEmitter {
|
||||
private readonly roomId: string;
|
||||
private readonly eventId: string;
|
||||
@@ -224,11 +239,13 @@ describe("MatrixClient request hardening", () => {
|
||||
lastCreateClientOpts = null;
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
clearTestUndiciRuntimeDepsOverride();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
clearTestUndiciRuntimeDepsOverride();
|
||||
});
|
||||
|
||||
it("blocks absolute endpoints unless explicitly allowed", async () => {
|
||||
@@ -238,7 +255,7 @@ describe("MatrixClient request hardening", () => {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
await expect(client.doRequest("GET", "https://matrix.example.org/start")).rejects.toThrow(
|
||||
@@ -265,7 +282,7 @@ describe("MatrixClient request hardening", () => {
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
|
||||
async () => new Response(payload, { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("http://127.0.0.1:8008", "token", {
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
@@ -296,7 +313,7 @@ describe("MatrixClient request hardening", () => {
|
||||
}
|
||||
return new Response(payload, { status: 200 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("http://127.0.0.1:8008", "token", {
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
@@ -475,7 +492,7 @@ describe("MatrixClient request hardening", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("http://127.0.0.1:8008", "token", {
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
@@ -506,7 +523,7 @@ describe("MatrixClient request hardening", () => {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("http://127.0.0.1:8008", "token", {
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
@@ -531,7 +548,7 @@ describe("MatrixClient request hardening", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("http://127.0.0.1:8008", "token", {
|
||||
localTimeoutMs: 25,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { fetchWithRuntimeDispatcher } from "openclaw/plugin-sdk/runtime-fetch";
|
||||
import {
|
||||
fetchWithRuntimeDispatcherOrMockedGlobal,
|
||||
isMockedFetch,
|
||||
} from "openclaw/plugin-sdk/runtime-fetch";
|
||||
import {
|
||||
closeDispatcher,
|
||||
createPinnedDispatcher,
|
||||
@@ -10,7 +13,8 @@ import {
|
||||
export {
|
||||
closeDispatcher,
|
||||
createPinnedDispatcher,
|
||||
fetchWithRuntimeDispatcher,
|
||||
fetchWithRuntimeDispatcherOrMockedGlobal,
|
||||
isMockedFetch,
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
type PinnedDispatcherPolicy,
|
||||
type SsrFPolicy,
|
||||
|
||||
@@ -8,6 +8,15 @@ function clearTestUndiciRuntimeDepsOverride(): void {
|
||||
Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY);
|
||||
}
|
||||
|
||||
function stubRuntimeFetch(fetchImpl: typeof fetch): void {
|
||||
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
|
||||
Agent: function MockAgent() {},
|
||||
EnvHttpProxyAgent: function MockEnvHttpProxyAgent() {},
|
||||
ProxyAgent: function MockProxyAgent() {},
|
||||
fetch: fetchImpl,
|
||||
};
|
||||
}
|
||||
|
||||
describe("performMatrixRequest", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@@ -19,8 +28,7 @@ describe("performMatrixRequest", () => {
|
||||
});
|
||||
|
||||
it("rejects oversized raw responses before buffering the whole body", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
stubRuntimeFetch(
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response("too-big", {
|
||||
@@ -55,8 +63,7 @@ describe("performMatrixRequest", () => {
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
stubRuntimeFetch(
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(stream, {
|
||||
@@ -87,8 +94,7 @@ describe("performMatrixRequest", () => {
|
||||
controller.enqueue(new Uint8Array([1, 2, 3]));
|
||||
},
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
stubRuntimeFetch(
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(stream, {
|
||||
@@ -135,12 +141,7 @@ describe("performMatrixRequest", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
|
||||
Agent: function MockAgent() {},
|
||||
EnvHttpProxyAgent: function MockEnvHttpProxyAgent() {},
|
||||
ProxyAgent: function MockProxyAgent() {},
|
||||
fetch: runtimeFetch,
|
||||
};
|
||||
stubRuntimeFetch(runtimeFetch);
|
||||
|
||||
const result = await performMatrixRequest({
|
||||
homeserver: "http://127.0.0.1:8008",
|
||||
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
buildTimeoutAbortSignal,
|
||||
closeDispatcher,
|
||||
createPinnedDispatcher,
|
||||
fetchWithRuntimeDispatcherOrMockedGlobal,
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
type SsrFPolicy,
|
||||
fetchWithRuntimeDispatcher,
|
||||
type PinnedDispatcherPolicy,
|
||||
} from "./transport-runtime-api.js";
|
||||
|
||||
@@ -89,13 +89,6 @@ function buildBufferedResponse(params: {
|
||||
return response;
|
||||
}
|
||||
|
||||
function isMockedFetch(fetchImpl: typeof fetch | undefined): boolean {
|
||||
if (typeof fetchImpl !== "function") {
|
||||
return false;
|
||||
}
|
||||
return typeof (fetchImpl as typeof fetch & { mock?: unknown }).mock === "object";
|
||||
}
|
||||
|
||||
async function fetchWithMatrixDispatcher(params: {
|
||||
url: string;
|
||||
init: MatrixDispatcherRequestInit;
|
||||
@@ -104,10 +97,7 @@ async function fetchWithMatrixDispatcher(params: {
|
||||
// fetches must stay fail-closed unless a retry path can preserve the
|
||||
// validated pinned-address binding. Route dispatcher-attached requests
|
||||
// through undici runtime fetch so the pinned dispatcher is preserved.
|
||||
if (params.init.dispatcher && !isMockedFetch(globalThis.fetch)) {
|
||||
return await fetchWithRuntimeDispatcher(params.url, params.init);
|
||||
}
|
||||
return await fetch(params.url, params.init);
|
||||
return await fetchWithRuntimeDispatcherOrMockedGlobal(params.url, params.init);
|
||||
}
|
||||
|
||||
async function fetchWithMatrixGuardedRedirects(params: {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type {
|
||||
BindingTargetKind,
|
||||
SessionBindingRecord,
|
||||
} from "openclaw/plugin-sdk/thread-bindings-runtime";
|
||||
import { resolveThreadBindingLifecycle } from "openclaw/plugin-sdk/thread-bindings-runtime";
|
||||
} from "openclaw/plugin-sdk/thread-bindings-session-runtime";
|
||||
import { resolveThreadBindingLifecycle } from "openclaw/plugin-sdk/thread-bindings-session-runtime";
|
||||
|
||||
export type MatrixThreadBindingTargetKind = "subagent" | "acp";
|
||||
|
||||
|
||||
@@ -311,15 +311,17 @@ describe("matrix thread bindings", () => {
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(61_000);
|
||||
await Promise.resolve();
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const persistedRaw = await fs.readFile(resolveBindingsFilePath(), "utf-8");
|
||||
expect(JSON.parse(persistedRaw)).toMatchObject({
|
||||
version: 1,
|
||||
bindings: [],
|
||||
});
|
||||
});
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
const persistedRaw = await fs.readFile(resolveBindingsFilePath(), "utf-8");
|
||||
expect(JSON.parse(persistedRaw)).toMatchObject({
|
||||
version: 1,
|
||||
bindings: [],
|
||||
});
|
||||
},
|
||||
{ interval: 1, timeout: 100 },
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
@@ -353,23 +355,22 @@ describe("matrix thread bindings", () => {
|
||||
|
||||
renameMock.mockRejectedValueOnce(new Error("disk full"));
|
||||
await vi.advanceTimersByTimeAsync(61_000);
|
||||
await Promise.resolve();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
logVerboseMessage.mock.calls.some(
|
||||
([message]) =>
|
||||
typeof message === "string" &&
|
||||
message.includes("failed auto-unbinding expired bindings"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(logVerboseMessage).toHaveBeenCalledWith(
|
||||
expect.stringContaining("matrix: auto-unbinding $thread due to idle-expired"),
|
||||
);
|
||||
});
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(
|
||||
logVerboseMessage.mock.calls.some(
|
||||
([message]) =>
|
||||
typeof message === "string" &&
|
||||
message.includes("failed auto-unbinding expired bindings"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(logVerboseMessage).toHaveBeenCalledWith(
|
||||
expect.stringContaining("matrix: auto-unbinding $thread due to idle-expired"),
|
||||
);
|
||||
},
|
||||
{ interval: 1, timeout: 100 },
|
||||
);
|
||||
|
||||
expect(
|
||||
getSessionBindingService().resolveByConversation({
|
||||
@@ -640,9 +641,12 @@ describe("matrix thread bindings", () => {
|
||||
expect(await readPersistedLastActivityAt(bindingsPath)).toBe(originalLastActivityAt);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(async () => {
|
||||
expect(await readPersistedLastActivityAt(bindingsPath)).toBe(secondTouchedAt);
|
||||
});
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
expect(await readPersistedLastActivityAt(bindingsPath)).toBe(secondTouchedAt);
|
||||
},
|
||||
{ interval: 1, timeout: 100 },
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
@@ -661,9 +665,12 @@ describe("matrix thread bindings", () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const bindingsPath = resolveBindingsFilePath();
|
||||
await vi.waitFor(async () => {
|
||||
expect(await readPersistedLastActivityAt(bindingsPath)).toBe(touchedAt);
|
||||
});
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
expect(await readPersistedLastActivityAt(bindingsPath)).toBe(touchedAt);
|
||||
},
|
||||
{ interval: 1, timeout: 100 },
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import path from "node:path";
|
||||
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
|
||||
import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/session-key-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
registerSessionBindingAdapter,
|
||||
resolveThreadBindingFarewellText,
|
||||
type SessionBindingAdapter,
|
||||
unregisterSessionBindingAdapter,
|
||||
} from "openclaw/plugin-sdk/thread-bindings-runtime";
|
||||
} from "openclaw/plugin-sdk/thread-bindings-session-runtime";
|
||||
import { claimCurrentTokenStorageState, resolveMatrixStateFilePath } from "./client/storage.js";
|
||||
import type { MatrixAuth } from "./client/types.js";
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WizardPrompter } from "../runtime-api.js";
|
||||
import { installMatrixTestRuntime } from "./test-runtime.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
@@ -10,11 +11,11 @@ vi.mock("./resolve-targets.js", () => ({
|
||||
resolveMatrixTargets: resolveMatrixTargetsMock,
|
||||
}));
|
||||
|
||||
let runMatrixAddAccountAllowlistConfigure: typeof import("./onboarding.test-harness.js").runMatrixAddAccountAllowlistConfigure;
|
||||
let promptMatrixAllowFrom: typeof import("./onboarding.js").__testing.promptMatrixAllowFrom;
|
||||
|
||||
describe("matrix onboarding account-scoped resolution", () => {
|
||||
beforeAll(async () => {
|
||||
({ runMatrixAddAccountAllowlistConfigure } = await import("./onboarding.test-harness.js"));
|
||||
({ promptMatrixAllowFrom } = (await import("./onboarding.js")).__testing);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -27,7 +28,11 @@ describe("matrix onboarding account-scoped resolution", () => {
|
||||
});
|
||||
|
||||
it("passes accountId into Matrix allowlist target resolution during onboarding", async () => {
|
||||
const result = await runMatrixAddAccountAllowlistConfigure({
|
||||
const prompter = {
|
||||
note: vi.fn(async () => {}),
|
||||
text: vi.fn(async () => "Alice"),
|
||||
} as unknown as WizardPrompter;
|
||||
const result = await promptMatrixAllowFrom({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
@@ -36,15 +41,19 @@ describe("matrix onboarding account-scoped resolution", () => {
|
||||
homeserver: "https://matrix.main.example.org",
|
||||
accessToken: "main-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
allowFromInput: "Alice",
|
||||
roomsAllowlistInput: "",
|
||||
prompter,
|
||||
accountId: "ops",
|
||||
});
|
||||
|
||||
expect(result).not.toBe("skip");
|
||||
expect(result.channels?.matrix?.accounts?.ops?.dm?.allowFrom).toEqual(["@alice:example.org"]);
|
||||
expect(resolveMatrixTargetsMock).toHaveBeenCalledWith({
|
||||
cfg: expect.any(Object),
|
||||
accountId: "ops",
|
||||
|
||||
@@ -769,3 +769,7 @@ export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = {
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export const __testing = {
|
||||
promptMatrixAllowFrom,
|
||||
};
|
||||
|
||||
16
package.json
16
package.json
@@ -212,6 +212,10 @@
|
||||
"types": "./dist/plugin-sdk/channel-reply-pipeline.d.ts",
|
||||
"default": "./dist/plugin-sdk/channel-reply-pipeline.js"
|
||||
},
|
||||
"./plugin-sdk/channel-reply-options-runtime": {
|
||||
"types": "./dist/plugin-sdk/channel-reply-options-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/channel-reply-options-runtime.js"
|
||||
},
|
||||
"./plugin-sdk/channel-runtime": {
|
||||
"types": "./dist/plugin-sdk/channel-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/channel-runtime.js"
|
||||
@@ -284,6 +288,10 @@
|
||||
"types": "./dist/plugin-sdk/thread-bindings-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/thread-bindings-runtime.js"
|
||||
},
|
||||
"./plugin-sdk/thread-bindings-session-runtime": {
|
||||
"types": "./dist/plugin-sdk/thread-bindings-session-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/thread-bindings-session-runtime.js"
|
||||
},
|
||||
"./plugin-sdk/text-runtime": {
|
||||
"types": "./dist/plugin-sdk/text-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/text-runtime.js"
|
||||
@@ -420,6 +428,10 @@
|
||||
"types": "./dist/plugin-sdk/account-resolution.d.ts",
|
||||
"default": "./dist/plugin-sdk/account-resolution.js"
|
||||
},
|
||||
"./plugin-sdk/account-resolution-runtime": {
|
||||
"types": "./dist/plugin-sdk/account-resolution-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/account-resolution-runtime.js"
|
||||
},
|
||||
"./plugin-sdk/agent-config-primitives": {
|
||||
"types": "./dist/plugin-sdk/agent-config-primitives.d.ts",
|
||||
"default": "./dist/plugin-sdk/agent-config-primitives.js"
|
||||
@@ -676,6 +688,10 @@
|
||||
"types": "./dist/plugin-sdk/session-binding-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/session-binding-runtime.js"
|
||||
},
|
||||
"./plugin-sdk/session-key-runtime": {
|
||||
"types": "./dist/plugin-sdk/session-key-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/session-key-runtime.js"
|
||||
},
|
||||
"./plugin-sdk/session-store-runtime": {
|
||||
"types": "./dist/plugin-sdk/session-store-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/session-store-runtime.js"
|
||||
|
||||
@@ -28,6 +28,33 @@ const COMPILE_INPUT_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts", ".js",
|
||||
const ROOTDIR_BOUNDARY_CANARY_IMPORT_PATH =
|
||||
"../../src/plugins/contracts/rootdir-boundary-canary.ts";
|
||||
const ROOTDIR_BOUNDARY_CANARY_OUTPUT_HINT = "src/plugins/contracts/rootdir-boundary-canary.ts";
|
||||
const BOUNDARY_FULL_SWEEP_PATHS = new Set([
|
||||
"package.json",
|
||||
"pnpm-lock.yaml",
|
||||
"scripts/check-extension-package-tsc-boundary.mjs",
|
||||
"scripts/prepare-extension-package-boundary-artifacts.mjs",
|
||||
"scripts/write-plugin-sdk-entry-dts.ts",
|
||||
"scripts/lib/plugin-sdk-entries.mjs",
|
||||
"scripts/lib/plugin-sdk-entrypoints.json",
|
||||
"src/plugins/contracts/rootdir-boundary-canary.ts",
|
||||
"src/video-generation/dashscope-compatible.ts",
|
||||
"src/video-generation/types.ts",
|
||||
"tsconfig.json",
|
||||
"tsconfig.package-boundary.base.json",
|
||||
"tsconfig.plugin-sdk.dts.json",
|
||||
]);
|
||||
const BOUNDARY_FULL_SWEEP_PREFIXES = [
|
||||
"packages/plugin-sdk/",
|
||||
"src/channels/plugins/",
|
||||
"src/plugin-sdk/",
|
||||
"src/types/",
|
||||
];
|
||||
|
||||
function normalizeRepoPath(filePath) {
|
||||
return String(filePath ?? "")
|
||||
.replaceAll("\\", "/")
|
||||
.replace(/^\.\/+/, "");
|
||||
}
|
||||
|
||||
function parseMode(argv) {
|
||||
const modeArg = argv.find((arg) => arg.startsWith("--mode="));
|
||||
@@ -86,6 +113,9 @@ export function formatBoundaryCheckSuccessSummary(params = {}) {
|
||||
if (params.mode) {
|
||||
lines.push(`mode: ${params.mode}`);
|
||||
}
|
||||
if (params.scope) {
|
||||
lines.push(`scope: ${params.scope}`);
|
||||
}
|
||||
if (Number.isInteger(params.compileCount)) {
|
||||
lines.push(`compiled plugins: ${params.compileCount}`);
|
||||
}
|
||||
@@ -195,6 +225,97 @@ function collectCanaryExtensionIds(extensionIds) {
|
||||
];
|
||||
}
|
||||
|
||||
function isBoundaryFullSweepPath(filePath) {
|
||||
const normalizedPath = normalizeRepoPath(filePath);
|
||||
return (
|
||||
BOUNDARY_FULL_SWEEP_PATHS.has(normalizedPath) ||
|
||||
BOUNDARY_FULL_SWEEP_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix))
|
||||
);
|
||||
}
|
||||
|
||||
function parseChangedExtensionsMatrix(rawMatrix) {
|
||||
if (!rawMatrix || !rawMatrix.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawMatrix);
|
||||
if (!Array.isArray(parsed?.include)) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
...new Set(
|
||||
parsed.include
|
||||
.map((entry) => (typeof entry?.extension === "string" ? entry.extension : ""))
|
||||
.filter(Boolean),
|
||||
),
|
||||
].toSorted((left, right) => left.localeCompare(right));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseChangedPathsJson(rawPaths) {
|
||||
if (!rawPaths || !rawPaths.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawPaths);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return [...new Set(parsed.map((entry) => normalizeRepoPath(entry)).filter(Boolean))].toSorted();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveBoundaryCheckSelection(params = {}) {
|
||||
const optInExtensionIds = Array.isArray(params.optInExtensionIds)
|
||||
? [...params.optInExtensionIds]
|
||||
: [];
|
||||
const changedPaths = params.changedPaths;
|
||||
const changedExtensionIds = Array.isArray(params.changedExtensionIds)
|
||||
? [...params.changedExtensionIds]
|
||||
: [];
|
||||
const resolveCanaryIds = params.resolveCanaryExtensionIds ?? collectCanaryExtensionIds;
|
||||
|
||||
if (!Array.isArray(changedPaths) || changedPaths.length === 0) {
|
||||
return {
|
||||
scope: "full",
|
||||
compileExtensionIds: optInExtensionIds,
|
||||
canaryExtensionIds: resolveCanaryIds(optInExtensionIds),
|
||||
};
|
||||
}
|
||||
|
||||
if (changedPaths.some((filePath) => isBoundaryFullSweepPath(filePath))) {
|
||||
return {
|
||||
scope: "full",
|
||||
compileExtensionIds: optInExtensionIds,
|
||||
canaryExtensionIds: resolveCanaryIds(optInExtensionIds),
|
||||
};
|
||||
}
|
||||
|
||||
const optInExtensionIdSet = new Set(optInExtensionIds);
|
||||
const scopedExtensionIds = changedExtensionIds
|
||||
.filter((extensionId) => optInExtensionIdSet.has(extensionId))
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
if (scopedExtensionIds.length === 0) {
|
||||
return {
|
||||
scope: "skip",
|
||||
compileExtensionIds: [],
|
||||
canaryExtensionIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scope: "scoped",
|
||||
compileExtensionIds: scopedExtensionIds,
|
||||
canaryExtensionIds: resolveCanaryIds(scopedExtensionIds),
|
||||
};
|
||||
}
|
||||
|
||||
function isRelevantCompileInput(filePath) {
|
||||
const basename = path.basename(filePath);
|
||||
if (
|
||||
@@ -768,7 +889,15 @@ export async function main(argv = process.argv.slice(2)) {
|
||||
const startedAt = Date.now();
|
||||
const mode = parseMode(argv);
|
||||
const optInExtensionIds = collectOptInExtensionIds();
|
||||
const canaryExtensionIds = collectCanaryExtensionIds(optInExtensionIds);
|
||||
const selection = resolveBoundaryCheckSelection({
|
||||
optInExtensionIds,
|
||||
changedExtensionIds: parseChangedExtensionsMatrix(
|
||||
process.env.OPENCLAW_EXTENSION_BOUNDARY_CHANGED_EXTENSIONS_MATRIX,
|
||||
),
|
||||
changedPaths: parseChangedPathsJson(process.env.OPENCLAW_EXTENSION_BOUNDARY_CHANGED_PATHS_JSON),
|
||||
});
|
||||
const compileExtensionIds = selection.compileExtensionIds;
|
||||
const canaryExtensionIds = selection.canaryExtensionIds;
|
||||
const cleanupExtensionIds = optInExtensionIds;
|
||||
const shouldRunCanary = mode === "all" || mode === "canary";
|
||||
const releaseBoundaryLock = acquireBoundaryCheckLock();
|
||||
@@ -782,16 +911,17 @@ export async function main(argv = process.argv.slice(2)) {
|
||||
|
||||
try {
|
||||
cleanupCanaryArtifactsForExtensions(cleanupExtensionIds);
|
||||
if (mode === "all" || mode === "compile") {
|
||||
if ((mode === "all" || mode === "compile") && compileExtensionIds.length > 0) {
|
||||
({ prepElapsedMs, compileCount, skippedCompileCount, compileElapsedMs, compileTimings } =
|
||||
await runCompileCheck(optInExtensionIds));
|
||||
await runCompileCheck(compileExtensionIds));
|
||||
}
|
||||
if (shouldRunCanary) {
|
||||
if (shouldRunCanary && canaryExtensionIds.length > 0) {
|
||||
({ canaryElapsedMs } = await runCanaryCheck(canaryExtensionIds));
|
||||
}
|
||||
process.stdout.write(
|
||||
formatBoundaryCheckSuccessSummary({
|
||||
mode,
|
||||
scope: selection.scope,
|
||||
compileCount,
|
||||
skippedCompileCount,
|
||||
canaryCount: shouldRunCanary ? canaryExtensionIds.length : 0,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { resolveBuildRequirement } from "./run-node.mjs";
|
||||
const DEFAULTS = {
|
||||
outputDir: path.join(process.cwd(), ".local", "gateway-watch-regression"),
|
||||
windowMs: 10_000,
|
||||
readyTimeoutMs: 20_000,
|
||||
readyTimeoutMs: 5_000,
|
||||
readySettleMs: 500,
|
||||
sigkillGraceMs: 10_000,
|
||||
cpuWarnMs: 1_000,
|
||||
@@ -34,6 +34,8 @@ const WATCH_GATEWAY_SKIP_ENV = {
|
||||
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
|
||||
};
|
||||
|
||||
const ANSI_ESCAPE_PATTERN = new RegExp(String.raw`\u001B\[[0-9;]*m`, "g");
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { ...DEFAULTS };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
@@ -96,9 +98,54 @@ function normalizePath(filePath) {
|
||||
return filePath.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
function listTreeEntries(rootName) {
|
||||
const rootPath = path.join(process.cwd(), rootName);
|
||||
if (!fs.existsSync(rootPath)) {
|
||||
function isMissingPathError(error) {
|
||||
return (
|
||||
Boolean(error) &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
(error.code === "ENOENT" || error.code === "ENOTDIR")
|
||||
);
|
||||
}
|
||||
|
||||
function safeReaddirSync(fsImpl, dirPath) {
|
||||
try {
|
||||
return fsImpl.readdirSync(dirPath, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function safeLstatSync(fsImpl, filePath) {
|
||||
try {
|
||||
return fsImpl.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function stripAnsi(text) {
|
||||
return String(text ?? "").replaceAll(ANSI_ESCAPE_PATTERN, "");
|
||||
}
|
||||
|
||||
export function hasGatewayReadyLog(text) {
|
||||
return stripAnsi(text).includes("[gateway] ready (");
|
||||
}
|
||||
|
||||
export function resolveReadyObservation(readyBeforeWindow, stdout, stderr) {
|
||||
return readyBeforeWindow || hasGatewayReadyLog(stdout) || hasGatewayReadyLog(stderr);
|
||||
}
|
||||
|
||||
export function listTreeEntries(rootName, params = {}) {
|
||||
const fsImpl = params.fs ?? fs;
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
const rootPath = path.join(cwd, rootName);
|
||||
if (!fsImpl.existsSync(rootPath)) {
|
||||
return [`${rootName} (missing)`];
|
||||
}
|
||||
|
||||
@@ -109,10 +156,10 @@ function listTreeEntries(rootName) {
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
const dirents = fs.readdirSync(current, { withFileTypes: true });
|
||||
const dirents = safeReaddirSync(fsImpl, current);
|
||||
for (const dirent of dirents) {
|
||||
const fullPath = path.join(current, dirent.name);
|
||||
const relativePath = normalizePath(path.relative(process.cwd(), fullPath));
|
||||
const relativePath = normalizePath(path.relative(cwd, fullPath));
|
||||
entries.push(relativePath);
|
||||
if (dirent.isDirectory()) {
|
||||
queue.push(fullPath);
|
||||
@@ -135,10 +182,12 @@ function humanBytes(bytes) {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
|
||||
}
|
||||
|
||||
function snapshotTree(rootName) {
|
||||
const rootPath = path.join(process.cwd(), rootName);
|
||||
export function snapshotTree(rootName, params = {}) {
|
||||
const fsImpl = params.fs ?? fs;
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
const rootPath = path.join(cwd, rootName);
|
||||
const stats = {
|
||||
exists: fs.existsSync(rootPath),
|
||||
exists: fsImpl.existsSync(rootPath),
|
||||
files: 0,
|
||||
directories: 0,
|
||||
symlinks: 0,
|
||||
@@ -156,11 +205,14 @@ function snapshotTree(rootName) {
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
const currentStats = fs.lstatSync(current);
|
||||
const currentStats = safeLstatSync(fsImpl, current);
|
||||
if (!currentStats) {
|
||||
continue;
|
||||
}
|
||||
stats.entries += 1;
|
||||
if (currentStats.isDirectory()) {
|
||||
stats.directories += 1;
|
||||
for (const dirent of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
for (const dirent of safeReaddirSync(fsImpl, current)) {
|
||||
queue.push(path.join(current, dirent.name));
|
||||
}
|
||||
continue;
|
||||
@@ -312,13 +364,15 @@ function readProcessTreeCpuMs(rootPid) {
|
||||
|
||||
async function waitForGatewayReady(readText, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (/\[gateway\] ready \(/.test(readText())) {
|
||||
while (true) {
|
||||
if (hasGatewayReadyLog(readText())) {
|
||||
return true;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
return false;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function allocateLoopbackPort() {
|
||||
@@ -435,7 +489,7 @@ async function runTimedWatch(options, outputDir) {
|
||||
});
|
||||
|
||||
const exitPromise = new Promise((resolve) => {
|
||||
child.on("exit", (code, signal) => resolve({ code, signal }));
|
||||
child.on("close", (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
|
||||
let watchPid = null;
|
||||
@@ -447,7 +501,7 @@ async function runTimedWatch(options, outputDir) {
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
const readyBeforeWindow = await waitForGatewayReady(
|
||||
let readyBeforeWindow = await waitForGatewayReady(
|
||||
() => `${stdout}\n${stderr}`,
|
||||
options.readyTimeoutMs,
|
||||
);
|
||||
@@ -482,6 +536,7 @@ async function runTimedWatch(options, outputDir) {
|
||||
}
|
||||
|
||||
const exit = (await exitPromise) ?? { code: null, signal: null };
|
||||
readyBeforeWindow = resolveReadyObservation(readyBeforeWindow, stdout, stderr);
|
||||
fs.writeFileSync(stdoutPath, stdout, "utf8");
|
||||
fs.writeFileSync(stderrPath, stderr, "utf8");
|
||||
const timing = fs.existsSync(timeFilePath)
|
||||
@@ -559,11 +614,13 @@ function buildRunNodeDeps(env) {
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
export async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
ensureDir(options.outputDir);
|
||||
if (!options.skipBuild) {
|
||||
runCheckedCommand("pnpm", ["build"]);
|
||||
// This regression only needs runtime dist artifacts; plugin-sdk d.ts output
|
||||
// adds CI time without affecting watch startup behavior.
|
||||
runCheckedCommand("pnpm", ["build:ci-artifacts"]);
|
||||
// The watch harness must start from a completed-build baseline. Refresh
|
||||
// the build stamp after the full build pipeline finishes so run-node does
|
||||
// not spuriously rebuild inside the bounded watch window.
|
||||
@@ -697,4 +754,6 @@ async function main() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await main();
|
||||
if (import.meta.main) {
|
||||
await main();
|
||||
}
|
||||
|
||||
@@ -70,6 +70,11 @@ function listChangedPaths(base, head = "HEAD") {
|
||||
.filter((line) => line.length > 0);
|
||||
}
|
||||
|
||||
export function listChangedPathsForScope(params = {}) {
|
||||
const head = params.head ?? "HEAD";
|
||||
return listChangedPaths(resolveChangedPathsBase(params), head);
|
||||
}
|
||||
|
||||
function hasExtensionPackage(extensionId) {
|
||||
return fs.existsSync(path.join(repoRoot, BUNDLED_PLUGIN_ROOT_DIR, extensionId, "package.json"));
|
||||
}
|
||||
@@ -122,8 +127,7 @@ export function listChangedExtensionIds(params = {}) {
|
||||
const unavailableBaseBehavior = params.unavailableBaseBehavior ?? "error";
|
||||
|
||||
try {
|
||||
const base = resolveChangedPathsBase(params);
|
||||
return detectChangedExtensionIds(listChangedPaths(base, head));
|
||||
return detectChangedExtensionIds(listChangedPathsForScope({ ...params, head }));
|
||||
} catch (error) {
|
||||
if (unavailableBaseBehavior === "all") {
|
||||
return listAvailableExtensionIds();
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"inbound-reply-dispatch",
|
||||
"inbound-envelope",
|
||||
"channel-reply-pipeline",
|
||||
"channel-reply-options-runtime",
|
||||
"channel-runtime",
|
||||
"interactive-runtime",
|
||||
"outbound-media",
|
||||
@@ -57,6 +58,7 @@
|
||||
"matrix-runtime-heavy",
|
||||
"matrix-runtime-shared",
|
||||
"thread-bindings-runtime",
|
||||
"thread-bindings-session-runtime",
|
||||
"text-runtime",
|
||||
"text-chunking",
|
||||
"agent-runtime",
|
||||
@@ -91,6 +93,7 @@
|
||||
"account-core",
|
||||
"account-id",
|
||||
"account-resolution",
|
||||
"account-resolution-runtime",
|
||||
"agent-config-primitives",
|
||||
"allow-from",
|
||||
"allowlist-config-edit",
|
||||
@@ -155,6 +158,7 @@
|
||||
"runtime-fetch",
|
||||
"response-limit-runtime",
|
||||
"session-binding-runtime",
|
||||
"session-key-runtime",
|
||||
"session-store-runtime",
|
||||
"ssrf-dispatcher",
|
||||
"string-coerce-runtime",
|
||||
|
||||
@@ -25,6 +25,12 @@ async function loadOAuthModuleForTest() {
|
||||
({ resolveApiKeyForProfile, resetOAuthRefreshQueuesForTest } = await import("./oauth.js"));
|
||||
}
|
||||
|
||||
function resolveApiKeyForProfileInTest(
|
||||
params: Omit<Parameters<typeof resolveApiKeyForProfile>[0], "cfg">,
|
||||
) {
|
||||
return resolveApiKeyForProfile({ cfg: {}, ...params });
|
||||
}
|
||||
|
||||
const {
|
||||
refreshProviderOAuthCredentialWithPluginMock,
|
||||
formatProviderAuthProfileApiKeyWithPluginMock,
|
||||
@@ -42,12 +48,32 @@ vi.mock("../cli-credentials.js", () => ({
|
||||
writeCodexCliCredentials: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", () => ({
|
||||
getOAuthApiKey: vi.fn(async () => null),
|
||||
getOAuthProviders: () => [{ id: "openai-codex" }, { id: "anthropic" }],
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/provider-runtime.runtime.js", () => ({
|
||||
formatProviderAuthProfileApiKeyWithPlugin: (params: { context?: { access?: string } }) =>
|
||||
formatProviderAuthProfileApiKeyWithPluginMock() ?? params?.context?.access,
|
||||
refreshProviderOAuthCredentialWithPlugin: refreshProviderOAuthCredentialWithPluginMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/file-lock.js", () => ({
|
||||
resetFileLockStateForTest: () => undefined,
|
||||
withFileLock: async <T>(_filePath: string, _options: unknown, run: () => Promise<T>) => run(),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugin-sdk/file-lock.js", () => ({
|
||||
resetFileLockStateForTest: () => undefined,
|
||||
withFileLock: async <T>(_filePath: string, _options: unknown, run: () => Promise<T>) => run(),
|
||||
}));
|
||||
|
||||
vi.mock("./external-auth.js", () => ({
|
||||
overlayExternalAuthProfiles: <T>(store: T) => store,
|
||||
shouldPersistExternalAuthProfile: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("./doctor.js", () => ({
|
||||
formatAuthDoctorHint: async () => undefined,
|
||||
}));
|
||||
@@ -74,7 +100,11 @@ function storeWith(profileId: string, cred: OAuthCredential): AuthProfileStore {
|
||||
}
|
||||
|
||||
describe("OAuth credential adoption is identity-gated", () => {
|
||||
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
|
||||
const envSnapshot = captureEnv([
|
||||
"OPENCLAW_STATE_DIR",
|
||||
"OPENCLAW_AGENT_DIR",
|
||||
"PI_CODING_AGENT_DIR",
|
||||
]);
|
||||
let tempRoot = "";
|
||||
let mainAgentDir = "";
|
||||
|
||||
@@ -88,6 +118,8 @@ describe("OAuth credential adoption is identity-gated", () => {
|
||||
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-adopt-identity-"));
|
||||
process.env.OPENCLAW_STATE_DIR = tempRoot;
|
||||
mainAgentDir = path.join(tempRoot, "agents", "main", "agent");
|
||||
process.env.OPENCLAW_AGENT_DIR = mainAgentDir;
|
||||
process.env.PI_CODING_AGENT_DIR = mainAgentDir;
|
||||
await fs.mkdir(mainAgentDir, { recursive: true });
|
||||
await loadOAuthModuleForTest();
|
||||
resetOAuthRefreshQueuesForTest();
|
||||
@@ -143,7 +175,7 @@ describe("OAuth credential adoption is identity-gated", () => {
|
||||
mainAgentDir,
|
||||
);
|
||||
|
||||
const result = await resolveApiKeyForProfile({
|
||||
const result = await resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(subAgentDir),
|
||||
profileId,
|
||||
agentDir: subAgentDir,
|
||||
@@ -212,7 +244,7 @@ describe("OAuth credential adoption is identity-gated", () => {
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const result = await resolveApiKeyForProfile({
|
||||
const result = await resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(subAgentDir),
|
||||
profileId,
|
||||
agentDir: subAgentDir,
|
||||
@@ -274,7 +306,7 @@ describe("OAuth credential adoption is identity-gated", () => {
|
||||
mainAgentDir,
|
||||
);
|
||||
|
||||
const result = await resolveApiKeyForProfile({
|
||||
const result = await resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(subAgentDir),
|
||||
profileId,
|
||||
agentDir: subAgentDir,
|
||||
@@ -331,7 +363,7 @@ describe("OAuth credential adoption is identity-gated", () => {
|
||||
|
||||
// Plugin refresh must NOT be called — sub should adopt main's fresh
|
||||
// cred rather than performing its own refresh.
|
||||
const result = await resolveApiKeyForProfile({
|
||||
const result = await resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(subAgentDir),
|
||||
profileId,
|
||||
agentDir: subAgentDir,
|
||||
@@ -380,7 +412,7 @@ describe("OAuth credential adoption is identity-gated", () => {
|
||||
mainAgentDir,
|
||||
);
|
||||
|
||||
const result = await resolveApiKeyForProfile({
|
||||
const result = await resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(subAgentDir),
|
||||
profileId,
|
||||
agentDir: subAgentDir,
|
||||
@@ -444,7 +476,7 @@ describe("OAuth credential adoption is identity-gated", () => {
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const result = await resolveApiKeyForProfile({
|
||||
const result = await resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(subAgentDir),
|
||||
profileId,
|
||||
agentDir: subAgentDir,
|
||||
@@ -511,7 +543,7 @@ describe("OAuth credential adoption is identity-gated", () => {
|
||||
// Catch-block main-inherit must refuse the non-overlapping cred and
|
||||
// propagate the original error rather than leaking main's credential.
|
||||
await expect(
|
||||
resolveApiKeyForProfile({
|
||||
resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(subAgentDir),
|
||||
profileId,
|
||||
agentDir: subAgentDir,
|
||||
@@ -574,7 +606,7 @@ describe("OAuth credential adoption is identity-gated", () => {
|
||||
throw new Error("upstream 503 service unavailable");
|
||||
});
|
||||
|
||||
const result = await resolveApiKeyForProfile({
|
||||
const result = await resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(subAgentDir),
|
||||
profileId,
|
||||
agentDir: subAgentDir,
|
||||
@@ -643,7 +675,7 @@ describe("OAuth credential adoption is identity-gated", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveApiKeyForProfile({
|
||||
resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(subAgentDir),
|
||||
profileId,
|
||||
agentDir: subAgentDir,
|
||||
|
||||
@@ -18,6 +18,12 @@ async function loadOAuthModuleForTest() {
|
||||
({ resolveApiKeyForProfile, resetOAuthRefreshQueuesForTest } = await import("./oauth.js"));
|
||||
}
|
||||
|
||||
function resolveApiKeyForProfileInTest(
|
||||
params: Omit<Parameters<typeof resolveApiKeyForProfile>[0], "cfg">,
|
||||
) {
|
||||
return resolveApiKeyForProfile({ cfg: {}, ...params });
|
||||
}
|
||||
|
||||
const {
|
||||
refreshProviderOAuthCredentialWithPluginMock,
|
||||
formatProviderAuthProfileApiKeyWithPluginMock,
|
||||
@@ -35,12 +41,22 @@ vi.mock("../cli-credentials.js", () => ({
|
||||
writeCodexCliCredentials: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", () => ({
|
||||
getOAuthApiKey: vi.fn(async () => null),
|
||||
getOAuthProviders: () => [{ id: "openai-codex" }],
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/provider-runtime.runtime.js", () => ({
|
||||
formatProviderAuthProfileApiKeyWithPlugin: (params: { context?: { access?: string } }) =>
|
||||
formatProviderAuthProfileApiKeyWithPluginMock() ?? params?.context?.access,
|
||||
refreshProviderOAuthCredentialWithPlugin: refreshProviderOAuthCredentialWithPluginMock,
|
||||
}));
|
||||
|
||||
vi.mock("./external-auth.js", () => ({
|
||||
overlayExternalAuthProfiles: <T>(store: T) => store,
|
||||
shouldPersistExternalAuthProfile: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("./doctor.js", () => ({
|
||||
formatAuthDoctorHint: async () => undefined,
|
||||
}));
|
||||
@@ -79,7 +95,11 @@ function createExpiredOauthStore(params: {
|
||||
}
|
||||
|
||||
describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", () => {
|
||||
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
|
||||
const envSnapshot = captureEnv([
|
||||
"OPENCLAW_STATE_DIR",
|
||||
"OPENCLAW_AGENT_DIR",
|
||||
"PI_CODING_AGENT_DIR",
|
||||
]);
|
||||
let tempRoot = "";
|
||||
let mainAgentDir = "";
|
||||
|
||||
@@ -93,6 +113,8 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", ()
|
||||
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-concurrent-"));
|
||||
process.env.OPENCLAW_STATE_DIR = tempRoot;
|
||||
mainAgentDir = path.join(tempRoot, "agents", "main", "agent");
|
||||
process.env.OPENCLAW_AGENT_DIR = mainAgentDir;
|
||||
process.env.PI_CODING_AGENT_DIR = mainAgentDir;
|
||||
await fs.mkdir(mainAgentDir, { recursive: true });
|
||||
await loadOAuthModuleForTest();
|
||||
// Drop any refresh-queue entries left behind by a prior timed-out test.
|
||||
@@ -150,7 +172,7 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", ()
|
||||
// performed; the remaining 19 adopt the resulting fresh credentials.
|
||||
const results = await Promise.all(
|
||||
subAgents.map((agentDir) =>
|
||||
resolveApiKeyForProfile({
|
||||
resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(agentDir),
|
||||
profileId,
|
||||
agentDir,
|
||||
@@ -165,6 +187,5 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", ()
|
||||
expect(result?.apiKey).toBe("cross-agent-refreshed-access");
|
||||
expect(result?.provider).toBe(provider);
|
||||
}
|
||||
}, // Generous timeout; the fix should complete well under 5s in practice.
|
||||
60_000);
|
||||
}, 60_000); // Generous timeout; the fix should complete well under 5s in practice.
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resetFileLockStateForTest } from "../../infra/file-lock.js";
|
||||
import { captureEnv } from "../../test-utils/env.js";
|
||||
import { __testing as externalAuthTesting } from "./external-auth.js";
|
||||
import {
|
||||
clearRuntimeAuthProfileStoreSnapshots,
|
||||
ensureAuthProfileStore,
|
||||
@@ -18,6 +19,12 @@ async function loadOAuthModuleForTest() {
|
||||
({ resolveApiKeyForProfile, resetOAuthRefreshQueuesForTest } = await import("./oauth.js"));
|
||||
}
|
||||
|
||||
function resolveApiKeyForProfileInTest(
|
||||
params: Omit<Parameters<typeof resolveApiKeyForProfile>[0], "cfg">,
|
||||
) {
|
||||
return resolveApiKeyForProfile({ cfg: {}, ...params });
|
||||
}
|
||||
|
||||
const {
|
||||
refreshProviderOAuthCredentialWithPluginMock,
|
||||
formatProviderAuthProfileApiKeyWithPluginMock,
|
||||
@@ -35,12 +42,35 @@ vi.mock("../cli-credentials.js", () => ({
|
||||
writeCodexCliCredentials: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", () => ({
|
||||
getOAuthProviders: () => [{ id: "anthropic" }, { id: "openai-codex" }],
|
||||
getOAuthApiKey: vi.fn(async (provider: string, credentials: Record<string, OAuthCredential>) => {
|
||||
const credential = credentials[provider];
|
||||
return credential
|
||||
? {
|
||||
apiKey: credential.access,
|
||||
newCredentials: credential,
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/provider-runtime.runtime.js", () => ({
|
||||
formatProviderAuthProfileApiKeyWithPlugin: (params: { context?: { access?: string } }) =>
|
||||
formatProviderAuthProfileApiKeyWithPluginMock() ?? params?.context?.access,
|
||||
refreshProviderOAuthCredentialWithPlugin: refreshProviderOAuthCredentialWithPluginMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/file-lock.js", () => ({
|
||||
resetFileLockStateForTest: () => undefined,
|
||||
withFileLock: async <T>(_filePath: string, _options: unknown, run: () => Promise<T>) => run(),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugin-sdk/file-lock.js", () => ({
|
||||
resetFileLockStateForTest: () => undefined,
|
||||
withFileLock: async <T>(_filePath: string, _options: unknown, run: () => Promise<T>) => run(),
|
||||
}));
|
||||
|
||||
vi.mock("./doctor.js", () => ({
|
||||
formatAuthDoctorHint: async () => undefined,
|
||||
}));
|
||||
@@ -76,7 +106,11 @@ function createExpiredOauthStore(params: {
|
||||
}
|
||||
|
||||
describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () => {
|
||||
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
|
||||
const envSnapshot = captureEnv([
|
||||
"OPENCLAW_STATE_DIR",
|
||||
"OPENCLAW_AGENT_DIR",
|
||||
"PI_CODING_AGENT_DIR",
|
||||
]);
|
||||
let tempRoot = "";
|
||||
let mainAgentDir = "";
|
||||
|
||||
@@ -86,10 +120,13 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
|
||||
refreshProviderOAuthCredentialWithPluginMock.mockResolvedValue(undefined);
|
||||
formatProviderAuthProfileApiKeyWithPluginMock.mockReset();
|
||||
formatProviderAuthProfileApiKeyWithPluginMock.mockReturnValue(undefined);
|
||||
externalAuthTesting.setResolveExternalAuthProfilesForTest(() => []);
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-mirror-"));
|
||||
process.env.OPENCLAW_STATE_DIR = tempRoot;
|
||||
mainAgentDir = path.join(tempRoot, "agents", "main", "agent");
|
||||
process.env.OPENCLAW_AGENT_DIR = mainAgentDir;
|
||||
process.env.PI_CODING_AGENT_DIR = mainAgentDir;
|
||||
await fs.mkdir(mainAgentDir, { recursive: true });
|
||||
await loadOAuthModuleForTest();
|
||||
resetOAuthRefreshQueuesForTest();
|
||||
@@ -98,6 +135,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
|
||||
afterEach(async () => {
|
||||
envSnapshot.restore();
|
||||
resetFileLockStateForTest();
|
||||
externalAuthTesting.resetResolveExternalAuthProfilesForTest();
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
if (resetOAuthRefreshQueuesForTest) {
|
||||
resetOAuthRefreshQueuesForTest();
|
||||
@@ -130,7 +168,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const result = await resolveApiKeyForProfile({
|
||||
const result = await resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(subAgentDir),
|
||||
profileId,
|
||||
agentDir: subAgentDir,
|
||||
@@ -174,7 +212,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
|
||||
// Main-agent refresh uses undefined agentDir; the mirror path is a no-op
|
||||
// (local == main). Just make sure the main store still reflects the refresh
|
||||
// and no double-write happens.
|
||||
const result = await resolveApiKeyForProfile({
|
||||
const result = await resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(undefined),
|
||||
profileId,
|
||||
agentDir: undefined,
|
||||
@@ -228,7 +266,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const result = await resolveApiKeyForProfile({
|
||||
const result = await resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(subAgentDir),
|
||||
profileId,
|
||||
agentDir: subAgentDir,
|
||||
@@ -294,7 +332,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const result = await resolveApiKeyForProfile({
|
||||
const result = await resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(subAgentDir),
|
||||
profileId,
|
||||
agentDir: subAgentDir,
|
||||
@@ -359,7 +397,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
|
||||
// The sub-agent will actually adopt main's fresher creds via the inside-
|
||||
// lock recheck (that's the whole point of #26322), so refresh may not
|
||||
// even fire. We only care that the main store is not regressed.
|
||||
await resolveApiKeyForProfile({
|
||||
await resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(subAgentDir),
|
||||
profileId,
|
||||
agentDir: subAgentDir,
|
||||
@@ -415,7 +453,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const result = await resolveApiKeyForProfile({
|
||||
const result = await resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(subAgentDir),
|
||||
profileId,
|
||||
agentDir: subAgentDir,
|
||||
@@ -473,7 +511,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
|
||||
}) as never,
|
||||
);
|
||||
|
||||
await resolveApiKeyForProfile({
|
||||
await resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(subAgentDir),
|
||||
profileId,
|
||||
agentDir: subAgentDir,
|
||||
@@ -522,7 +560,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
|
||||
|
||||
// Refresh mock intentionally left as default-undefined — it should not
|
||||
// be called, the pre-refresh adopt wins.
|
||||
const result = await resolveApiKeyForProfile({
|
||||
const result = await resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(subAgentDir),
|
||||
profileId,
|
||||
agentDir: subAgentDir,
|
||||
@@ -585,7 +623,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
|
||||
throw new Error("upstream 503 service unavailable");
|
||||
});
|
||||
|
||||
const result = await resolveApiKeyForProfile({
|
||||
const result = await resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(subAgentDir),
|
||||
profileId,
|
||||
agentDir: subAgentDir,
|
||||
@@ -649,7 +687,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const result = await resolveApiKeyForProfile({
|
||||
const result = await resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(subAgentDir),
|
||||
profileId,
|
||||
agentDir: subAgentDir,
|
||||
@@ -707,7 +745,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
|
||||
}) as never,
|
||||
);
|
||||
|
||||
await resolveApiKeyForProfile({
|
||||
await resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(subAgentDir),
|
||||
profileId,
|
||||
agentDir: subAgentDir,
|
||||
@@ -747,7 +785,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const result = await resolveApiKeyForProfile({
|
||||
const result = await resolveApiKeyForProfileInTest({
|
||||
store: ensureAuthProfileStore(subAgentDir),
|
||||
profileId,
|
||||
agentDir: subAgentDir,
|
||||
|
||||
@@ -23,6 +23,11 @@ vi.mock("../../plugins/manifest-registry.js", () => ({
|
||||
loadPluginManifestRegistry,
|
||||
}));
|
||||
|
||||
vi.mock("./external-auth.js", () => ({
|
||||
overlayExternalAuthProfiles: <T>(store: T) => store,
|
||||
shouldPersistExternalAuthProfile: () => true,
|
||||
}));
|
||||
|
||||
describe("resolveAuthProfileOrder", () => {
|
||||
beforeEach(() => {
|
||||
loadPluginManifestRegistry.mockClear();
|
||||
|
||||
@@ -1,13 +1,50 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
estimateMessagesTokens,
|
||||
pruneHistoryForContextShare,
|
||||
splitMessagesByTokenShare,
|
||||
} from "./compaction.js";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { makeAgentAssistantMessage } from "./test-helpers/agent-message-fixtures.js";
|
||||
|
||||
const piCodingAgentMocks = vi.hoisted(() => ({
|
||||
estimateTokens: vi.fn((message: unknown) => estimateTokenish(message)),
|
||||
}));
|
||||
|
||||
function readText(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(readText).join("");
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as { text?: unknown; content?: unknown; arguments?: unknown };
|
||||
return `${readText(record.text)}${readText(record.content)}${readText(record.arguments)}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function estimateTokenish(message: unknown): number {
|
||||
return Math.max(1, Math.ceil(readText(message).length / 4));
|
||||
}
|
||||
|
||||
vi.mock("@mariozechner/pi-coding-agent", async () => {
|
||||
const actual = await vi.importActual<typeof import("@mariozechner/pi-coding-agent")>(
|
||||
"@mariozechner/pi-coding-agent",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
estimateTokens: piCodingAgentMocks.estimateTokens,
|
||||
};
|
||||
});
|
||||
|
||||
let estimateMessagesTokens: typeof import("./compaction.js").estimateMessagesTokens;
|
||||
let pruneHistoryForContextShare: typeof import("./compaction.js").pruneHistoryForContextShare;
|
||||
let splitMessagesByTokenShare: typeof import("./compaction.js").splitMessagesByTokenShare;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
({ estimateMessagesTokens, pruneHistoryForContextShare, splitMessagesByTokenShare } =
|
||||
await import("./compaction.js"));
|
||||
});
|
||||
|
||||
function makeMessage(id: number, size: number): AgentMessage {
|
||||
return {
|
||||
role: "user",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { BootstrapMode } from "../../bootstrap-mode.js";
|
||||
import { resolveBootstrapMode } from "../../bootstrap-mode.js";
|
||||
import { buildAgentUserPromptPrefix } from "../../system-prompt.js";
|
||||
|
||||
export type AttemptBootstrapRoutingInput = {
|
||||
workspaceBootstrapPending: boolean;
|
||||
bootstrapContextRunKind?: "default" | "heartbeat" | "cron";
|
||||
trigger?: string;
|
||||
sessionKey?: string;
|
||||
isPrimaryRun: boolean;
|
||||
isCanonicalWorkspace?: boolean;
|
||||
effectiveWorkspace: string;
|
||||
resolvedWorkspace: string;
|
||||
hasBootstrapFileAccess: boolean;
|
||||
};
|
||||
|
||||
export type AttemptBootstrapRouting = {
|
||||
bootstrapMode: BootstrapMode;
|
||||
shouldStripBootstrapFromContext: boolean;
|
||||
userPromptPrefixText?: string;
|
||||
};
|
||||
|
||||
export type AttemptWorkspaceBootstrapRoutingInput = Omit<
|
||||
AttemptBootstrapRoutingInput,
|
||||
"workspaceBootstrapPending"
|
||||
> & {
|
||||
isWorkspaceBootstrapPending: (workspaceDir: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export function shouldStripBootstrapFromEmbeddedContext(_params: {
|
||||
bootstrapMode: BootstrapMode;
|
||||
}): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveAttemptBootstrapRouting(
|
||||
params: AttemptBootstrapRoutingInput,
|
||||
): AttemptBootstrapRouting {
|
||||
const bootstrapMode = resolveBootstrapMode({
|
||||
bootstrapPending: params.workspaceBootstrapPending,
|
||||
runKind: params.bootstrapContextRunKind ?? "default",
|
||||
isInteractiveUserFacing: params.trigger === "user" || params.trigger === "manual",
|
||||
isPrimaryRun: params.isPrimaryRun,
|
||||
isCanonicalWorkspace:
|
||||
(params.isCanonicalWorkspace ?? true) &&
|
||||
params.effectiveWorkspace === params.resolvedWorkspace,
|
||||
hasBootstrapFileAccess: params.hasBootstrapFileAccess,
|
||||
});
|
||||
|
||||
return {
|
||||
bootstrapMode,
|
||||
shouldStripBootstrapFromContext: shouldStripBootstrapFromEmbeddedContext({
|
||||
bootstrapMode,
|
||||
}),
|
||||
userPromptPrefixText: buildAgentUserPromptPrefix({
|
||||
bootstrapMode,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveAttemptWorkspaceBootstrapRouting(
|
||||
params: AttemptWorkspaceBootstrapRoutingInput,
|
||||
): Promise<AttemptBootstrapRouting> {
|
||||
const workspaceBootstrapPending = await params.isWorkspaceBootstrapPending(
|
||||
params.resolvedWorkspace,
|
||||
);
|
||||
return resolveAttemptBootstrapRouting({
|
||||
...params,
|
||||
workspaceBootstrapPending,
|
||||
});
|
||||
}
|
||||
@@ -1,60 +1,28 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
cleanupTempPaths,
|
||||
createContextEngineAttemptRunner,
|
||||
getHoisted,
|
||||
resetEmbeddedAttemptHarness,
|
||||
} from "./attempt.spawn-workspace.test-support.js";
|
||||
|
||||
const hoisted = getHoisted();
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveAttemptWorkspaceBootstrapRouting } from "./attempt-bootstrap-routing.js";
|
||||
|
||||
describe("runEmbeddedAttempt bootstrap routing", () => {
|
||||
const tempPaths: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
resetEmbeddedAttemptHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTempPaths(tempPaths);
|
||||
});
|
||||
|
||||
it("resolves bootstrap pending from the canonical workspace instead of a copied sandbox", async () => {
|
||||
const sandboxWorkspace = "/tmp/openclaw-sandbox-copy";
|
||||
let capturedPrompt = "";
|
||||
|
||||
hoisted.resolveSandboxContextMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
workspaceAccess: "ro",
|
||||
workspaceDir: sandboxWorkspace,
|
||||
});
|
||||
hoisted.isWorkspaceBootstrapPendingMock.mockImplementation(async (workspaceDir: string) => {
|
||||
const canonicalWorkspace = "/tmp/openclaw-canonical-workspace";
|
||||
const isWorkspaceBootstrapPending = vi.fn(async (workspaceDir: string) => {
|
||||
return workspaceDir === sandboxWorkspace;
|
||||
});
|
||||
|
||||
await createContextEngineAttemptRunner({
|
||||
sessionKey: "agent:main:bootstrap-canonical-workspace",
|
||||
tempPaths,
|
||||
contextEngine: {
|
||||
assemble: async ({ messages }) => ({
|
||||
messages,
|
||||
estimatedTokens: 1,
|
||||
}),
|
||||
},
|
||||
attemptOverrides: {
|
||||
disableTools: true,
|
||||
},
|
||||
sessionPrompt: async (session, prompt) => {
|
||||
capturedPrompt = prompt;
|
||||
session.messages = [
|
||||
...session.messages,
|
||||
{ role: "assistant", content: "done", timestamp: 2 } as never,
|
||||
];
|
||||
},
|
||||
const routing = await resolveAttemptWorkspaceBootstrapRouting({
|
||||
isWorkspaceBootstrapPending,
|
||||
trigger: "user",
|
||||
isPrimaryRun: true,
|
||||
isCanonicalWorkspace: true,
|
||||
effectiveWorkspace: sandboxWorkspace,
|
||||
resolvedWorkspace: canonicalWorkspace,
|
||||
hasBootstrapFileAccess: true,
|
||||
});
|
||||
|
||||
expect(hoisted.isWorkspaceBootstrapPendingMock).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.isWorkspaceBootstrapPendingMock).not.toHaveBeenCalledWith(sandboxWorkspace);
|
||||
expect(capturedPrompt).not.toContain("[Bootstrap pending]");
|
||||
expect(isWorkspaceBootstrapPending).toHaveBeenCalledOnce();
|
||||
expect(isWorkspaceBootstrapPending).toHaveBeenCalledWith(canonicalWorkspace);
|
||||
expect(isWorkspaceBootstrapPending).not.toHaveBeenCalledWith(sandboxWorkspace);
|
||||
expect(routing.bootstrapMode).toBe("none");
|
||||
expect(routing.userPromptPrefixText).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import {
|
||||
createAgentSession,
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
resolveBootstrapContextForRun,
|
||||
resolveContextInjectionMode,
|
||||
} from "../../bootstrap-files.js";
|
||||
import { resolveBootstrapMode } from "../../bootstrap-mode.js";
|
||||
import { createCacheTrace } from "../../cache-trace.js";
|
||||
import {
|
||||
listChannelSupportedActions,
|
||||
@@ -114,7 +113,6 @@ import {
|
||||
import { resolveSystemPromptOverride } from "../../system-prompt-override.js";
|
||||
import { buildSystemPromptParams } from "../../system-prompt-params.js";
|
||||
import { buildSystemPromptReport } from "../../system-prompt-report.js";
|
||||
import { buildAgentUserPromptPrefix } from "../../system-prompt.js";
|
||||
import { resolveAgentTimeoutMs } from "../../timeout.js";
|
||||
import { UNKNOWN_TOOL_THRESHOLD } from "../../tool-loop-detection.js";
|
||||
import {
|
||||
@@ -182,6 +180,11 @@ import { splitSdkTools } from "../tool-split.js";
|
||||
import { mapThinkingLevel } from "../utils.js";
|
||||
import { flushPendingToolResultsAfterIdle } from "../wait-for-idle-before-flush.js";
|
||||
export { buildContextEnginePromptCacheInfo } from "./attempt.context-engine-helpers.js";
|
||||
import {
|
||||
resolveAttemptWorkspaceBootstrapRouting,
|
||||
shouldStripBootstrapFromEmbeddedContext,
|
||||
} from "./attempt-bootstrap-routing.js";
|
||||
export { shouldStripBootstrapFromEmbeddedContext } from "./attempt-bootstrap-routing.js";
|
||||
import { configureEmbeddedAttemptHttpRuntime } from "./attempt-http-runtime.js";
|
||||
import {
|
||||
assembleAttemptContextEngine,
|
||||
@@ -318,12 +321,6 @@ export function resolveUnknownToolGuardThreshold(loopDetection?: {
|
||||
return UNKNOWN_TOOL_THRESHOLD;
|
||||
}
|
||||
|
||||
export function shouldStripBootstrapFromEmbeddedContext(_params: {
|
||||
bootstrapMode: "full" | "limited" | "none";
|
||||
}): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isPrimaryBootstrapRun(sessionKey?: string): boolean {
|
||||
return !isSubagentSessionKey(sessionKey) && !isAcpSessionKey(sessionKey);
|
||||
}
|
||||
@@ -338,8 +335,7 @@ export function remapInjectedContextFilesToWorkspace(params: {
|
||||
}
|
||||
return params.files.map((file) => {
|
||||
const relative = path.relative(params.sourceWorkspaceDir, file.path);
|
||||
const canRemap =
|
||||
relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
const canRemap = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
return canRemap
|
||||
? {
|
||||
...file,
|
||||
@@ -484,8 +480,6 @@ export async function runEmbeddedAttempt(
|
||||
|
||||
const sessionLabel = params.sessionKey ?? params.sessionId;
|
||||
const contextInjectionMode = resolveContextInjectionMode(params.config);
|
||||
// Bootstrap lifecycle is owned by the canonical workspace, not a copied sandbox view.
|
||||
const workspaceBootstrapPending = await isWorkspaceBootstrapPending(resolvedWorkspace);
|
||||
const agentDir = params.agentDir ?? resolveOpenClawAgentDir();
|
||||
const toolsRaw = params.disableTools
|
||||
? []
|
||||
@@ -555,20 +549,20 @@ export async function runEmbeddedAttempt(
|
||||
return allTools;
|
||||
})();
|
||||
const toolsEnabled = supportsModelTools(params.model);
|
||||
const bootstrapRunKind = params.bootstrapContextRunKind ?? "default";
|
||||
const bootstrapHasFileAccess = toolsEnabled && toolsRaw.some((tool) => tool.name === "read");
|
||||
const bootstrapMode = resolveBootstrapMode({
|
||||
bootstrapPending: workspaceBootstrapPending,
|
||||
runKind: bootstrapRunKind,
|
||||
isInteractiveUserFacing: params.trigger === "user" || params.trigger === "manual",
|
||||
const bootstrapRouting = await resolveAttemptWorkspaceBootstrapRouting({
|
||||
isWorkspaceBootstrapPending,
|
||||
bootstrapContextRunKind: params.bootstrapContextRunKind,
|
||||
trigger: params.trigger,
|
||||
sessionKey: params.sessionKey,
|
||||
isPrimaryRun: isPrimaryBootstrapRun(params.sessionKey),
|
||||
isCanonicalWorkspace:
|
||||
(params.isCanonicalWorkspace ?? true) && effectiveWorkspace === resolvedWorkspace,
|
||||
isCanonicalWorkspace: params.isCanonicalWorkspace,
|
||||
effectiveWorkspace,
|
||||
resolvedWorkspace,
|
||||
hasBootstrapFileAccess: bootstrapHasFileAccess,
|
||||
});
|
||||
const shouldStripBootstrapFromContext = shouldStripBootstrapFromEmbeddedContext({
|
||||
bootstrapMode,
|
||||
});
|
||||
const bootstrapMode = bootstrapRouting.bootstrapMode;
|
||||
const shouldStripBootstrapFromContext = bootstrapRouting.shouldStripBootstrapFromContext;
|
||||
const {
|
||||
bootstrapFiles: hookAdjustedBootstrapFiles,
|
||||
contextFiles: resolvedContextFiles,
|
||||
@@ -576,7 +570,7 @@ export async function runEmbeddedAttempt(
|
||||
} = await resolveAttemptBootstrapContext({
|
||||
contextInjectionMode,
|
||||
bootstrapContextMode: params.bootstrapContextMode,
|
||||
bootstrapContextRunKind: bootstrapRunKind,
|
||||
bootstrapContextRunKind: params.bootstrapContextRunKind ?? "default",
|
||||
bootstrapMode,
|
||||
sessionFile: params.sessionFile,
|
||||
hasCompletedBootstrapTurn,
|
||||
@@ -927,9 +921,7 @@ export async function runEmbeddedAttempt(
|
||||
});
|
||||
const systemPromptOverride = createSystemPromptOverride(appendPrompt);
|
||||
let systemPromptText = systemPromptOverride();
|
||||
const userPromptPrefixText = buildAgentUserPromptPrefix({
|
||||
bootstrapMode,
|
||||
});
|
||||
const userPromptPrefixText = bootstrapRouting.userPromptPrefixText;
|
||||
|
||||
let sessionManager: ReturnType<typeof guardSessionManager> | undefined;
|
||||
let session: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
|
||||
|
||||
@@ -1,11 +1,51 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { estimateToolResultReductionPotential } from "../tool-result-truncation.js";
|
||||
import {
|
||||
PREEMPTIVE_OVERFLOW_ERROR_TEXT,
|
||||
estimatePrePromptTokens,
|
||||
shouldPreemptivelyCompactBeforePrompt,
|
||||
} from "./preemptive-compaction.js";
|
||||
|
||||
const piCodingAgentMocks = vi.hoisted(() => ({
|
||||
estimateTokens: vi.fn((message: unknown) => estimateTokenish(message)),
|
||||
}));
|
||||
|
||||
function readText(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(readText).join("");
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as { text?: unknown; content?: unknown };
|
||||
return `${readText(record.text)}${readText(record.content)}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function estimateTokenish(message: unknown): number {
|
||||
return Math.max(1, Math.ceil(readText(message).length / 4));
|
||||
}
|
||||
|
||||
vi.mock("@mariozechner/pi-coding-agent", async () => {
|
||||
const actual = await vi.importActual<typeof import("@mariozechner/pi-coding-agent")>(
|
||||
"@mariozechner/pi-coding-agent",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
estimateTokens: piCodingAgentMocks.estimateTokens,
|
||||
};
|
||||
});
|
||||
|
||||
let PREEMPTIVE_OVERFLOW_ERROR_TEXT: typeof import("./preemptive-compaction.js").PREEMPTIVE_OVERFLOW_ERROR_TEXT;
|
||||
let estimatePrePromptTokens: typeof import("./preemptive-compaction.js").estimatePrePromptTokens;
|
||||
let shouldPreemptivelyCompactBeforePrompt: typeof import("./preemptive-compaction.js").shouldPreemptivelyCompactBeforePrompt;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
({
|
||||
PREEMPTIVE_OVERFLOW_ERROR_TEXT,
|
||||
estimatePrePromptTokens,
|
||||
shouldPreemptivelyCompactBeforePrompt,
|
||||
} = await import("./preemptive-compaction.js"));
|
||||
});
|
||||
|
||||
let timestamp = 1;
|
||||
|
||||
|
||||
@@ -54,6 +54,12 @@ const telegramModelsTestPlugin: ChannelPlugin = {
|
||||
},
|
||||
};
|
||||
|
||||
const textSurfaceModelsTestPlugins = (["discord", "whatsapp"] as const).map((id) => ({
|
||||
pluginId: id,
|
||||
plugin: createChannelTestPluginBase({ id }),
|
||||
source: "test",
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
modelCatalogMocks.loadModelCatalog.mockReset();
|
||||
modelCatalogMocks.loadModelCatalog.mockResolvedValue([
|
||||
@@ -67,6 +73,7 @@ beforeEach(() => {
|
||||
modelAuthLabelMocks.resolveModelAuthLabel.mockReturnValue(undefined);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
...textSurfaceModelsTestPlugins,
|
||||
{
|
||||
pluginId: "telegram",
|
||||
plugin: telegramModelsTestPlugin,
|
||||
|
||||
@@ -31,7 +31,7 @@ async function loadDevTemplate(name: string, fallback: string): Promise<string>
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveDevWorkspaceDir = (env: NodeJS.ProcessEnv = process.env): string => {
|
||||
const resolveDevWorkspaceDir = (env: NodeJS.ProcessEnv = process.env): string => {
|
||||
const baseDir = resolveDefaultAgentWorkspaceDir(env, os.homedir);
|
||||
const profile = normalizeOptionalLowercaseString(env.OPENCLAW_PROFILE);
|
||||
if (profile === "dev") {
|
||||
@@ -54,7 +54,7 @@ async function writeFileIfMissing(filePath: string, content: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureDevWorkspace(dir: string) {
|
||||
async function ensureDevWorkspace(dir: string) {
|
||||
const resolvedDir = resolveUserPath(dir);
|
||||
await fs.promises.mkdir(resolvedDir, { recursive: true });
|
||||
|
||||
|
||||
@@ -76,11 +76,6 @@ const coreEntrySpecs: readonly CommandGroupDescriptorSpec<
|
||||
loadModule: () => import("./register.backup.js"),
|
||||
exportName: "registerBackupCommand",
|
||||
},
|
||||
{
|
||||
commandNames: ["workspace"],
|
||||
loadModule: () => import("./register.workspace.js"),
|
||||
exportName: "registerWorkspaceCommand",
|
||||
},
|
||||
{
|
||||
commandNames: ["doctor", "dashboard", "reset", "uninstall"],
|
||||
loadModule: () => import("./register.maintenance.js"),
|
||||
|
||||
@@ -18,13 +18,6 @@ vi.mock("./register.backup.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./register.workspace.js", () => ({
|
||||
registerWorkspaceCommand: (program: Command) => {
|
||||
const workspace = program.command("workspace");
|
||||
workspace.command("reset");
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./register.maintenance.js", () => ({
|
||||
registerMaintenanceCommands: (program: Command) => {
|
||||
program.command("doctor");
|
||||
@@ -77,7 +70,6 @@ describe("command-registry", () => {
|
||||
expect(names).toContain("mcp");
|
||||
expect(names).toContain("agent");
|
||||
expect(names).toContain("agents");
|
||||
expect(names).toContain("workspace");
|
||||
});
|
||||
|
||||
it("returns only commands that support subcommands", () => {
|
||||
@@ -85,7 +77,6 @@ describe("command-registry", () => {
|
||||
expect(names).toContain("config");
|
||||
expect(names).toContain("agents");
|
||||
expect(names).toContain("backup");
|
||||
expect(names).toContain("workspace");
|
||||
expect(names).toContain("mcp");
|
||||
expect(names).toContain("sessions");
|
||||
expect(names).toContain("tasks");
|
||||
|
||||
@@ -50,11 +50,6 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([
|
||||
description: "Uninstall the gateway service + local data (CLI remains)",
|
||||
hasSubcommands: false,
|
||||
},
|
||||
{
|
||||
name: "workspace",
|
||||
description: "Manage agent workspaces",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
{
|
||||
name: "message",
|
||||
description: "Send, read, and manage messages",
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerWorkspaceCommand } from "./register.workspace.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
workspaceResetCommand: vi.fn(),
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../commands/workspace.js", () => ({
|
||||
workspaceResetCommand: mocks.workspaceResetCommand,
|
||||
}));
|
||||
|
||||
vi.mock("../../runtime.js", () => ({
|
||||
defaultRuntime: mocks.runtime,
|
||||
}));
|
||||
|
||||
describe("registerWorkspaceCommand", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("passes workspace reset options through to the command", async () => {
|
||||
const program = new Command();
|
||||
registerWorkspaceCommand(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"workspace",
|
||||
"reset",
|
||||
"--workspace",
|
||||
"/tmp/ws",
|
||||
"--agent",
|
||||
"ops",
|
||||
"--include-sessions",
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(mocks.workspaceResetCommand).toHaveBeenCalledWith(
|
||||
mocks.runtime,
|
||||
expect.objectContaining({
|
||||
workspace: "/tmp/ws",
|
||||
agent: "ops",
|
||||
includeSessions: true,
|
||||
yes: true,
|
||||
dryRun: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { Command } from "commander";
|
||||
import { workspaceResetCommand } from "../../commands/workspace.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { formatDocsLink } from "../../terminal/links.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import { runCommandWithRuntime } from "../cli-utils.js";
|
||||
import { formatHelpExamples } from "../help-format.js";
|
||||
|
||||
export function registerWorkspaceCommand(program: Command) {
|
||||
const workspace = program
|
||||
.command("workspace")
|
||||
.description("Manage agent workspaces")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/workspace", "docs.openclaw.ai/cli/workspace")}\n`,
|
||||
);
|
||||
|
||||
workspace
|
||||
.command("reset")
|
||||
.description("Reset only the active agent workspace and reseed it as a fresh agent")
|
||||
.option("--workspace <dir>", "Explicit workspace directory to reset")
|
||||
.option("--agent <id>", "Agent id to resolve workspace/sessions from the active config")
|
||||
.option("--include-sessions", "Also clear this agent's session transcripts", false)
|
||||
.option("--yes", "Skip the confirmation prompt", false)
|
||||
.option("--dry-run", "Print the reset plan without moving anything", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
|
||||
["openclaw workspace reset", "Trash and reseed only the active workspace."],
|
||||
[
|
||||
"openclaw workspace reset --include-sessions",
|
||||
"Also clear the active agent's session transcripts.",
|
||||
],
|
||||
[
|
||||
"openclaw workspace reset --agent ops",
|
||||
"Reset the workspace resolved for a specific agent id.",
|
||||
],
|
||||
[
|
||||
"openclaw workspace reset --workspace ~/tmp/test-workspace --dry-run",
|
||||
"Preview a custom workspace-only reset.",
|
||||
],
|
||||
])}`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
await runCommandWithRuntime(defaultRuntime, async () => {
|
||||
await workspaceResetCommand(defaultRuntime, {
|
||||
workspace: opts.workspace as string | undefined,
|
||||
agent: opts.agent as string | undefined,
|
||||
includeSessions: Boolean(opts.includeSessions),
|
||||
yes: Boolean(opts.yes),
|
||||
dryRun: Boolean(opts.dryRun),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import "./agent-command.test-mocks.js";
|
||||
import * as acpManagerModule from "../acp/control-plane/manager.js";
|
||||
import { AcpRuntimeError } from "../acp/runtime/errors.js";
|
||||
import * as embeddedModule from "../agents/pi-embedded.js";
|
||||
import * as configIoModule from "../config/io.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
@@ -401,9 +402,10 @@ describe("agentCommand ACP runtime routing", () => {
|
||||
return {
|
||||
kind: "stale",
|
||||
sessionKey,
|
||||
error: Object.assign(new Error(`ACP metadata is missing for session ${sessionKey}.`), {
|
||||
code: "ACP_SESSION_INIT_FAILED",
|
||||
}),
|
||||
error: new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`ACP metadata is missing for session ${sessionKey}.`,
|
||||
),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ vi.mock("../../config/plugin-auto-enable.js", () => ({
|
||||
|
||||
const resolveBundledPluginSources = vi.fn();
|
||||
const getChannelPluginCatalogEntry = vi.fn();
|
||||
const listChannelPluginCatalogEntries = vi.fn(() => []);
|
||||
const listChannelPluginCatalogEntries = vi.fn((..._args: unknown[]) => []);
|
||||
vi.mock("../../channels/plugins/catalog.js", () => {
|
||||
return {
|
||||
getChannelPluginCatalogEntry: (...args: unknown[]) => getChannelPluginCatalogEntry(...args),
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
|
||||
type RunMessageActionParams = {
|
||||
cfg?: unknown;
|
||||
action: string;
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
moveToTrash,
|
||||
normalizeGatewayTokenInput,
|
||||
openUrl,
|
||||
probeGatewayReachable,
|
||||
@@ -226,43 +223,6 @@ describe("normalizeGatewayTokenInput", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveToTrash", () => {
|
||||
it("falls back to mv into ~/.Trash when trash exits successfully but leaves the path in place", async () => {
|
||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-trash-home-"));
|
||||
const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-trash-target-"));
|
||||
const target = path.join(targetDir, "workspace");
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
await fs.writeFile(path.join(target, "stale.txt"), "old", "utf-8");
|
||||
vi.spyOn(os, "homedir").mockReturnValue(tempHome);
|
||||
|
||||
mocks.runCommandWithTimeout.mockImplementationOnce(async () => ({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
}));
|
||||
mocks.runCommandWithTimeout.mockImplementationOnce(async (argv) => {
|
||||
await fs.rename(argv[1], argv[2]);
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
});
|
||||
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
|
||||
await moveToTrash(target, runtime as never);
|
||||
|
||||
await expect(fs.access(target)).rejects.toThrow();
|
||||
await expect(fs.access(path.join(tempHome, ".Trash", "workspace"))).resolves.toBeUndefined();
|
||||
expect(runtime.log).toHaveBeenCalledWith(`Moved to Trash: ${target}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateGatewayPasswordInput", () => {
|
||||
it("requires a non-empty password", () => {
|
||||
expect(validateGatewayPasswordInput("")).toBe("Required");
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { inspect } from "node:util";
|
||||
import { cancel, isCancel } from "@clack/prompts";
|
||||
@@ -188,28 +187,6 @@ export function resolveNodeManagerOptions(): Array<{
|
||||
];
|
||||
}
|
||||
|
||||
async function pathExists(pathname: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(pathname);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveTrashDestination(pathname: string): Promise<string> {
|
||||
const trashDir = path.join(os.homedir(), ".Trash");
|
||||
await fs.mkdir(trashDir, { recursive: true });
|
||||
const baseName = path.basename(pathname);
|
||||
let candidate = path.join(trashDir, baseName);
|
||||
let suffix = 1;
|
||||
while (await pathExists(candidate)) {
|
||||
candidate = path.join(trashDir, `${baseName}.${suffix}`);
|
||||
suffix += 1;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promise<void> {
|
||||
if (!pathname) {
|
||||
return;
|
||||
@@ -221,26 +198,8 @@ export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promis
|
||||
}
|
||||
try {
|
||||
await runCommandWithTimeout(["trash", pathname], { timeoutMs: 5000 });
|
||||
if (!(await pathExists(pathname))) {
|
||||
runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`);
|
||||
return;
|
||||
}
|
||||
runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`);
|
||||
} catch {
|
||||
// Fall through to a verified mv-based fallback below.
|
||||
}
|
||||
|
||||
try {
|
||||
const destination = await resolveTrashDestination(pathname);
|
||||
await runCommandWithTimeout(["mv", pathname, destination], { timeoutMs: 5000 });
|
||||
if (!(await pathExists(pathname))) {
|
||||
runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Surface the manual action guidance below.
|
||||
}
|
||||
|
||||
if (await pathExists(pathname)) {
|
||||
runtime.log(`Failed to move to Trash (manual delete): ${shortenHomePath(pathname)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ function createDefaultSessionStoreEntry() {
|
||||
cacheRead: 2_000,
|
||||
cacheWrite: 1_000,
|
||||
totalTokens: 5_000,
|
||||
totalTokensFresh: true as boolean,
|
||||
contextTokens: 10_000,
|
||||
model: "pi:opus",
|
||||
sessionId: "abc123",
|
||||
@@ -188,7 +189,11 @@ async function createStatusServiceSummary(
|
||||
}
|
||||
|
||||
function createSessionStatusRows() {
|
||||
const agents = mocks.listGatewayAgentsBasic().agents ?? [{ id: "main", name: "Main" }];
|
||||
const agents = (mocks.listGatewayAgentsBasic().agents ?? [
|
||||
{ id: "main", name: "Main" },
|
||||
]) as Array<{
|
||||
id: string;
|
||||
}>;
|
||||
const byAgent = agents.map((agent: { id: string }) => {
|
||||
const path = mocks.resolveStorePath("sessions", { agentId: agent.id });
|
||||
const store = mocks.loadSessionStore(path) as Record<
|
||||
@@ -198,7 +203,7 @@ function createSessionStatusRows() {
|
||||
const recent = Object.entries(store).map(([key, entry]) => {
|
||||
const contextTokens = typeof entry.contextTokens === "number" ? entry.contextTokens : null;
|
||||
const freshTotal =
|
||||
typeof entry.totalTokens === "number" && entry.totalTokensFresh !== false
|
||||
typeof entry.totalTokens === "number" && (entry.totalTokensFresh ?? true)
|
||||
? entry.totalTokens
|
||||
: null;
|
||||
return {
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { workspaceResetCommand } from "./workspace.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readBestEffortConfig: vi.fn(async () => ({})),
|
||||
resolveDefaultAgentId: vi.fn(() => "main"),
|
||||
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace-main"),
|
||||
resolveSessionTranscriptsDirForAgent: vi.fn(() => "/tmp/sessions-main"),
|
||||
ensureAgentWorkspace: vi.fn(async ({ dir }: { dir: string }) => {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, "BOOTSTRAP.md"), "# BOOTSTRAP\n", "utf-8");
|
||||
return { dir };
|
||||
}),
|
||||
ensureDevWorkspace: vi.fn(async (dir: string) => {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, "AGENTS.md"), "# DEV AGENTS\n", "utf-8");
|
||||
}),
|
||||
resolveDevWorkspaceDir: vi.fn(() => "/tmp/workspace-dev"),
|
||||
moveToTrash: vi.fn(async (target: string) => {
|
||||
await fs.rm(target, { recursive: true, force: true });
|
||||
}),
|
||||
confirm: vi.fn(async () => true),
|
||||
cancel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
readBestEffortConfig: mocks.readBestEffortConfig,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/agent-scope.js", () => ({
|
||||
resolveDefaultAgentId: mocks.resolveDefaultAgentId,
|
||||
resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir,
|
||||
}));
|
||||
|
||||
vi.mock("../config/sessions/paths.js", () => ({
|
||||
resolveSessionTranscriptsDirForAgent: mocks.resolveSessionTranscriptsDirForAgent,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/workspace.js", () => ({
|
||||
ensureAgentWorkspace: mocks.ensureAgentWorkspace,
|
||||
}));
|
||||
|
||||
vi.mock("../cli/gateway-cli/dev.js", () => ({
|
||||
ensureDevWorkspace: mocks.ensureDevWorkspace,
|
||||
resolveDevWorkspaceDir: mocks.resolveDevWorkspaceDir,
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-helpers.js", () => ({
|
||||
moveToTrash: mocks.moveToTrash,
|
||||
}));
|
||||
|
||||
vi.mock("@clack/prompts", () => ({
|
||||
confirm: mocks.confirm,
|
||||
cancel: mocks.cancel,
|
||||
isCancel: (value: unknown) => value === Symbol.for("clack.cancel"),
|
||||
}));
|
||||
|
||||
describe("workspaceResetCommand", () => {
|
||||
let tempRoot: string;
|
||||
let runtime: {
|
||||
log: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
exit: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-reset-"));
|
||||
runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it("resets only the workspace by default and reseeds it", async () => {
|
||||
const workspaceDir = path.join(tempRoot, "workspace-main");
|
||||
const sessionsDir = path.join(tempRoot, "sessions-main");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "stale.txt"), "old", "utf-8");
|
||||
|
||||
mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceDir);
|
||||
mocks.resolveSessionTranscriptsDirForAgent.mockReturnValue(sessionsDir);
|
||||
|
||||
await workspaceResetCommand(runtime as never, {});
|
||||
|
||||
expect(mocks.confirm).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.moveToTrash).toHaveBeenCalledWith(workspaceDir, runtime);
|
||||
expect(mocks.moveToTrash).not.toHaveBeenCalledWith(sessionsDir, runtime);
|
||||
expect(await fs.readFile(path.join(workspaceDir, "BOOTSTRAP.md"), "utf-8")).toContain(
|
||||
"BOOTSTRAP",
|
||||
);
|
||||
expect(await fs.stat(sessionsDir)).toBeDefined();
|
||||
});
|
||||
|
||||
it("also resets sessions when --include-sessions is enabled", async () => {
|
||||
const workspaceDir = path.join(tempRoot, "workspace-main");
|
||||
const sessionsDir = path.join(tempRoot, "sessions-main");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(sessionsDir, "old.log"), "old", "utf-8");
|
||||
|
||||
mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceDir);
|
||||
mocks.resolveSessionTranscriptsDirForAgent.mockReturnValue(sessionsDir);
|
||||
|
||||
await workspaceResetCommand(runtime as never, { includeSessions: true });
|
||||
|
||||
expect(mocks.moveToTrash).toHaveBeenCalledWith(workspaceDir, runtime);
|
||||
expect(mocks.moveToTrash).toHaveBeenCalledWith(sessionsDir, runtime);
|
||||
expect(await fs.readFile(path.join(workspaceDir, "BOOTSTRAP.md"), "utf-8")).toContain(
|
||||
"BOOTSTRAP",
|
||||
);
|
||||
expect(await fs.readdir(sessionsDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects combining --workspace with --include-sessions", async () => {
|
||||
const customWorkspace = path.join(tempRoot, "custom-workspace");
|
||||
await fs.mkdir(customWorkspace, { recursive: true });
|
||||
|
||||
await expect(
|
||||
workspaceResetCommand(runtime as never, {
|
||||
workspace: customWorkspace,
|
||||
includeSessions: true,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"--include-sessions cannot be combined with --workspace; sessions are resolved from configured agent state only.",
|
||||
);
|
||||
|
||||
expect(mocks.moveToTrash).not.toHaveBeenCalled();
|
||||
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
|
||||
expect(mocks.ensureDevWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("supports dry-run without modifying workspace or sessions", async () => {
|
||||
const workspaceDir = path.join(tempRoot, "workspace-main");
|
||||
const sessionsDir = path.join(tempRoot, "sessions-main");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceDir);
|
||||
mocks.resolveSessionTranscriptsDirForAgent.mockReturnValue(sessionsDir);
|
||||
|
||||
await workspaceResetCommand(runtime as never, { includeSessions: true, dryRun: true });
|
||||
|
||||
expect(mocks.moveToTrash).not.toHaveBeenCalled();
|
||||
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
|
||||
expect(await fs.stat(workspaceDir)).toBeDefined();
|
||||
expect(await fs.stat(sessionsDir)).toBeDefined();
|
||||
});
|
||||
|
||||
it("skips confirmation when --yes is enabled", async () => {
|
||||
const workspaceDir = path.join(tempRoot, "workspace-main");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceDir);
|
||||
|
||||
await workspaceResetCommand(runtime as never, { yes: true });
|
||||
|
||||
expect(mocks.confirm).not.toHaveBeenCalled();
|
||||
expect(mocks.moveToTrash).toHaveBeenCalledWith(workspaceDir, runtime);
|
||||
});
|
||||
|
||||
it("cancels without touching anything when confirmation is declined", async () => {
|
||||
const workspaceDir = path.join(tempRoot, "workspace-main");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceDir);
|
||||
mocks.confirm.mockResolvedValueOnce(false);
|
||||
|
||||
await workspaceResetCommand(runtime as never, {});
|
||||
|
||||
expect(mocks.moveToTrash).not.toHaveBeenCalled();
|
||||
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
|
||||
expect(runtime.exit).toHaveBeenCalledWith(0);
|
||||
expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("Workspace reseeded"));
|
||||
});
|
||||
|
||||
it("reuses the dev reseed helper for the active dev workspace", async () => {
|
||||
const devWorkspace = path.join(tempRoot, "workspace-dev");
|
||||
await fs.mkdir(devWorkspace, { recursive: true });
|
||||
mocks.resolveAgentWorkspaceDir.mockReturnValue(devWorkspace);
|
||||
mocks.resolveDevWorkspaceDir.mockReturnValue(devWorkspace);
|
||||
|
||||
await workspaceResetCommand(runtime as never, { yes: true });
|
||||
|
||||
expect(mocks.ensureDevWorkspace).toHaveBeenCalledWith(devWorkspace);
|
||||
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
|
||||
expect(await fs.readFile(path.join(devWorkspace, "AGENTS.md"), "utf-8")).toContain(
|
||||
"DEV AGENTS",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { cancel, confirm, isCancel } from "@clack/prompts";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { ensureAgentWorkspace } from "../agents/workspace.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { ensureDevWorkspace, resolveDevWorkspaceDir } from "../cli/gateway-cli/dev.js";
|
||||
import { readBestEffortConfig } from "../config/config.js";
|
||||
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { stylePromptMessage, stylePromptTitle } from "../terminal/prompt-style.js";
|
||||
import { resolveUserPath, shortenHomePath } from "../utils.js";
|
||||
import { moveToTrash } from "./onboard-helpers.js";
|
||||
|
||||
export type WorkspaceResetOptions = {
|
||||
workspace?: string;
|
||||
agent?: string;
|
||||
includeSessions?: boolean;
|
||||
yes?: boolean;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
function hasValue(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
async function pathExists(target: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function describeResetPlan(params: {
|
||||
workspaceDir: string;
|
||||
sessionsDir: string;
|
||||
includeSessions: boolean;
|
||||
}): string[] {
|
||||
return [
|
||||
`Workspace: ${shortenHomePath(params.workspaceDir)}`,
|
||||
params.includeSessions ? `Sessions: ${shortenHomePath(params.sessionsDir)}` : undefined,
|
||||
"Preserves: config, credentials, channels, and gateway auth.",
|
||||
].filter((line): line is string => Boolean(line));
|
||||
}
|
||||
|
||||
function isActiveDevWorkspaceTarget(workspaceDir: string): boolean {
|
||||
return resolveUserPath(workspaceDir) === resolveUserPath(resolveDevWorkspaceDir(process.env));
|
||||
}
|
||||
|
||||
export async function workspaceResetCommand(
|
||||
runtime: RuntimeEnv,
|
||||
opts: WorkspaceResetOptions,
|
||||
): Promise<void> {
|
||||
const cfg = await readBestEffortConfig();
|
||||
const hasExplicitWorkspace = hasValue(opts.workspace);
|
||||
const agentId = hasValue(opts.agent) ? opts.agent.trim() : resolveDefaultAgentId(cfg);
|
||||
const workspaceDir = hasExplicitWorkspace
|
||||
? resolveUserPath(opts.workspace!.trim())
|
||||
: resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId);
|
||||
const includeSessions = Boolean(opts.includeSessions);
|
||||
const dryRun = Boolean(opts.dryRun);
|
||||
|
||||
if (hasExplicitWorkspace && includeSessions) {
|
||||
throw new Error(
|
||||
"--include-sessions cannot be combined with --workspace; sessions are resolved from configured agent state only.",
|
||||
);
|
||||
}
|
||||
|
||||
for (const line of describeResetPlan({ workspaceDir, sessionsDir, includeSessions })) {
|
||||
runtime.log(line);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
runtime.log(`[dry-run] trash ${shortenHomePath(workspaceDir)}`);
|
||||
if (includeSessions) {
|
||||
runtime.log(`[dry-run] trash ${shortenHomePath(sessionsDir)}`);
|
||||
}
|
||||
runtime.log(
|
||||
`[dry-run] reseed ${shortenHomePath(workspaceDir)} with default workspace files and BOOTSTRAP.md`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!opts.yes) {
|
||||
const ok = await confirm({
|
||||
message: stylePromptMessage(
|
||||
`Trash and reseed ${shortenHomePath(workspaceDir)}${includeSessions ? " and clear this agent's sessions" : ""}?`,
|
||||
),
|
||||
});
|
||||
if (isCancel(ok) || !ok) {
|
||||
cancel(stylePromptTitle("Workspace reset cancelled.") ?? "Workspace reset cancelled.");
|
||||
runtime.exit(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await moveToTrash(workspaceDir, runtime);
|
||||
if (await pathExists(workspaceDir)) {
|
||||
throw new Error(
|
||||
`Workspace reset did not remove ${shortenHomePath(workspaceDir)}. Move it manually or retry.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (includeSessions) {
|
||||
await moveToTrash(sessionsDir, runtime);
|
||||
if (await pathExists(sessionsDir)) {
|
||||
throw new Error(
|
||||
`Session reset did not remove ${shortenHomePath(sessionsDir)}. Move it manually or retry.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isActiveDevWorkspaceTarget(workspaceDir)) {
|
||||
await ensureDevWorkspace(workspaceDir);
|
||||
} else {
|
||||
await ensureAgentWorkspace({
|
||||
dir: workspaceDir,
|
||||
ensureBootstrapFiles: true,
|
||||
});
|
||||
}
|
||||
runtime.log(`Workspace reseeded: ${shortenHomePath(workspaceDir)}`);
|
||||
|
||||
if (includeSessions) {
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
runtime.log(`Sessions reset: ${shortenHomePath(sessionsDir)}`);
|
||||
}
|
||||
|
||||
runtime.log("Workspace reset complete.");
|
||||
runtime.log("Recommended next steps:");
|
||||
runtime.log(`- ${formatCliCommand("openclaw onboard")}`);
|
||||
runtime.log(`- ${formatCliCommand("openclaw gateway run")}`);
|
||||
}
|
||||
@@ -90,3 +90,13 @@ export async function fetchWithRuntimeDispatcher(
|
||||
normalizeRuntimeRequestInit(init, runtimeDeps.FormData),
|
||||
)) as Response;
|
||||
}
|
||||
|
||||
export async function fetchWithRuntimeDispatcherOrMockedGlobal(
|
||||
input: RequestInfo | URL,
|
||||
init?: DispatcherAwareRequestInit,
|
||||
): Promise<Response> {
|
||||
if (isMockedFetch(globalThis.fetch)) {
|
||||
return await globalThis.fetch(input, init);
|
||||
}
|
||||
return await fetchWithRuntimeDispatcher(input, init);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,11 @@ describe("getChannelMessageAdapter", () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "discord", plugin: discordCrossContextPlugin, source: "test" },
|
||||
{
|
||||
pluginId: "telegram",
|
||||
plugin: createChannelTestPluginBase({ id: "telegram" }),
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
20
src/plugin-sdk/account-resolution-runtime.ts
Normal file
20
src/plugin-sdk/account-resolution-runtime.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export { resolveMergedAccountConfig } from "../channels/plugins/account-helpers.js";
|
||||
export { resolveNormalizedAccountEntry } from "../routing/account-lookup.js";
|
||||
|
||||
/** List normalized configured account ids from a raw channel account record map. */
|
||||
export function listConfiguredAccountIds(params: {
|
||||
accounts: Record<string, unknown> | undefined;
|
||||
normalizeAccountId: (accountId: string) => string;
|
||||
}): string[] {
|
||||
if (!params.accounts) {
|
||||
return [];
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (const key of Object.keys(params.accounts)) {
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
ids.add(params.normalizeAccountId(key));
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
4
src/plugin-sdk/channel-reply-options-runtime.ts
Normal file
4
src/plugin-sdk/channel-reply-options-runtime.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Narrow reply helper surface for channel handlers that do not need the full
|
||||
// reply pipeline factory.
|
||||
export { createReplyPrefixOptions } from "../channels/reply-prefix.js";
|
||||
export { createTypingCallbacks } from "../channels/typing.js";
|
||||
@@ -3,5 +3,7 @@
|
||||
|
||||
export {
|
||||
fetchWithRuntimeDispatcher,
|
||||
fetchWithRuntimeDispatcherOrMockedGlobal,
|
||||
isMockedFetch,
|
||||
type DispatcherAwareRequestInit,
|
||||
} from "../infra/net/runtime-fetch.js";
|
||||
|
||||
6
src/plugin-sdk/session-key-runtime.ts
Normal file
6
src/plugin-sdk/session-key-runtime.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Narrow session-key helpers for channel hot paths that should not import the
|
||||
// broader routing SDK barrel.
|
||||
export {
|
||||
resolveAgentIdFromSessionKey,
|
||||
type ParsedAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
@@ -2,3 +2,5 @@
|
||||
|
||||
export { loadSessionStore } from "../config/sessions/store-load.js";
|
||||
export { resolveSessionStoreEntry } from "../config/sessions/store-entry.js";
|
||||
export { resolveStorePath } from "../config/sessions/paths.js";
|
||||
export { readSessionUpdatedAt } from "../config/sessions/store.js";
|
||||
|
||||
52
src/plugin-sdk/thread-bindings-session-runtime.ts
Normal file
52
src/plugin-sdk/thread-bindings-session-runtime.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export { resolveThreadBindingFarewellText } from "../channels/thread-bindings-messages.js";
|
||||
export {
|
||||
registerSessionBindingAdapter,
|
||||
unregisterSessionBindingAdapter,
|
||||
type BindingTargetKind,
|
||||
type SessionBindingAdapter,
|
||||
type SessionBindingRecord,
|
||||
} from "../infra/outbound/session-binding-service.js";
|
||||
|
||||
type ThreadBindingLifecycleRecord = {
|
||||
boundAt: number;
|
||||
lastActivityAt: number;
|
||||
idleTimeoutMs?: number;
|
||||
maxAgeMs?: number;
|
||||
};
|
||||
|
||||
export function resolveThreadBindingLifecycle(params: {
|
||||
record: ThreadBindingLifecycleRecord;
|
||||
defaultIdleTimeoutMs: number;
|
||||
defaultMaxAgeMs: number;
|
||||
}): {
|
||||
expiresAt?: number;
|
||||
reason?: "idle-expired" | "max-age-expired";
|
||||
} {
|
||||
const idleTimeoutMs =
|
||||
typeof params.record.idleTimeoutMs === "number"
|
||||
? Math.max(0, Math.floor(params.record.idleTimeoutMs))
|
||||
: params.defaultIdleTimeoutMs;
|
||||
const maxAgeMs =
|
||||
typeof params.record.maxAgeMs === "number"
|
||||
? Math.max(0, Math.floor(params.record.maxAgeMs))
|
||||
: params.defaultMaxAgeMs;
|
||||
|
||||
const inactivityExpiresAt =
|
||||
idleTimeoutMs > 0
|
||||
? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs
|
||||
: undefined;
|
||||
const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined;
|
||||
|
||||
if (inactivityExpiresAt != null && maxAgeExpiresAt != null) {
|
||||
return inactivityExpiresAt <= maxAgeExpiresAt
|
||||
? { expiresAt: inactivityExpiresAt, reason: "idle-expired" }
|
||||
: { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" };
|
||||
}
|
||||
if (inactivityExpiresAt != null) {
|
||||
return { expiresAt: inactivityExpiresAt, reason: "idle-expired" };
|
||||
}
|
||||
if (maxAgeExpiresAt != null) {
|
||||
return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -346,7 +346,6 @@ describe("plugin-sdk subpath exports", () => {
|
||||
"pairing-access",
|
||||
"provider-model-definitions",
|
||||
"reply-prefix",
|
||||
"secret-input-runtime",
|
||||
"secret-input-schema",
|
||||
"signal-core",
|
||||
"synology-chat",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
formatStepFailure,
|
||||
installCanaryArtifactCleanup,
|
||||
isBoundaryCompileFresh,
|
||||
resolveBoundaryCheckSelection,
|
||||
resolveBoundaryCheckLockPath,
|
||||
resolveCanaryArtifactPaths,
|
||||
runNodeStepAsync,
|
||||
@@ -165,6 +166,29 @@ describe("check-extension-package-tsc-boundary", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the scope label when the boundary check was CI-scoped", () => {
|
||||
expect(
|
||||
formatBoundaryCheckSuccessSummary({
|
||||
mode: "all",
|
||||
scope: "scoped",
|
||||
compileCount: 2,
|
||||
skippedCompileCount: 0,
|
||||
canaryCount: 1,
|
||||
elapsedMs: 1_234,
|
||||
}),
|
||||
).toBe(
|
||||
[
|
||||
"extension package boundary check passed",
|
||||
"mode: all",
|
||||
"scope: scoped",
|
||||
"compiled plugins: 2",
|
||||
"canary plugins: 1",
|
||||
"elapsed: 1234ms",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits phase timings that never ran", () => {
|
||||
expect(
|
||||
formatBoundaryCheckSuccessSummary({
|
||||
@@ -220,6 +244,50 @@ describe("check-extension-package-tsc-boundary", () => {
|
||||
).toBe(["slowest plugin compiles:", "- slow: 900ms", "- medium: 250ms", ""].join("\n"));
|
||||
});
|
||||
|
||||
it("keeps the full sweep when shared plugin-sdk paths changed", () => {
|
||||
expect(
|
||||
resolveBoundaryCheckSelection({
|
||||
optInExtensionIds: ["browser", "matrix", "zalo"],
|
||||
changedExtensionIds: ["browser"],
|
||||
changedPaths: ["src/plugin-sdk/provider-entry.ts"],
|
||||
resolveCanaryExtensionIds: (extensionIds: string[]) => extensionIds.slice(0, 2),
|
||||
}),
|
||||
).toEqual({
|
||||
scope: "full",
|
||||
compileExtensionIds: ["browser", "matrix", "zalo"],
|
||||
canaryExtensionIds: ["browser", "matrix"],
|
||||
});
|
||||
});
|
||||
|
||||
it("scopes CI compiles to changed opt-in extensions when only extension-local paths changed", () => {
|
||||
expect(
|
||||
resolveBoundaryCheckSelection({
|
||||
optInExtensionIds: ["browser", "matrix", "zalo"],
|
||||
changedExtensionIds: ["browser", "matrix"],
|
||||
changedPaths: ["extensions/browser/src/runtime.ts", "docs/plugins/sdk-overview.md"],
|
||||
resolveCanaryExtensionIds: (extensionIds: string[]) => extensionIds,
|
||||
}),
|
||||
).toEqual({
|
||||
scope: "scoped",
|
||||
compileExtensionIds: ["browser", "matrix"],
|
||||
canaryExtensionIds: ["browser", "matrix"],
|
||||
});
|
||||
});
|
||||
|
||||
it("skips the boundary sweep when no opt-in extension or shared path changed", () => {
|
||||
expect(
|
||||
resolveBoundaryCheckSelection({
|
||||
optInExtensionIds: ["browser", "matrix", "zalo"],
|
||||
changedExtensionIds: ["docs-helper"],
|
||||
changedPaths: ["docs/reference/cli.md"],
|
||||
}),
|
||||
).toEqual({
|
||||
scope: "skip",
|
||||
compileExtensionIds: [],
|
||||
canaryExtensionIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a plugin compile as fresh only when its outputs are newer than plugin and shared sdk inputs", () => {
|
||||
const { rootDir, extensionRoot } = createTempExtensionRoot();
|
||||
const extensionSourcePath = path.join(extensionRoot, "index.ts");
|
||||
|
||||
91
test/scripts/check-gateway-watch-regression.test.ts
Normal file
91
test/scripts/check-gateway-watch-regression.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasGatewayReadyLog,
|
||||
listTreeEntries,
|
||||
resolveReadyObservation,
|
||||
snapshotTree,
|
||||
stripAnsi,
|
||||
} from "../../scripts/check-gateway-watch-regression.mjs";
|
||||
|
||||
function createDirent(name: string, kind: "dir" | "file" | "symlink") {
|
||||
return {
|
||||
name,
|
||||
isDirectory: () => kind === "dir",
|
||||
isFile: () => kind === "file",
|
||||
isSymbolicLink: () => kind === "symlink",
|
||||
};
|
||||
}
|
||||
|
||||
describe("check-gateway-watch-regression", () => {
|
||||
it("detects the gateway ready line even when logs are ANSI-colorized", () => {
|
||||
const line =
|
||||
"\u001b[90m2026-04-17T16:47:21.723+00:00\u001b[39m \u001b[36m[gateway]\u001b[39m \u001b[36mready (5 plugins: acpx; 1.8s)\u001b[39m";
|
||||
|
||||
expect(stripAnsi(line)).toContain("[gateway] ready (5 plugins: acpx; 1.8s)");
|
||||
expect(hasGatewayReadyLog(line)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a buffered ready log as a successful ready observation", () => {
|
||||
expect(
|
||||
resolveReadyObservation(
|
||||
false,
|
||||
"2026-04-17T10:34:39.684-07:00 [gateway] ready (5 plugins: acpx; 3.9s)\n",
|
||||
"",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps missing trees explicit in snapshot path listings", () => {
|
||||
const entries = listTreeEntries("dist-runtime", {
|
||||
cwd: "/repo",
|
||||
fs: {
|
||||
existsSync: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(entries).toEqual(["dist-runtime (missing)"]);
|
||||
});
|
||||
|
||||
it("ignores files that disappear between readdir and lstat while snapshotting", () => {
|
||||
const fakeFs = {
|
||||
existsSync(filePath: string) {
|
||||
return filePath === "/repo/dist";
|
||||
},
|
||||
readdirSync(filePath: string) {
|
||||
if (filePath === "/repo/dist") {
|
||||
return [createDirent("kept.js", "file"), createDirent("gone.js", "file")];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
lstatSync(filePath: string) {
|
||||
if (filePath === "/repo/dist") {
|
||||
return {
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
};
|
||||
}
|
||||
if (filePath === "/repo/dist/kept.js") {
|
||||
return {
|
||||
size: 7,
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
};
|
||||
}
|
||||
const error = new Error(`ENOENT: ${filePath}`) as NodeJS.ErrnoException;
|
||||
error.code = "ENOENT";
|
||||
throw error;
|
||||
},
|
||||
};
|
||||
|
||||
expect(snapshotTree("dist", { cwd: "/repo", fs: fakeFs })).toEqual({
|
||||
exists: true,
|
||||
files: 1,
|
||||
directories: 1,
|
||||
symlinks: 0,
|
||||
entries: 2,
|
||||
apparentBytes: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,28 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { refreshChat, refreshChatAvatar } from "./app-chat.ts";
|
||||
import { syncUrlWithSessionKey } from "./app-settings.ts";
|
||||
import type { AppViewState } from "./app-view-state.ts";
|
||||
import { createChatModelOverride } from "./chat-model-ref.ts";
|
||||
import {
|
||||
resolveChatModelOverrideValue,
|
||||
resolveChatModelSelectState,
|
||||
} from "./chat-model-select-state.ts";
|
||||
isCronSessionKey,
|
||||
parseSessionKey,
|
||||
renderChatSessionSelect as renderChatSessionSelectBase,
|
||||
renderChatThinkingSelect,
|
||||
resolveSessionDisplayName,
|
||||
resolveSessionOptionGroups,
|
||||
} from "./chat/session-controls.ts";
|
||||
import { refreshSlashCommands } from "./chat/slash-commands.ts";
|
||||
import { refreshVisibleToolsEffectiveForCurrentSession } from "./controllers/agents.ts";
|
||||
import { ChatState, loadChatHistory } from "./controllers/chat.ts";
|
||||
import { loadSessions } from "./controllers/sessions.ts";
|
||||
import { icons } from "./icons.ts";
|
||||
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts";
|
||||
import { parseAgentSessionKey } from "./session-key.ts";
|
||||
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-coerce.ts";
|
||||
import { normalizeOptionalString } from "./string-coerce.ts";
|
||||
import type { ThemeMode } from "./theme.ts";
|
||||
import {
|
||||
listThinkingLevelLabels,
|
||||
normalizeThinkLevel,
|
||||
resolveThinkingDefaultForModel,
|
||||
} from "./thinking.ts";
|
||||
import type { SessionsListResult } from "./types.ts";
|
||||
|
||||
export { isCronSessionKey, parseSessionKey, resolveSessionDisplayName, resolveSessionOptionGroups };
|
||||
|
||||
type SessionDefaultsSnapshot = {
|
||||
mainSessionKey?: string;
|
||||
mainKey?: string;
|
||||
@@ -174,51 +172,7 @@ function renderCronFilterIcon(hiddenCount: number) {
|
||||
}
|
||||
|
||||
export function renderChatSessionSelect(state: AppViewState) {
|
||||
const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult);
|
||||
const modelSelect = renderChatModelSelect(state);
|
||||
const thinkingSelect = renderChatThinkingSelect(state);
|
||||
const selectedSessionLabel =
|
||||
sessionGroups.flatMap((group) => group.options).find((entry) => entry.key === state.sessionKey)
|
||||
?.label ?? state.sessionKey;
|
||||
return html`
|
||||
<div class="chat-controls__session-row">
|
||||
<label class="field chat-controls__session">
|
||||
<select
|
||||
.value=${state.sessionKey}
|
||||
title=${selectedSessionLabel}
|
||||
?disabled=${!state.connected || sessionGroups.length === 0}
|
||||
@change=${(e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value;
|
||||
if (state.sessionKey === next) {
|
||||
return;
|
||||
}
|
||||
switchChatSession(state, next);
|
||||
}}
|
||||
>
|
||||
${repeat(
|
||||
sessionGroups,
|
||||
(group) => group.id,
|
||||
(group) =>
|
||||
html`<optgroup label=${group.label}>
|
||||
${repeat(
|
||||
group.options,
|
||||
(entry) => entry.key,
|
||||
(entry) =>
|
||||
html`<option
|
||||
value=${entry.key}
|
||||
title=${entry.title}
|
||||
?selected=${entry.key === state.sessionKey}
|
||||
>
|
||||
${entry.label}
|
||||
</option>`,
|
||||
)}
|
||||
</optgroup>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
${modelSelect} ${thinkingSelect}
|
||||
</div>
|
||||
`;
|
||||
return renderChatSessionSelectBase(state, switchChatSession);
|
||||
}
|
||||
|
||||
export function renderChatControls(state: AppViewState) {
|
||||
@@ -579,520 +533,6 @@ async function refreshSessionOptions(state: AppViewState) {
|
||||
});
|
||||
}
|
||||
|
||||
function renderChatModelSelect(state: AppViewState) {
|
||||
const { currentOverride, defaultLabel, options } = resolveChatModelSelectState(state);
|
||||
const busy =
|
||||
state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null;
|
||||
const disabled =
|
||||
!state.connected || busy || (state.chatModelsLoading && options.length === 0) || !state.client;
|
||||
const selectedLabel =
|
||||
currentOverride === ""
|
||||
? defaultLabel
|
||||
: (options.find((entry) => entry.value === currentOverride)?.label ?? currentOverride);
|
||||
return html`
|
||||
<label class="field chat-controls__session chat-controls__model">
|
||||
<select
|
||||
data-chat-model-select="true"
|
||||
aria-label="Chat model"
|
||||
title=${selectedLabel}
|
||||
?disabled=${disabled}
|
||||
@change=${async (e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value.trim();
|
||||
await switchChatModel(state, next);
|
||||
}}
|
||||
>
|
||||
<option value="" ?selected=${currentOverride === ""}>${defaultLabel}</option>
|
||||
${repeat(
|
||||
options,
|
||||
(entry) => entry.value,
|
||||
(entry) =>
|
||||
html`<option value=${entry.value} ?selected=${entry.value === currentOverride}>
|
||||
${entry.label}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
type ChatThinkingSelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type ChatThinkingSelectState = {
|
||||
currentOverride: string;
|
||||
defaultLabel: string;
|
||||
options: ChatThinkingSelectOption[];
|
||||
};
|
||||
|
||||
function resolveThinkingTargetModel(state: AppViewState): {
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
} {
|
||||
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey);
|
||||
return {
|
||||
provider: activeRow?.modelProvider ?? state.sessionsResult?.defaults?.modelProvider ?? null,
|
||||
model: activeRow?.model ?? state.sessionsResult?.defaults?.model ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildThinkingOptions(
|
||||
provider: string | null,
|
||||
model: string | null,
|
||||
currentOverride: string,
|
||||
): ChatThinkingSelectOption[] {
|
||||
const seen = new Set<string>();
|
||||
const options: ChatThinkingSelectOption[] = [];
|
||||
|
||||
const addOption = (value: string, label?: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
const key = normalizeLowercaseStringOrEmpty(trimmed);
|
||||
if (seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
options.push({
|
||||
value: trimmed,
|
||||
label:
|
||||
label ??
|
||||
trimmed
|
||||
.split(/[-_]/g)
|
||||
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
|
||||
.join(" "),
|
||||
});
|
||||
};
|
||||
|
||||
for (const label of listThinkingLevelLabels(provider)) {
|
||||
const normalized = normalizeThinkLevel(label) ?? normalizeLowercaseStringOrEmpty(label);
|
||||
addOption(normalized);
|
||||
}
|
||||
if (currentOverride) {
|
||||
addOption(currentOverride);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function resolveChatThinkingSelectState(state: AppViewState): ChatThinkingSelectState {
|
||||
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey);
|
||||
const persisted = activeRow?.thinkingLevel;
|
||||
const currentOverride =
|
||||
typeof persisted === "string" && persisted.trim()
|
||||
? (normalizeThinkLevel(persisted) ?? persisted.trim())
|
||||
: "";
|
||||
const { provider, model } = resolveThinkingTargetModel(state);
|
||||
const defaultLevel =
|
||||
provider && model
|
||||
? resolveThinkingDefaultForModel({
|
||||
provider,
|
||||
model,
|
||||
catalog: state.chatModelCatalog ?? [],
|
||||
})
|
||||
: "off";
|
||||
return {
|
||||
currentOverride,
|
||||
defaultLabel: `Default (${defaultLevel})`,
|
||||
options: buildThinkingOptions(provider, model, currentOverride),
|
||||
};
|
||||
}
|
||||
|
||||
function renderChatThinkingSelect(state: AppViewState) {
|
||||
const { currentOverride, defaultLabel, options } = resolveChatThinkingSelectState(state);
|
||||
const busy =
|
||||
state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null;
|
||||
const disabled = !state.connected || busy || !state.client;
|
||||
const selectedLabel =
|
||||
currentOverride === ""
|
||||
? defaultLabel
|
||||
: (options.find((entry) => entry.value === currentOverride)?.label ?? currentOverride);
|
||||
return html`
|
||||
<label class="field chat-controls__session chat-controls__thinking-select">
|
||||
<select
|
||||
data-chat-thinking-select="true"
|
||||
aria-label="Chat thinking level"
|
||||
title=${selectedLabel}
|
||||
?disabled=${disabled}
|
||||
@change=${async (e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value.trim();
|
||||
await switchChatThinkingLevel(state, next);
|
||||
}}
|
||||
>
|
||||
<option value="" ?selected=${currentOverride === ""}>${defaultLabel}</option>
|
||||
${repeat(
|
||||
options,
|
||||
(entry) => entry.value,
|
||||
(entry) =>
|
||||
html`<option value=${entry.value} ?selected=${entry.value === currentOverride}>
|
||||
${entry.label}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
async function switchChatModel(state: AppViewState, nextModel: string) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
const currentOverride = resolveChatModelOverrideValue(state);
|
||||
if (currentOverride === nextModel) {
|
||||
return;
|
||||
}
|
||||
const targetSessionKey = state.sessionKey;
|
||||
const prevOverride = state.chatModelOverrides[targetSessionKey];
|
||||
state.lastError = null;
|
||||
// Write the override cache immediately so the picker stays in sync during the RPC round-trip.
|
||||
state.chatModelOverrides = {
|
||||
...state.chatModelOverrides,
|
||||
[targetSessionKey]: createChatModelOverride(nextModel),
|
||||
};
|
||||
try {
|
||||
await state.client.request("sessions.patch", {
|
||||
key: targetSessionKey,
|
||||
model: nextModel || null,
|
||||
});
|
||||
void refreshVisibleToolsEffectiveForCurrentSession(state);
|
||||
await refreshSessionOptions(state);
|
||||
} catch (err) {
|
||||
// Roll back so the picker reflects the actual server model.
|
||||
state.chatModelOverrides = { ...state.chatModelOverrides, [targetSessionKey]: prevOverride };
|
||||
state.lastError = `Failed to set model: ${String(err)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function patchSessionThinkingLevel(
|
||||
state: AppViewState,
|
||||
sessionKey: string,
|
||||
thinkingLevel: string | undefined,
|
||||
) {
|
||||
const current = state.sessionsResult;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
state.sessionsResult = {
|
||||
...current,
|
||||
sessions: current.sessions.map((row) =>
|
||||
row.key === sessionKey
|
||||
? {
|
||||
...row,
|
||||
thinkingLevel,
|
||||
}
|
||||
: row,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function switchChatThinkingLevel(state: AppViewState, nextThinkingLevel: string) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
const targetSessionKey = state.sessionKey;
|
||||
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === targetSessionKey);
|
||||
const previousThinkingLevel = activeRow?.thinkingLevel;
|
||||
const normalizedNext =
|
||||
(normalizeThinkLevel(nextThinkingLevel) ?? nextThinkingLevel.trim()) || undefined;
|
||||
const normalizedPrev =
|
||||
typeof previousThinkingLevel === "string" && previousThinkingLevel.trim()
|
||||
? (normalizeThinkLevel(previousThinkingLevel) ?? previousThinkingLevel.trim())
|
||||
: undefined;
|
||||
if ((normalizedPrev ?? "") === (normalizedNext ?? "")) {
|
||||
return;
|
||||
}
|
||||
state.lastError = null;
|
||||
patchSessionThinkingLevel(state, targetSessionKey, normalizedNext);
|
||||
state.chatThinkingLevel = normalizedNext ?? null;
|
||||
try {
|
||||
await state.client.request("sessions.patch", {
|
||||
key: targetSessionKey,
|
||||
thinkingLevel: normalizedNext ?? null,
|
||||
});
|
||||
await refreshSessionOptions(state);
|
||||
} catch (err) {
|
||||
patchSessionThinkingLevel(state, targetSessionKey, previousThinkingLevel);
|
||||
state.chatThinkingLevel = normalizedPrev ?? null;
|
||||
state.lastError = `Failed to set thinking level: ${String(err)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Channel display labels ────────────────────────────── */
|
||||
const CHANNEL_LABELS: Record<string, string> = {
|
||||
bluebubbles: "iMessage",
|
||||
telegram: "Telegram",
|
||||
discord: "Discord",
|
||||
signal: "Signal",
|
||||
slack: "Slack",
|
||||
whatsapp: "WhatsApp",
|
||||
matrix: "Matrix",
|
||||
email: "Email",
|
||||
sms: "SMS",
|
||||
};
|
||||
|
||||
const KNOWN_CHANNEL_KEYS = Object.keys(CHANNEL_LABELS);
|
||||
|
||||
/** Parsed type / context extracted from a session key. */
|
||||
export type SessionKeyInfo = {
|
||||
/** Prefix for typed sessions (Subagent:/Cron:). Empty for others. */
|
||||
prefix: string;
|
||||
/** Human-readable fallback when no label / displayName is available. */
|
||||
fallbackName: string;
|
||||
};
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a session key to extract type information and a human-readable
|
||||
* fallback display name. Exported for testing.
|
||||
*/
|
||||
export function parseSessionKey(key: string): SessionKeyInfo {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(key);
|
||||
|
||||
// ── Main session ─────────────────────────────────
|
||||
if (key === "main" || key === "agent:main:main") {
|
||||
return { prefix: "", fallbackName: "Main Session" };
|
||||
}
|
||||
|
||||
// ── Subagent ─────────────────────────────────────
|
||||
if (key.includes(":subagent:")) {
|
||||
return { prefix: "Subagent:", fallbackName: "Subagent:" };
|
||||
}
|
||||
|
||||
// ── Cron job ─────────────────────────────────────
|
||||
if (normalized.startsWith("cron:") || key.includes(":cron:")) {
|
||||
return { prefix: "Cron:", fallbackName: "Cron Job:" };
|
||||
}
|
||||
|
||||
// ── Direct chat (agent:<x>:<channel>:direct:<id>) ──
|
||||
const directMatch = key.match(/^agent:[^:]+:([^:]+):direct:(.+)$/);
|
||||
if (directMatch) {
|
||||
const channel = directMatch[1];
|
||||
const identifier = directMatch[2];
|
||||
const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel);
|
||||
return { prefix: "", fallbackName: `${channelLabel} · ${identifier}` };
|
||||
}
|
||||
|
||||
// ── Group chat (agent:<x>:<channel>:group:<id>) ────
|
||||
const groupMatch = key.match(/^agent:[^:]+:([^:]+):group:(.+)$/);
|
||||
if (groupMatch) {
|
||||
const channel = groupMatch[1];
|
||||
const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel);
|
||||
return { prefix: "", fallbackName: `${channelLabel} Group` };
|
||||
}
|
||||
|
||||
// ── Channel-prefixed legacy keys (e.g. "bluebubbles:g-…") ──
|
||||
for (const ch of KNOWN_CHANNEL_KEYS) {
|
||||
if (key === ch || key.startsWith(`${ch}:`)) {
|
||||
return { prefix: "", fallbackName: `${CHANNEL_LABELS[ch]} Session` };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unknown — return key as-is ───────────────────
|
||||
return { prefix: "", fallbackName: key };
|
||||
}
|
||||
|
||||
export function resolveSessionDisplayName(
|
||||
key: string,
|
||||
row?: SessionsListResult["sessions"][number],
|
||||
): string {
|
||||
const label = normalizeOptionalString(row?.label) ?? "";
|
||||
const displayName = normalizeOptionalString(row?.displayName) ?? "";
|
||||
const { prefix, fallbackName } = parseSessionKey(key);
|
||||
|
||||
const applyTypedPrefix = (name: string): string => {
|
||||
if (!prefix) {
|
||||
return name;
|
||||
}
|
||||
const prefixPattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}\\s*`, "i");
|
||||
return prefixPattern.test(name) ? name : `${prefix} ${name}`;
|
||||
};
|
||||
|
||||
if (label && label !== key) {
|
||||
return applyTypedPrefix(label);
|
||||
}
|
||||
if (displayName && displayName !== key) {
|
||||
return applyTypedPrefix(displayName);
|
||||
}
|
||||
return fallbackName;
|
||||
}
|
||||
|
||||
export function isCronSessionKey(key: string): boolean {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(key);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (normalized.startsWith("cron:")) {
|
||||
return true;
|
||||
}
|
||||
if (!normalized.startsWith("agent:")) {
|
||||
return false;
|
||||
}
|
||||
const parts = normalized.split(":").filter(Boolean);
|
||||
if (parts.length < 3) {
|
||||
return false;
|
||||
}
|
||||
const rest = parts.slice(2).join(":");
|
||||
return rest.startsWith("cron:");
|
||||
}
|
||||
|
||||
type SessionOptionEntry = {
|
||||
key: string;
|
||||
label: string;
|
||||
scopeLabel: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type SessionOptionGroup = {
|
||||
id: string;
|
||||
label: string;
|
||||
options: SessionOptionEntry[];
|
||||
};
|
||||
|
||||
export function resolveSessionOptionGroups(
|
||||
state: AppViewState,
|
||||
sessionKey: string,
|
||||
sessions: SessionsListResult | null,
|
||||
): SessionOptionGroup[] {
|
||||
const rows = sessions?.sessions ?? [];
|
||||
const hideCron = state.sessionsHideCron ?? true;
|
||||
const byKey = new Map<string, SessionsListResult["sessions"][number]>();
|
||||
for (const row of rows) {
|
||||
byKey.set(row.key, row);
|
||||
}
|
||||
|
||||
const seenKeys = new Set<string>();
|
||||
const groups = new Map<string, SessionOptionGroup>();
|
||||
const ensureGroup = (groupId: string, label: string): SessionOptionGroup => {
|
||||
const existing = groups.get(groupId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const created: SessionOptionGroup = {
|
||||
id: groupId,
|
||||
label,
|
||||
options: [],
|
||||
};
|
||||
groups.set(groupId, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
const addOption = (key: string) => {
|
||||
if (!key || seenKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
seenKeys.add(key);
|
||||
const row = byKey.get(key);
|
||||
const parsed = parseAgentSessionKey(key);
|
||||
const group = parsed
|
||||
? ensureGroup(
|
||||
`agent:${normalizeLowercaseStringOrEmpty(parsed.agentId)}`,
|
||||
resolveAgentGroupLabel(state, parsed.agentId),
|
||||
)
|
||||
: ensureGroup("other", "Other Sessions");
|
||||
const scopeLabel = normalizeOptionalString(parsed?.rest) ?? key;
|
||||
const label = resolveSessionScopedOptionLabel(key, row, parsed?.rest);
|
||||
group.options.push({
|
||||
key,
|
||||
label,
|
||||
scopeLabel,
|
||||
title: key,
|
||||
});
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.key !== sessionKey && (row.kind === "global" || row.kind === "unknown")) {
|
||||
continue;
|
||||
}
|
||||
if (hideCron && row.key !== sessionKey && isCronSessionKey(row.key)) {
|
||||
continue;
|
||||
}
|
||||
addOption(row.key);
|
||||
}
|
||||
addOption(sessionKey);
|
||||
|
||||
for (const group of groups.values()) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const option of group.options) {
|
||||
counts.set(option.label, (counts.get(option.label) ?? 0) + 1);
|
||||
}
|
||||
for (const option of group.options) {
|
||||
if ((counts.get(option.label) ?? 0) > 1 && option.scopeLabel !== option.label) {
|
||||
option.label = `${option.label} · ${option.scopeLabel}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allOptions = Array.from(groups.values()).flatMap((group) =>
|
||||
group.options.map((option) => ({ groupLabel: group.label, option })),
|
||||
);
|
||||
const labels = new Map(allOptions.map(({ option }) => [option, option.label]));
|
||||
const countAssignedLabels = () => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const { option } of allOptions) {
|
||||
const label = labels.get(option) ?? option.label;
|
||||
counts.set(label, (counts.get(label) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
};
|
||||
const labelIncludesScopeLabel = (label: string, scopeLabel: string) => {
|
||||
const trimmedScope = scopeLabel.trim();
|
||||
if (!trimmedScope) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
label === trimmedScope ||
|
||||
label.endsWith(` · ${trimmedScope}`) ||
|
||||
label.endsWith(` / ${trimmedScope}`)
|
||||
);
|
||||
};
|
||||
|
||||
const globalCounts = countAssignedLabels();
|
||||
for (const { groupLabel, option } of allOptions) {
|
||||
const currentLabel = labels.get(option) ?? option.label;
|
||||
if ((globalCounts.get(currentLabel) ?? 0) <= 1) {
|
||||
continue;
|
||||
}
|
||||
const scopedPrefix = `${groupLabel} / `;
|
||||
if (currentLabel.startsWith(scopedPrefix)) {
|
||||
continue;
|
||||
}
|
||||
// Keep the agent visible once the native select collapses to a single chosen label.
|
||||
labels.set(option, `${groupLabel} / ${currentLabel}`);
|
||||
}
|
||||
|
||||
const scopedCounts = countAssignedLabels();
|
||||
for (const { option } of allOptions) {
|
||||
const currentLabel = labels.get(option) ?? option.label;
|
||||
if ((scopedCounts.get(currentLabel) ?? 0) <= 1) {
|
||||
continue;
|
||||
}
|
||||
if (labelIncludesScopeLabel(currentLabel, option.scopeLabel)) {
|
||||
continue;
|
||||
}
|
||||
labels.set(option, `${currentLabel} · ${option.scopeLabel}`);
|
||||
}
|
||||
|
||||
const finalCounts = countAssignedLabels();
|
||||
for (const { option } of allOptions) {
|
||||
const currentLabel = labels.get(option) ?? option.label;
|
||||
if ((finalCounts.get(currentLabel) ?? 0) <= 1) {
|
||||
continue;
|
||||
}
|
||||
// Fall back to the full key only when every friendlier disambiguator still collides.
|
||||
labels.set(option, `${currentLabel} · ${option.key}`);
|
||||
}
|
||||
|
||||
for (const { option } of allOptions) {
|
||||
option.label = labels.get(option) ?? option.label;
|
||||
}
|
||||
|
||||
return Array.from(groups.values());
|
||||
}
|
||||
|
||||
/** Count sessions with a cron: key that would be hidden when hideCron=true. */
|
||||
function countHiddenCronSessions(sessionKey: string, sessions: SessionsListResult | null): number {
|
||||
if (!sessions?.sessions) {
|
||||
@@ -1102,35 +542,6 @@ function countHiddenCronSessions(sessionKey: string, sessions: SessionsListResul
|
||||
return sessions.sessions.filter((s) => isCronSessionKey(s.key) && s.key !== sessionKey).length;
|
||||
}
|
||||
|
||||
function resolveAgentGroupLabel(state: AppViewState, agentIdRaw: string): string {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(agentIdRaw);
|
||||
const agent = (state.agentsList?.agents ?? []).find(
|
||||
(entry) => normalizeLowercaseStringOrEmpty(entry.id) === normalized,
|
||||
);
|
||||
const name =
|
||||
normalizeOptionalString(agent?.identity?.name) ?? normalizeOptionalString(agent?.name) ?? "";
|
||||
return name && name !== agentIdRaw ? `${name} (${agentIdRaw})` : agentIdRaw;
|
||||
}
|
||||
|
||||
function resolveSessionScopedOptionLabel(
|
||||
key: string,
|
||||
row?: SessionsListResult["sessions"][number],
|
||||
rest?: string,
|
||||
) {
|
||||
const base = normalizeOptionalString(rest) ?? key;
|
||||
if (!row) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const label = normalizeOptionalString(row.label) ?? "";
|
||||
const displayName = normalizeOptionalString(row.displayName) ?? "";
|
||||
if ((label && label !== key) || (displayName && displayName !== key)) {
|
||||
return resolveSessionDisplayName(key, row);
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
type ThemeModeOption = { id: ThemeMode; label: string; short: string };
|
||||
const THEME_MODE_OPTIONS: ThemeModeOption[] = [
|
||||
{ id: "system", label: "System", short: "SYS" },
|
||||
|
||||
276
ui/src/ui/chat/session-controls.test.ts
Normal file
276
ui/src/ui/chat/session-controls.test.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AppViewState } from "../app-view-state.ts";
|
||||
import {
|
||||
createModelCatalog,
|
||||
createSessionsListResult,
|
||||
DEFAULT_CHAT_MODEL_CATALOG,
|
||||
} from "../chat-model.test-helpers.ts";
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { ModelCatalogEntry } from "../types.ts";
|
||||
import { renderChatSessionSelect } from "./session-controls.ts";
|
||||
|
||||
function createChatHeaderState(
|
||||
overrides: {
|
||||
model?: string | null;
|
||||
modelProvider?: string | null;
|
||||
models?: ModelCatalogEntry[];
|
||||
omitSessionFromList?: boolean;
|
||||
} = {},
|
||||
): { state: AppViewState; request: ReturnType<typeof vi.fn> } {
|
||||
let currentModel = overrides.model ?? null;
|
||||
let currentModelProvider = overrides.modelProvider ?? (currentModel ? "openai" : null);
|
||||
const omitSessionFromList = overrides.omitSessionFromList ?? false;
|
||||
const catalog = overrides.models ?? createModelCatalog(...DEFAULT_CHAT_MODEL_CATALOG);
|
||||
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
|
||||
if (method === "sessions.patch") {
|
||||
const nextModel = (params.model as string | null | undefined) ?? null;
|
||||
if (!nextModel) {
|
||||
currentModel = null;
|
||||
currentModelProvider = null;
|
||||
} else {
|
||||
const normalized = nextModel.trim();
|
||||
const slashIndex = normalized.indexOf("/");
|
||||
if (slashIndex > 0) {
|
||||
currentModelProvider = normalized.slice(0, slashIndex);
|
||||
currentModel = normalized.slice(slashIndex + 1);
|
||||
} else {
|
||||
currentModel = normalized;
|
||||
const matchingProviders = catalog
|
||||
.filter((entry) => entry.id === normalized)
|
||||
.map((entry) => entry.provider)
|
||||
.filter(Boolean);
|
||||
currentModelProvider =
|
||||
matchingProviders.length === 1 ? matchingProviders[0] : currentModelProvider;
|
||||
}
|
||||
}
|
||||
return { ok: true, key: "main" };
|
||||
}
|
||||
if (method === "chat.history") {
|
||||
return { messages: [], thinkingLevel: null };
|
||||
}
|
||||
if (method === "sessions.list") {
|
||||
return createSessionsListResult({
|
||||
model: currentModel,
|
||||
modelProvider: currentModelProvider,
|
||||
omitSessionFromList,
|
||||
});
|
||||
}
|
||||
if (method === "models.list") {
|
||||
return { models: catalog };
|
||||
}
|
||||
if (method === "tools.effective") {
|
||||
return {
|
||||
agentId: "main",
|
||||
profile: "coding",
|
||||
groups: [],
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const state = {
|
||||
sessionKey: "main",
|
||||
connected: true,
|
||||
sessionsHideCron: true,
|
||||
sessionsResult: createSessionsListResult({
|
||||
model: currentModel,
|
||||
modelProvider: currentModelProvider,
|
||||
omitSessionFromList,
|
||||
}),
|
||||
chatModelOverrides: {},
|
||||
chatModelCatalog: catalog,
|
||||
chatModelsLoading: false,
|
||||
client: { request } as unknown as GatewayBrowserClient,
|
||||
settings: {
|
||||
gatewayUrl: "",
|
||||
token: "",
|
||||
locale: "en",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "claw",
|
||||
themeMode: "dark",
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
chatFocusMode: false,
|
||||
chatShowThinking: false,
|
||||
},
|
||||
chatMessage: "",
|
||||
chatStream: null,
|
||||
chatStreamStartedAt: null,
|
||||
chatRunId: null,
|
||||
chatQueue: [],
|
||||
chatMessages: [],
|
||||
chatLoading: false,
|
||||
chatThinkingLevel: null,
|
||||
lastError: null,
|
||||
chatAvatarUrl: null,
|
||||
basePath: "",
|
||||
hello: null,
|
||||
agentsList: null,
|
||||
agentsPanel: "overview",
|
||||
agentsSelectedId: null,
|
||||
toolsEffectiveLoading: false,
|
||||
toolsEffectiveLoadingKey: null,
|
||||
toolsEffectiveResultKey: null,
|
||||
toolsEffectiveError: null,
|
||||
toolsEffectiveResult: null,
|
||||
applySettings(next: AppViewState["settings"]) {
|
||||
state.settings = next;
|
||||
},
|
||||
loadAssistantIdentity: vi.fn(),
|
||||
resetToolStream: vi.fn(),
|
||||
resetChatScroll: vi.fn(),
|
||||
} as unknown as AppViewState & {
|
||||
client: GatewayBrowserClient;
|
||||
settings: AppViewState["settings"];
|
||||
};
|
||||
return { state, request };
|
||||
}
|
||||
|
||||
function flushTasks() {
|
||||
return new Promise<void>((resolve) => queueMicrotask(resolve));
|
||||
}
|
||||
|
||||
describe("chat session controls", () => {
|
||||
it("patches the current session model from the chat header picker", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
} satisfies Partial<Response>),
|
||||
);
|
||||
const { state, request } = createChatHeaderState();
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(modelSelect).not.toBeNull();
|
||||
expect(modelSelect?.value).toBe("");
|
||||
|
||||
modelSelect!.value = "openai/gpt-5-mini";
|
||||
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushTasks();
|
||||
|
||||
expect(request).toHaveBeenCalledWith("sessions.patch", {
|
||||
key: "main",
|
||||
model: "openai/gpt-5-mini",
|
||||
});
|
||||
expect(request).not.toHaveBeenCalledWith("chat.history", expect.anything());
|
||||
expect(state.sessionsResult?.sessions[0]?.model).toBe("gpt-5-mini");
|
||||
expect(state.sessionsResult?.sessions[0]?.modelProvider).toBe("openai");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("reloads effective tools after a chat-header model switch for the active tools panel", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
} satisfies Partial<Response>),
|
||||
);
|
||||
const { state, request } = createChatHeaderState();
|
||||
state.agentsPanel = "tools";
|
||||
state.agentsSelectedId = "main";
|
||||
state.toolsEffectiveResultKey = "main:main";
|
||||
state.toolsEffectiveResult = {
|
||||
agentId: "main",
|
||||
profile: "coding",
|
||||
groups: [],
|
||||
};
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(modelSelect).not.toBeNull();
|
||||
|
||||
modelSelect!.value = "openai/gpt-5-mini";
|
||||
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushTasks();
|
||||
|
||||
expect(request).toHaveBeenCalledWith("tools.effective", {
|
||||
agentId: "main",
|
||||
sessionKey: "main",
|
||||
});
|
||||
expect(state.toolsEffectiveResultKey).toBe("main:main:model=openai/gpt-5-mini");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("clears the session model override back to the default model", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
} satisfies Partial<Response>),
|
||||
);
|
||||
const { state, request } = createChatHeaderState({ model: "gpt-5-mini" });
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(modelSelect).not.toBeNull();
|
||||
expect(modelSelect?.value).toBe("openai/gpt-5-mini");
|
||||
|
||||
modelSelect!.value = "";
|
||||
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushTasks();
|
||||
|
||||
expect(request).toHaveBeenCalledWith("sessions.patch", {
|
||||
key: "main",
|
||||
model: null,
|
||||
});
|
||||
expect(state.sessionsResult?.sessions[0]?.model).toBeUndefined();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("disables the chat header model picker while a run is active", () => {
|
||||
const { state } = createChatHeaderState();
|
||||
state.chatRunId = "run-123";
|
||||
state.chatStream = "Working";
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(modelSelect).not.toBeNull();
|
||||
expect(modelSelect?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the selected model visible when the active session is absent from sessions.list", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
} satisfies Partial<Response>),
|
||||
);
|
||||
const { state } = createChatHeaderState({ omitSessionFromList: true });
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(modelSelect).not.toBeNull();
|
||||
|
||||
modelSelect!.value = "openai/gpt-5-mini";
|
||||
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushTasks();
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const rerendered = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(rerendered?.value).toBe("openai/gpt-5-mini");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
623
ui/src/ui/chat/session-controls.ts
Normal file
623
ui/src/ui/chat/session-controls.ts
Normal file
@@ -0,0 +1,623 @@
|
||||
import { html } from "lit";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import type { AppViewState } from "../app-view-state.ts";
|
||||
import { createChatModelOverride } from "../chat-model-ref.ts";
|
||||
import {
|
||||
resolveChatModelOverrideValue,
|
||||
resolveChatModelSelectState,
|
||||
} from "../chat-model-select-state.ts";
|
||||
import { refreshVisibleToolsEffectiveForCurrentSession } from "../controllers/agents.ts";
|
||||
import { loadSessions } from "../controllers/sessions.ts";
|
||||
import { parseAgentSessionKey } from "../session-key.ts";
|
||||
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts";
|
||||
import {
|
||||
listThinkingLevelLabels,
|
||||
normalizeThinkLevel,
|
||||
resolveThinkingDefaultForModel,
|
||||
} from "../thinking.ts";
|
||||
import type { SessionsListResult } from "../types.ts";
|
||||
|
||||
type ChatSessionSwitchHandler = (state: AppViewState, nextSessionKey: string) => void;
|
||||
|
||||
export function renderChatSessionSelect(
|
||||
state: AppViewState,
|
||||
onSwitchSession: ChatSessionSwitchHandler = () => undefined,
|
||||
) {
|
||||
const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult);
|
||||
const modelSelect = renderChatModelSelect(state);
|
||||
const thinkingSelect = renderChatThinkingSelect(state);
|
||||
const selectedSessionLabel =
|
||||
sessionGroups.flatMap((group) => group.options).find((entry) => entry.key === state.sessionKey)
|
||||
?.label ?? state.sessionKey;
|
||||
return html`
|
||||
<div class="chat-controls__session-row">
|
||||
<label class="field chat-controls__session">
|
||||
<select
|
||||
.value=${state.sessionKey}
|
||||
title=${selectedSessionLabel}
|
||||
?disabled=${!state.connected || sessionGroups.length === 0}
|
||||
@change=${(e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value;
|
||||
if (state.sessionKey === next) {
|
||||
return;
|
||||
}
|
||||
onSwitchSession(state, next);
|
||||
}}
|
||||
>
|
||||
${repeat(
|
||||
sessionGroups,
|
||||
(group) => group.id,
|
||||
(group) =>
|
||||
html`<optgroup label=${group.label}>
|
||||
${repeat(
|
||||
group.options,
|
||||
(entry) => entry.key,
|
||||
(entry) =>
|
||||
html`<option
|
||||
value=${entry.key}
|
||||
title=${entry.title}
|
||||
?selected=${entry.key === state.sessionKey}
|
||||
>
|
||||
${entry.label}
|
||||
</option>`,
|
||||
)}
|
||||
</optgroup>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
${modelSelect} ${thinkingSelect}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function refreshSessionOptions(state: AppViewState) {
|
||||
await loadSessions(state as unknown as Parameters<typeof loadSessions>[0], {
|
||||
activeMinutes: 0,
|
||||
limit: 0,
|
||||
includeGlobal: true,
|
||||
includeUnknown: true,
|
||||
});
|
||||
}
|
||||
|
||||
function renderChatModelSelect(state: AppViewState) {
|
||||
const { currentOverride, defaultLabel, options } = resolveChatModelSelectState(state);
|
||||
const busy =
|
||||
state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null;
|
||||
const disabled =
|
||||
!state.connected || busy || (state.chatModelsLoading && options.length === 0) || !state.client;
|
||||
const selectedLabel =
|
||||
currentOverride === ""
|
||||
? defaultLabel
|
||||
: (options.find((entry) => entry.value === currentOverride)?.label ?? currentOverride);
|
||||
return html`
|
||||
<label class="field chat-controls__session chat-controls__model">
|
||||
<select
|
||||
data-chat-model-select="true"
|
||||
aria-label="Chat model"
|
||||
title=${selectedLabel}
|
||||
?disabled=${disabled}
|
||||
@change=${async (e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value.trim();
|
||||
await switchChatModel(state, next);
|
||||
}}
|
||||
>
|
||||
<option value="" ?selected=${currentOverride === ""}>${defaultLabel}</option>
|
||||
${repeat(
|
||||
options,
|
||||
(entry) => entry.value,
|
||||
(entry) =>
|
||||
html`<option value=${entry.value} ?selected=${entry.value === currentOverride}>
|
||||
${entry.label}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
type ChatThinkingSelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type ChatThinkingSelectState = {
|
||||
currentOverride: string;
|
||||
defaultLabel: string;
|
||||
options: ChatThinkingSelectOption[];
|
||||
};
|
||||
|
||||
function resolveThinkingTargetModel(state: AppViewState): {
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
} {
|
||||
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey);
|
||||
return {
|
||||
provider: activeRow?.modelProvider ?? state.sessionsResult?.defaults?.modelProvider ?? null,
|
||||
model: activeRow?.model ?? state.sessionsResult?.defaults?.model ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildThinkingOptions(
|
||||
provider: string | null,
|
||||
model: string | null,
|
||||
currentOverride: string,
|
||||
): ChatThinkingSelectOption[] {
|
||||
const seen = new Set<string>();
|
||||
const options: ChatThinkingSelectOption[] = [];
|
||||
|
||||
const addOption = (value: string, label?: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
const key = normalizeLowercaseStringOrEmpty(trimmed);
|
||||
if (seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
options.push({
|
||||
value: trimmed,
|
||||
label:
|
||||
label ??
|
||||
trimmed
|
||||
.split(/[-_]/g)
|
||||
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
|
||||
.join(" "),
|
||||
});
|
||||
};
|
||||
|
||||
for (const label of listThinkingLevelLabels(provider)) {
|
||||
const normalized = normalizeThinkLevel(label) ?? normalizeLowercaseStringOrEmpty(label);
|
||||
addOption(normalized);
|
||||
}
|
||||
if (currentOverride) {
|
||||
addOption(currentOverride);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function resolveChatThinkingSelectState(state: AppViewState): ChatThinkingSelectState {
|
||||
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey);
|
||||
const persisted = activeRow?.thinkingLevel;
|
||||
const currentOverride =
|
||||
typeof persisted === "string" && persisted.trim()
|
||||
? (normalizeThinkLevel(persisted) ?? persisted.trim())
|
||||
: "";
|
||||
const { provider, model } = resolveThinkingTargetModel(state);
|
||||
const defaultLevel =
|
||||
provider && model
|
||||
? resolveThinkingDefaultForModel({
|
||||
provider,
|
||||
model,
|
||||
catalog: state.chatModelCatalog ?? [],
|
||||
})
|
||||
: "off";
|
||||
return {
|
||||
currentOverride,
|
||||
defaultLabel: `Default (${defaultLevel})`,
|
||||
options: buildThinkingOptions(provider, model, currentOverride),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderChatThinkingSelect(state: AppViewState) {
|
||||
const { currentOverride, defaultLabel, options } = resolveChatThinkingSelectState(state);
|
||||
const busy =
|
||||
state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null;
|
||||
const disabled = !state.connected || busy || !state.client;
|
||||
const selectedLabel =
|
||||
currentOverride === ""
|
||||
? defaultLabel
|
||||
: (options.find((entry) => entry.value === currentOverride)?.label ?? currentOverride);
|
||||
return html`
|
||||
<label class="field chat-controls__session chat-controls__thinking-select">
|
||||
<select
|
||||
data-chat-thinking-select="true"
|
||||
aria-label="Chat thinking level"
|
||||
title=${selectedLabel}
|
||||
?disabled=${disabled}
|
||||
@change=${async (e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value.trim();
|
||||
await switchChatThinkingLevel(state, next);
|
||||
}}
|
||||
>
|
||||
<option value="" ?selected=${currentOverride === ""}>${defaultLabel}</option>
|
||||
${repeat(
|
||||
options,
|
||||
(entry) => entry.value,
|
||||
(entry) =>
|
||||
html`<option value=${entry.value} ?selected=${entry.value === currentOverride}>
|
||||
${entry.label}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
async function switchChatModel(state: AppViewState, nextModel: string) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
const currentOverride = resolveChatModelOverrideValue(state);
|
||||
if (currentOverride === nextModel) {
|
||||
return;
|
||||
}
|
||||
const targetSessionKey = state.sessionKey;
|
||||
const prevOverride = state.chatModelOverrides[targetSessionKey];
|
||||
state.lastError = null;
|
||||
// Write the override cache immediately so the picker stays in sync during the RPC round-trip.
|
||||
state.chatModelOverrides = {
|
||||
...state.chatModelOverrides,
|
||||
[targetSessionKey]: createChatModelOverride(nextModel),
|
||||
};
|
||||
try {
|
||||
await state.client.request("sessions.patch", {
|
||||
key: targetSessionKey,
|
||||
model: nextModel || null,
|
||||
});
|
||||
void refreshVisibleToolsEffectiveForCurrentSession(state);
|
||||
await refreshSessionOptions(state);
|
||||
} catch (err) {
|
||||
// Roll back so the picker reflects the actual server model.
|
||||
state.chatModelOverrides = { ...state.chatModelOverrides, [targetSessionKey]: prevOverride };
|
||||
state.lastError = `Failed to set model: ${String(err)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function patchSessionThinkingLevel(
|
||||
state: AppViewState,
|
||||
sessionKey: string,
|
||||
thinkingLevel: string | undefined,
|
||||
) {
|
||||
const current = state.sessionsResult;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
state.sessionsResult = {
|
||||
...current,
|
||||
sessions: current.sessions.map((row) =>
|
||||
row.key === sessionKey
|
||||
? {
|
||||
...row,
|
||||
thinkingLevel,
|
||||
}
|
||||
: row,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function switchChatThinkingLevel(state: AppViewState, nextThinkingLevel: string) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
const targetSessionKey = state.sessionKey;
|
||||
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === targetSessionKey);
|
||||
const previousThinkingLevel = activeRow?.thinkingLevel;
|
||||
const normalizedNext =
|
||||
(normalizeThinkLevel(nextThinkingLevel) ?? nextThinkingLevel.trim()) || undefined;
|
||||
const normalizedPrev =
|
||||
typeof previousThinkingLevel === "string" && previousThinkingLevel.trim()
|
||||
? (normalizeThinkLevel(previousThinkingLevel) ?? previousThinkingLevel.trim())
|
||||
: undefined;
|
||||
if ((normalizedPrev ?? "") === (normalizedNext ?? "")) {
|
||||
return;
|
||||
}
|
||||
state.lastError = null;
|
||||
patchSessionThinkingLevel(state, targetSessionKey, normalizedNext);
|
||||
state.chatThinkingLevel = normalizedNext ?? null;
|
||||
try {
|
||||
await state.client.request("sessions.patch", {
|
||||
key: targetSessionKey,
|
||||
thinkingLevel: normalizedNext ?? null,
|
||||
});
|
||||
await refreshSessionOptions(state);
|
||||
} catch (err) {
|
||||
patchSessionThinkingLevel(state, targetSessionKey, previousThinkingLevel);
|
||||
state.chatThinkingLevel = normalizedPrev ?? null;
|
||||
state.lastError = `Failed to set thinking level: ${String(err)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/* Channel display labels. */
|
||||
const CHANNEL_LABELS: Record<string, string> = {
|
||||
bluebubbles: "iMessage",
|
||||
telegram: "Telegram",
|
||||
discord: "Discord",
|
||||
signal: "Signal",
|
||||
slack: "Slack",
|
||||
whatsapp: "WhatsApp",
|
||||
matrix: "Matrix",
|
||||
email: "Email",
|
||||
sms: "SMS",
|
||||
};
|
||||
|
||||
const KNOWN_CHANNEL_KEYS = Object.keys(CHANNEL_LABELS);
|
||||
|
||||
/** Parsed type / context extracted from a session key. */
|
||||
export type SessionKeyInfo = {
|
||||
/** Prefix for typed sessions (Subagent:/Cron:). Empty for others. */
|
||||
prefix: string;
|
||||
/** Human-readable fallback when no label / displayName is available. */
|
||||
fallbackName: string;
|
||||
};
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a session key to extract type information and a human-readable
|
||||
* fallback display name. Exported for testing.
|
||||
*/
|
||||
export function parseSessionKey(key: string): SessionKeyInfo {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(key);
|
||||
|
||||
// Main session.
|
||||
if (key === "main" || key === "agent:main:main") {
|
||||
return { prefix: "", fallbackName: "Main Session" };
|
||||
}
|
||||
|
||||
// Subagent.
|
||||
if (key.includes(":subagent:")) {
|
||||
return { prefix: "Subagent:", fallbackName: "Subagent:" };
|
||||
}
|
||||
|
||||
// Cron job.
|
||||
if (normalized.startsWith("cron:") || key.includes(":cron:")) {
|
||||
return { prefix: "Cron:", fallbackName: "Cron Job:" };
|
||||
}
|
||||
|
||||
// Direct chat: agent:<x>:<channel>:direct:<id>.
|
||||
const directMatch = key.match(/^agent:[^:]+:([^:]+):direct:(.+)$/);
|
||||
if (directMatch) {
|
||||
const channel = directMatch[1];
|
||||
const identifier = directMatch[2];
|
||||
const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel);
|
||||
return { prefix: "", fallbackName: `${channelLabel} · ${identifier}` };
|
||||
}
|
||||
|
||||
// Group chat: agent:<x>:<channel>:group:<id>.
|
||||
const groupMatch = key.match(/^agent:[^:]+:([^:]+):group:(.+)$/);
|
||||
if (groupMatch) {
|
||||
const channel = groupMatch[1];
|
||||
const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel);
|
||||
return { prefix: "", fallbackName: `${channelLabel} Group` };
|
||||
}
|
||||
|
||||
// Channel-prefixed legacy keys, for example "bluebubbles:g-...".
|
||||
for (const ch of KNOWN_CHANNEL_KEYS) {
|
||||
if (key === ch || key.startsWith(`${ch}:`)) {
|
||||
return { prefix: "", fallbackName: `${CHANNEL_LABELS[ch]} Session` };
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown: return key as-is.
|
||||
return { prefix: "", fallbackName: key };
|
||||
}
|
||||
|
||||
export function resolveSessionDisplayName(
|
||||
key: string,
|
||||
row?: SessionsListResult["sessions"][number],
|
||||
): string {
|
||||
const label = normalizeOptionalString(row?.label) ?? "";
|
||||
const displayName = normalizeOptionalString(row?.displayName) ?? "";
|
||||
const { prefix, fallbackName } = parseSessionKey(key);
|
||||
|
||||
const applyTypedPrefix = (name: string): string => {
|
||||
if (!prefix) {
|
||||
return name;
|
||||
}
|
||||
const prefixPattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}\\s*`, "i");
|
||||
return prefixPattern.test(name) ? name : `${prefix} ${name}`;
|
||||
};
|
||||
|
||||
if (label && label !== key) {
|
||||
return applyTypedPrefix(label);
|
||||
}
|
||||
if (displayName && displayName !== key) {
|
||||
return applyTypedPrefix(displayName);
|
||||
}
|
||||
return fallbackName;
|
||||
}
|
||||
|
||||
export function isCronSessionKey(key: string): boolean {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(key);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (normalized.startsWith("cron:")) {
|
||||
return true;
|
||||
}
|
||||
if (!normalized.startsWith("agent:")) {
|
||||
return false;
|
||||
}
|
||||
const parts = normalized.split(":").filter(Boolean);
|
||||
if (parts.length < 3) {
|
||||
return false;
|
||||
}
|
||||
const rest = parts.slice(2).join(":");
|
||||
return rest.startsWith("cron:");
|
||||
}
|
||||
|
||||
type SessionOptionEntry = {
|
||||
key: string;
|
||||
label: string;
|
||||
scopeLabel: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type SessionOptionGroup = {
|
||||
id: string;
|
||||
label: string;
|
||||
options: SessionOptionEntry[];
|
||||
};
|
||||
|
||||
export function resolveSessionOptionGroups(
|
||||
state: AppViewState,
|
||||
sessionKey: string,
|
||||
sessions: SessionsListResult | null,
|
||||
): SessionOptionGroup[] {
|
||||
const rows = sessions?.sessions ?? [];
|
||||
const hideCron = state.sessionsHideCron ?? true;
|
||||
const byKey = new Map<string, SessionsListResult["sessions"][number]>();
|
||||
for (const row of rows) {
|
||||
byKey.set(row.key, row);
|
||||
}
|
||||
|
||||
const seenKeys = new Set<string>();
|
||||
const groups = new Map<string, SessionOptionGroup>();
|
||||
const ensureGroup = (groupId: string, label: string): SessionOptionGroup => {
|
||||
const existing = groups.get(groupId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const created: SessionOptionGroup = {
|
||||
id: groupId,
|
||||
label,
|
||||
options: [],
|
||||
};
|
||||
groups.set(groupId, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
const addOption = (key: string) => {
|
||||
if (!key || seenKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
seenKeys.add(key);
|
||||
const row = byKey.get(key);
|
||||
const parsed = parseAgentSessionKey(key);
|
||||
const group = parsed
|
||||
? ensureGroup(
|
||||
`agent:${normalizeLowercaseStringOrEmpty(parsed.agentId)}`,
|
||||
resolveAgentGroupLabel(state, parsed.agentId),
|
||||
)
|
||||
: ensureGroup("other", "Other Sessions");
|
||||
const scopeLabel = normalizeOptionalString(parsed?.rest) ?? key;
|
||||
const label = resolveSessionScopedOptionLabel(key, row, parsed?.rest);
|
||||
group.options.push({
|
||||
key,
|
||||
label,
|
||||
scopeLabel,
|
||||
title: key,
|
||||
});
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.key !== sessionKey && (row.kind === "global" || row.kind === "unknown")) {
|
||||
continue;
|
||||
}
|
||||
if (hideCron && row.key !== sessionKey && isCronSessionKey(row.key)) {
|
||||
continue;
|
||||
}
|
||||
addOption(row.key);
|
||||
}
|
||||
addOption(sessionKey);
|
||||
|
||||
for (const group of groups.values()) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const option of group.options) {
|
||||
counts.set(option.label, (counts.get(option.label) ?? 0) + 1);
|
||||
}
|
||||
for (const option of group.options) {
|
||||
if ((counts.get(option.label) ?? 0) > 1 && option.scopeLabel !== option.label) {
|
||||
option.label = `${option.label} · ${option.scopeLabel}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allOptions = Array.from(groups.values()).flatMap((group) =>
|
||||
group.options.map((option) => ({ groupLabel: group.label, option })),
|
||||
);
|
||||
const labels = new Map(allOptions.map(({ option }) => [option, option.label]));
|
||||
const countAssignedLabels = () => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const { option } of allOptions) {
|
||||
const label = labels.get(option) ?? option.label;
|
||||
counts.set(label, (counts.get(label) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
};
|
||||
const labelIncludesScopeLabel = (label: string, scopeLabel: string) => {
|
||||
const trimmedScope = scopeLabel.trim();
|
||||
if (!trimmedScope) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
label === trimmedScope ||
|
||||
label.endsWith(` · ${trimmedScope}`) ||
|
||||
label.endsWith(` / ${trimmedScope}`)
|
||||
);
|
||||
};
|
||||
|
||||
const globalCounts = countAssignedLabels();
|
||||
for (const { groupLabel, option } of allOptions) {
|
||||
const currentLabel = labels.get(option) ?? option.label;
|
||||
if ((globalCounts.get(currentLabel) ?? 0) <= 1) {
|
||||
continue;
|
||||
}
|
||||
const scopedPrefix = `${groupLabel} / `;
|
||||
if (currentLabel.startsWith(scopedPrefix)) {
|
||||
continue;
|
||||
}
|
||||
// Keep the agent visible once the native select collapses to a single chosen label.
|
||||
labels.set(option, `${groupLabel} / ${currentLabel}`);
|
||||
}
|
||||
|
||||
const scopedCounts = countAssignedLabels();
|
||||
for (const { option } of allOptions) {
|
||||
const currentLabel = labels.get(option) ?? option.label;
|
||||
if ((scopedCounts.get(currentLabel) ?? 0) <= 1) {
|
||||
continue;
|
||||
}
|
||||
if (labelIncludesScopeLabel(currentLabel, option.scopeLabel)) {
|
||||
continue;
|
||||
}
|
||||
labels.set(option, `${currentLabel} · ${option.scopeLabel}`);
|
||||
}
|
||||
|
||||
const finalCounts = countAssignedLabels();
|
||||
for (const { option } of allOptions) {
|
||||
const currentLabel = labels.get(option) ?? option.label;
|
||||
if ((finalCounts.get(currentLabel) ?? 0) <= 1) {
|
||||
continue;
|
||||
}
|
||||
// Fall back to the full key only when every friendlier disambiguator still collides.
|
||||
labels.set(option, `${currentLabel} · ${option.key}`);
|
||||
}
|
||||
|
||||
for (const { option } of allOptions) {
|
||||
option.label = labels.get(option) ?? option.label;
|
||||
}
|
||||
|
||||
return Array.from(groups.values());
|
||||
}
|
||||
|
||||
function resolveAgentGroupLabel(state: AppViewState, agentIdRaw: string): string {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(agentIdRaw);
|
||||
const agent = (state.agentsList?.agents ?? []).find(
|
||||
(entry) => normalizeLowercaseStringOrEmpty(entry.id) === normalized,
|
||||
);
|
||||
const name =
|
||||
normalizeOptionalString(agent?.identity?.name) ?? normalizeOptionalString(agent?.name) ?? "";
|
||||
return name && name !== agentIdRaw ? `${name} (${agentIdRaw})` : agentIdRaw;
|
||||
}
|
||||
|
||||
function resolveSessionScopedOptionLabel(
|
||||
key: string,
|
||||
row?: SessionsListResult["sessions"][number],
|
||||
rest?: string,
|
||||
) {
|
||||
const base = normalizeOptionalString(rest) ?? key;
|
||||
if (!row) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const label = normalizeOptionalString(row.label) ?? "";
|
||||
const displayName = normalizeOptionalString(row.displayName) ?? "";
|
||||
if ((label && label !== key) || (displayName && displayName !== key)) {
|
||||
return resolveSessionDisplayName(key, row);
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
@@ -96,8 +96,11 @@ function stubWindowGlobals(storage?: ReturnType<typeof createStorageMock>) {
|
||||
vi.stubGlobal("window", {
|
||||
location: { href: "http://127.0.0.1:18789/" },
|
||||
localStorage: storage,
|
||||
setTimeout: (handler: (...args: unknown[]) => void, timeout?: number, ...args: unknown[]) =>
|
||||
globalThis.setTimeout(() => handler(...args), timeout),
|
||||
setTimeout: (handler: (...args: unknown[]) => void, timeout?: number, ...args: unknown[]) => {
|
||||
// Keep connect debounce behavior testable without paying real 750ms waits per handshake.
|
||||
const effectiveTimeout = timeout === 750 ? 0 : timeout;
|
||||
return globalThis.setTimeout(() => handler(...args), effectiveTimeout);
|
||||
},
|
||||
clearTimeout: (timeoutId: number | undefined) => globalThis.clearTimeout(timeoutId),
|
||||
});
|
||||
}
|
||||
@@ -127,10 +130,19 @@ async function continueConnect(ws: MockWebSocket, nonce = "nonce-1") {
|
||||
event: "connect.challenge",
|
||||
payload: { nonce },
|
||||
});
|
||||
await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0));
|
||||
if (vi.isFakeTimers()) {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
} else {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
expect(ws.sent.length).toBeGreaterThan(0);
|
||||
return { ws, connectFrame: parseLatestConnectFrame(ws) };
|
||||
}
|
||||
|
||||
async function expectSocketClosed(ws: MockWebSocket) {
|
||||
await vi.waitFor(() => expect(ws.readyState).toBe(3), { interval: 1, timeout: 50 });
|
||||
}
|
||||
|
||||
async function startConnect(client: InstanceType<typeof GatewayBrowserClient>, nonce = "nonce-1") {
|
||||
client.start();
|
||||
return await continueConnect(getLatestWebSocket(), nonce);
|
||||
@@ -163,7 +175,7 @@ async function startRetriedDeviceTokenConnect(params: {
|
||||
expect(firstConnect.params?.auth?.deviceToken).toBeUndefined();
|
||||
|
||||
emitRetryableTokenMismatch(firstWs, firstConnect.id);
|
||||
await vi.waitFor(() => expect(firstWs.readyState).toBe(3));
|
||||
await expectSocketClosed(firstWs);
|
||||
firstWs.emitClose(4008, "connect failed");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
@@ -331,7 +343,7 @@ describe("GatewayBrowserClient", () => {
|
||||
details: { code: "AUTH_TOKEN_MISMATCH" },
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(secondWs.readyState).toBe(3));
|
||||
await expectSocketClosed(secondWs);
|
||||
secondWs.emitClose(4008, "connect failed");
|
||||
expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator" })?.token).toBe(
|
||||
"stored-device-token",
|
||||
@@ -374,7 +386,7 @@ describe("GatewayBrowserClient", () => {
|
||||
details: { code: "AUTH_TOKEN_MISMATCH" },
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(ws1.readyState).toBe(3));
|
||||
await expectSocketClosed(ws1);
|
||||
ws1.emitClose(4008, "connect failed");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
@@ -444,7 +456,7 @@ describe("GatewayBrowserClient", () => {
|
||||
details: { code: "AUTH_TOKEN_MISSING" },
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(ws1.readyState).toBe(3));
|
||||
await expectSocketClosed(ws1);
|
||||
ws1.emitClose(4008, "connect failed");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import "../test-helpers/load-styles.ts";
|
||||
import { mountApp as mountTestApp, registerAppMountHooks } from "./test-helpers/app-mount.ts";
|
||||
|
||||
registerAppMountHooks();
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mountApp(pathname: string) {
|
||||
return mountTestApp(pathname);
|
||||
}
|
||||
@@ -158,7 +162,7 @@ describe("control UI routing", () => {
|
||||
expect(app.querySelector(".dreams__lobster")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the refreshed top navigation shell", async () => {
|
||||
it("renders the refreshed desktop navigation shell and collapsed state", async () => {
|
||||
const app = mountApp("/chat");
|
||||
await app.updateComplete;
|
||||
|
||||
@@ -166,11 +170,6 @@ describe("control UI routing", () => {
|
||||
expect(app.querySelector(".topnav-shell__content")).not.toBeNull();
|
||||
expect(app.querySelector(".topnav-shell__actions")).not.toBeNull();
|
||||
expect(app.querySelector(".topnav-shell .brand-title")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the refreshed sidebar shell structure", async () => {
|
||||
const app = mountApp("/chat");
|
||||
await app.updateComplete;
|
||||
|
||||
expect(app.querySelector(".sidebar-shell")).not.toBeNull();
|
||||
expect(app.querySelector(".sidebar-shell__header")).not.toBeNull();
|
||||
@@ -179,11 +178,6 @@ describe("control UI routing", () => {
|
||||
expect(app.querySelector(".sidebar-brand")).not.toBeNull();
|
||||
expect(app.querySelector(".sidebar-brand__logo")).not.toBeNull();
|
||||
expect(app.querySelector(".sidebar-brand__copy")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not render a desktop sidebar resizer or inject a custom nav width", async () => {
|
||||
const app = mountApp("/chat");
|
||||
await app.updateComplete;
|
||||
|
||||
app.applySettings({ ...app.settings, navWidth: 360 });
|
||||
await app.updateComplete;
|
||||
@@ -191,36 +185,15 @@ describe("control UI routing", () => {
|
||||
expect(app.querySelector(".sidebar-resizer")).toBeNull();
|
||||
const shell = app.querySelector<HTMLElement>(".shell");
|
||||
expect(shell?.style.getPropertyValue("--shell-nav-width")).toBe("");
|
||||
});
|
||||
|
||||
it("hides section labels in collapsed mode", async () => {
|
||||
const app = mountApp("/chat");
|
||||
await app.updateComplete;
|
||||
|
||||
app.applySettings({ ...app.settings, navCollapsed: true });
|
||||
await app.updateComplete;
|
||||
|
||||
expect(app.querySelector(".nav-section__label")).toBeNull();
|
||||
expect(app.querySelector(".sidebar-brand__logo")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps footer utilities available in collapsed mode", async () => {
|
||||
const app = mountApp("/chat");
|
||||
await app.updateComplete;
|
||||
|
||||
app.applySettings({ ...app.settings, navCollapsed: true });
|
||||
await app.updateComplete;
|
||||
|
||||
expect(app.querySelector(".sidebar-shell__footer")).not.toBeNull();
|
||||
expect(app.querySelector(".sidebar-utility-link")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the collapsed desktop rail compact", async () => {
|
||||
const app = mountApp("/chat");
|
||||
await app.updateComplete;
|
||||
|
||||
app.applySettings({ ...app.settings, navCollapsed: true });
|
||||
await app.updateComplete;
|
||||
|
||||
const item = app.querySelector<HTMLElement>(".sidebar .nav-item");
|
||||
const header = app.querySelector<HTMLElement>(".sidebar-shell__header");
|
||||
@@ -375,6 +348,10 @@ describe("control UI routing", () => {
|
||||
});
|
||||
|
||||
it("auto-scrolls chat history to the latest message", async () => {
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
||||
queueMicrotask(() => callback(performance.now()));
|
||||
return 1;
|
||||
});
|
||||
const app = mountApp("/chat");
|
||||
await app.updateComplete;
|
||||
|
||||
@@ -407,9 +384,9 @@ describe("control UI routing", () => {
|
||||
scrollTop = Math.max(0, Math.min(top, 2400 - 180));
|
||||
}) as typeof initialContainer.scrollTo;
|
||||
|
||||
app.chatMessages = Array.from({ length: 60 }, (_, index) => ({
|
||||
app.chatMessages = Array.from({ length: 3 }, (_, index) => ({
|
||||
role: "assistant",
|
||||
content: `Line ${index} - ${"x".repeat(200)}`,
|
||||
content: `Line ${index}`,
|
||||
timestamp: Date.now() + index,
|
||||
}));
|
||||
|
||||
@@ -451,8 +428,8 @@ describe("control UI routing", () => {
|
||||
...app.chatMessages,
|
||||
{
|
||||
role: "assistant",
|
||||
content: `Line 60 - ${"x".repeat(200)}`,
|
||||
timestamp: Date.now() + 60,
|
||||
content: "Line 3",
|
||||
timestamp: Date.now() + 3,
|
||||
},
|
||||
];
|
||||
await app.updateComplete;
|
||||
|
||||
@@ -3,17 +3,8 @@
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getSafeLocalStorage } from "../../local-storage.ts";
|
||||
import { renderChatSessionSelect } from "../app-render.helpers.ts";
|
||||
import type { AppViewState } from "../app-view-state.ts";
|
||||
import {
|
||||
createModelCatalog,
|
||||
createSessionsListResult,
|
||||
DEFAULT_CHAT_MODEL_CATALOG,
|
||||
} from "../chat-model.test-helpers.ts";
|
||||
import { resetAssistantAttachmentAvailabilityCacheForTest } from "../chat/grouped-render.ts";
|
||||
import { normalizeMessage } from "../chat/message-normalizer.ts";
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { ModelCatalogEntry } from "../types.ts";
|
||||
import type { SessionsListResult } from "../types.ts";
|
||||
import { renderChat, type ChatProps } from "./chat.ts";
|
||||
|
||||
@@ -27,125 +18,6 @@ function createSessions(): SessionsListResult {
|
||||
};
|
||||
}
|
||||
|
||||
function createChatHeaderState(
|
||||
overrides: {
|
||||
model?: string | null;
|
||||
modelProvider?: string | null;
|
||||
models?: ModelCatalogEntry[];
|
||||
omitSessionFromList?: boolean;
|
||||
} = {},
|
||||
): { state: AppViewState; request: ReturnType<typeof vi.fn> } {
|
||||
let currentModel = overrides.model ?? null;
|
||||
let currentModelProvider = overrides.modelProvider ?? (currentModel ? "openai" : null);
|
||||
const omitSessionFromList = overrides.omitSessionFromList ?? false;
|
||||
const catalog = overrides.models ?? createModelCatalog(...DEFAULT_CHAT_MODEL_CATALOG);
|
||||
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
|
||||
if (method === "sessions.patch") {
|
||||
const nextModel = (params.model as string | null | undefined) ?? null;
|
||||
if (!nextModel) {
|
||||
currentModel = null;
|
||||
currentModelProvider = null;
|
||||
} else {
|
||||
const normalized = nextModel.trim();
|
||||
const slashIndex = normalized.indexOf("/");
|
||||
if (slashIndex > 0) {
|
||||
currentModelProvider = normalized.slice(0, slashIndex);
|
||||
currentModel = normalized.slice(slashIndex + 1);
|
||||
} else {
|
||||
currentModel = normalized;
|
||||
const matchingProviders = catalog
|
||||
.filter((entry) => entry.id === normalized)
|
||||
.map((entry) => entry.provider)
|
||||
.filter(Boolean);
|
||||
currentModelProvider =
|
||||
matchingProviders.length === 1 ? matchingProviders[0] : currentModelProvider;
|
||||
}
|
||||
}
|
||||
return { ok: true, key: "main" };
|
||||
}
|
||||
if (method === "chat.history") {
|
||||
return { messages: [], thinkingLevel: null };
|
||||
}
|
||||
if (method === "sessions.list") {
|
||||
return createSessionsListResult({
|
||||
model: currentModel,
|
||||
modelProvider: currentModelProvider,
|
||||
omitSessionFromList,
|
||||
});
|
||||
}
|
||||
if (method === "models.list") {
|
||||
return { models: catalog };
|
||||
}
|
||||
if (method === "tools.effective") {
|
||||
return {
|
||||
agentId: "main",
|
||||
profile: "coding",
|
||||
groups: [],
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const state = {
|
||||
sessionKey: "main",
|
||||
connected: true,
|
||||
sessionsHideCron: true,
|
||||
sessionsResult: createSessionsListResult({
|
||||
model: currentModel,
|
||||
modelProvider: currentModelProvider,
|
||||
omitSessionFromList,
|
||||
}),
|
||||
chatModelOverrides: {},
|
||||
chatModelCatalog: catalog,
|
||||
chatModelsLoading: false,
|
||||
client: { request } as unknown as GatewayBrowserClient,
|
||||
settings: {
|
||||
gatewayUrl: "",
|
||||
token: "",
|
||||
locale: "en",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "claw",
|
||||
themeMode: "dark",
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
chatFocusMode: false,
|
||||
chatShowThinking: false,
|
||||
},
|
||||
chatMessage: "",
|
||||
chatStream: null,
|
||||
chatStreamStartedAt: null,
|
||||
chatRunId: null,
|
||||
chatQueue: [],
|
||||
chatMessages: [],
|
||||
chatLoading: false,
|
||||
chatThinkingLevel: null,
|
||||
lastError: null,
|
||||
chatAvatarUrl: null,
|
||||
basePath: "",
|
||||
hello: null,
|
||||
agentsList: null,
|
||||
agentsPanel: "overview",
|
||||
agentsSelectedId: null,
|
||||
toolsEffectiveLoading: false,
|
||||
toolsEffectiveLoadingKey: null,
|
||||
toolsEffectiveResultKey: null,
|
||||
toolsEffectiveError: null,
|
||||
toolsEffectiveResult: null,
|
||||
applySettings(next: AppViewState["settings"]) {
|
||||
state.settings = next;
|
||||
},
|
||||
loadAssistantIdentity: vi.fn(),
|
||||
resetToolStream: vi.fn(),
|
||||
resetChatScroll: vi.fn(),
|
||||
} as unknown as AppViewState & {
|
||||
client: GatewayBrowserClient;
|
||||
settings: AppViewState["settings"];
|
||||
};
|
||||
return { state, request };
|
||||
}
|
||||
|
||||
function flushTasks() {
|
||||
return new Promise<void>((resolve) => queueMicrotask(resolve));
|
||||
}
|
||||
@@ -808,144 +680,6 @@ describe("chat view", () => {
|
||||
expect(confirm?.classList.contains("chat-delete-confirm--right")).toBe(true);
|
||||
});
|
||||
|
||||
it("patches the current session model from the chat header picker", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
} satisfies Partial<Response>),
|
||||
);
|
||||
const { state, request } = createChatHeaderState();
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(modelSelect).not.toBeNull();
|
||||
expect(modelSelect?.value).toBe("");
|
||||
|
||||
modelSelect!.value = "openai/gpt-5-mini";
|
||||
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushTasks();
|
||||
|
||||
expect(request).toHaveBeenCalledWith("sessions.patch", {
|
||||
key: "main",
|
||||
model: "openai/gpt-5-mini",
|
||||
});
|
||||
expect(request).not.toHaveBeenCalledWith("chat.history", expect.anything());
|
||||
expect(state.sessionsResult?.sessions[0]?.model).toBe("gpt-5-mini");
|
||||
expect(state.sessionsResult?.sessions[0]?.modelProvider).toBe("openai");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("reloads effective tools after a chat-header model switch for the active tools panel", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
} satisfies Partial<Response>),
|
||||
);
|
||||
const { state, request } = createChatHeaderState();
|
||||
state.agentsPanel = "tools";
|
||||
state.agentsSelectedId = "main";
|
||||
state.toolsEffectiveResultKey = "main:main";
|
||||
state.toolsEffectiveResult = {
|
||||
agentId: "main",
|
||||
profile: "coding",
|
||||
groups: [],
|
||||
};
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(modelSelect).not.toBeNull();
|
||||
|
||||
modelSelect!.value = "openai/gpt-5-mini";
|
||||
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushTasks();
|
||||
|
||||
expect(request).toHaveBeenCalledWith("tools.effective", {
|
||||
agentId: "main",
|
||||
sessionKey: "main",
|
||||
});
|
||||
expect(state.toolsEffectiveResultKey).toBe("main:main:model=openai/gpt-5-mini");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("clears the session model override back to the default model", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
} satisfies Partial<Response>),
|
||||
);
|
||||
const { state, request } = createChatHeaderState({ model: "gpt-5-mini" });
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(modelSelect).not.toBeNull();
|
||||
expect(modelSelect?.value).toBe("openai/gpt-5-mini");
|
||||
|
||||
modelSelect!.value = "";
|
||||
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushTasks();
|
||||
|
||||
expect(request).toHaveBeenCalledWith("sessions.patch", {
|
||||
key: "main",
|
||||
model: null,
|
||||
});
|
||||
expect(state.sessionsResult?.sessions[0]?.model).toBeUndefined();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("disables the chat header model picker while a run is active", () => {
|
||||
const { state } = createChatHeaderState();
|
||||
state.chatRunId = "run-123";
|
||||
state.chatStream = "Working";
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(modelSelect).not.toBeNull();
|
||||
expect(modelSelect?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the selected model visible when the active session is absent from sessions.list", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
} satisfies Partial<Response>),
|
||||
);
|
||||
const { state } = createChatHeaderState({ omitSessionFromList: true });
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(modelSelect).not.toBeNull();
|
||||
|
||||
modelSelect!.value = "openai/gpt-5-mini";
|
||||
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushTasks();
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const rerendered = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(rerendered?.value).toBe("openai/gpt-5-mini");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps tool cards collapsed by default and expands them inline on demand", async () => {
|
||||
const container = document.createElement("div");
|
||||
const props = createProps({
|
||||
|
||||
Reference in New Issue
Block a user