From 2c926c12e3d4894e478a23c1a7a30c81df567620 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 16 Mar 2026 13:18:02 -0500 Subject: [PATCH 1/7] =?UTF-8?q?fix:=20Phase=201=20quick=20wins=20=E2=80=94?= =?UTF-8?q?=20bug=20fixes,=20security=20hardening,=20and=20performance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix loadStoredEnvKeys divergent provider lists: add telegram_bot and custom-openai to wizard.ts (the canonical copy used by CLI), remove dead duplicate from onboarding.ts - Security: add SAFE_COMMAND_PREFIXES allowlist to resolveConfigValue to prevent arbitrary RCE via settings.json shell commands - Security: add TOFU (Trust On First Use) model for project-local extensions — skip untrusted .pi/extensions/ with stderr warning - Performance: debounce sql.js MemoryStorage persistence (500ms window) so rapid mutations coalesce into a single db.export()+writeFileSync - Fix double lstatSync call in tool-bootstrap.ts isRegularFile - Add 26 new tests covering all changes --- .../src/core/extensions/index.ts | 3 + .../src/core/extensions/loader.test.ts | 141 ++++++++++++++++++ .../src/core/extensions/loader.ts | 15 +- .../src/core/extensions/project-trust.ts | 51 +++++++ .../src/core/resolve-config-value.test.ts | 132 ++++++++++++++++ .../src/core/resolve-config-value.ts | 20 +++ .../extensions/memory/storage.test.ts | 98 ++++++++++++ .../resources/extensions/memory/storage.ts | 33 ++-- src/onboarding.ts | 29 ---- src/tests/app-smoke.test.ts | 33 ++-- src/tool-bootstrap.ts | 3 +- src/wizard.ts | 2 + 12 files changed, 504 insertions(+), 56 deletions(-) create mode 100644 packages/pi-coding-agent/src/core/extensions/loader.test.ts create mode 100644 packages/pi-coding-agent/src/core/extensions/project-trust.ts create mode 100644 packages/pi-coding-agent/src/core/resolve-config-value.test.ts create mode 100644 packages/pi-coding-agent/src/resources/extensions/memory/storage.test.ts diff --git a/packages/pi-coding-agent/src/core/extensions/index.ts b/packages/pi-coding-agent/src/core/extensions/index.ts index 39b4a66e4..1e5938a79 100644 --- a/packages/pi-coding-agent/src/core/extensions/index.ts +++ b/packages/pi-coding-agent/src/core/extensions/index.ts @@ -6,8 +6,11 @@ export type { SlashCommandInfo, SlashCommandLocation, SlashCommandSource } from export { createExtensionRuntime, discoverAndLoadExtensions, + getUntrustedExtensionPaths, + isProjectTrusted, loadExtensionFromFactory, loadExtensions, + trustProject, } from "./loader.js"; export type { ExtensionErrorListener, diff --git a/packages/pi-coding-agent/src/core/extensions/loader.test.ts b/packages/pi-coding-agent/src/core/extensions/loader.test.ts new file mode 100644 index 000000000..ef98c1189 --- /dev/null +++ b/packages/pi-coding-agent/src/core/extensions/loader.test.ts @@ -0,0 +1,141 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { isProjectTrusted, trustProject, getUntrustedExtensionPaths } from "./project-trust.js"; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "loader-test-")); +} + +function cleanDir(dir: string): void { + fs.rmSync(dir, { recursive: true, force: true }); +} + +// ─── isProjectTrusted ───────────────────────────────────────────────────────── + +describe("isProjectTrusted", () => { + let agentDir: string; + + beforeEach(() => { + agentDir = makeTempDir(); + }); + + afterEach(() => { + cleanDir(agentDir); + }); + + it("returns false when no trusted-projects.json exists", () => { + assert.equal(isProjectTrusted("/some/project", agentDir), false); + }); + + it("returns false for an untrusted project path", () => { + trustProject("/trusted/project", agentDir); + assert.equal(isProjectTrusted("/other/project", agentDir), false); + }); + + it("returns true after trustProject is called for that path", () => { + trustProject("/trusted/project", agentDir); + assert.equal(isProjectTrusted("/trusted/project", agentDir), true); + }); + + it("canonicalizes paths before comparison (trailing slash)", () => { + trustProject("/my/project/", agentDir); + assert.equal(isProjectTrusted("/my/project", agentDir), true); + }); + + it("returns false when trusted-projects.json is malformed JSON", () => { + fs.mkdirSync(agentDir, { recursive: true }); + fs.writeFileSync(path.join(agentDir, "trusted-projects.json"), "not json"); + assert.equal(isProjectTrusted("/any/project", agentDir), false); + }); + + it("returns false when trusted-projects.json contains non-array", () => { + fs.mkdirSync(agentDir, { recursive: true }); + fs.writeFileSync(path.join(agentDir, "trusted-projects.json"), JSON.stringify({ foo: "bar" })); + assert.equal(isProjectTrusted("/any/project", agentDir), false); + }); +}); + +// ─── trustProject ───────────────────────────────────────────────────────────── + +describe("trustProject", () => { + let agentDir: string; + + beforeEach(() => { + agentDir = makeTempDir(); + }); + + afterEach(() => { + cleanDir(agentDir); + }); + + it("creates agentDir if it does not exist", () => { + const nested = path.join(agentDir, "deeply", "nested"); + trustProject("/a/project", nested); + assert.ok(fs.existsSync(nested)); + }); + + it("persists the trusted path to trusted-projects.json", () => { + trustProject("/a/project", agentDir); + const content = JSON.parse(fs.readFileSync(path.join(agentDir, "trusted-projects.json"), "utf-8")); + assert.ok(Array.isArray(content)); + assert.ok(content.includes(path.resolve("/a/project"))); + }); + + it("accumulates multiple trusted projects", () => { + trustProject("/project/one", agentDir); + trustProject("/project/two", agentDir); + const content = JSON.parse(fs.readFileSync(path.join(agentDir, "trusted-projects.json"), "utf-8")); + assert.equal(content.length, 2); + }); + + it("does not duplicate already-trusted paths", () => { + trustProject("/project/one", agentDir); + trustProject("/project/one", agentDir); + const content = JSON.parse(fs.readFileSync(path.join(agentDir, "trusted-projects.json"), "utf-8")); + assert.equal(content.length, 1); + }); +}); + +// ─── getUntrustedExtensionPaths ─────────────────────────────────────────────── + +describe("getUntrustedExtensionPaths", () => { + let agentDir: string; + + beforeEach(() => { + agentDir = makeTempDir(); + }); + + afterEach(() => { + cleanDir(agentDir); + }); + + it("returns all paths when project is not trusted", () => { + const paths = ["/proj/.pi/extensions/a.ts", "/proj/.pi/extensions/b.ts"]; + const result = getUntrustedExtensionPaths("/proj", paths, agentDir); + assert.deepEqual(result, paths); + }); + + it("returns empty array when project is trusted", () => { + trustProject("/proj", agentDir); + const paths = ["/proj/.pi/extensions/a.ts", "/proj/.pi/extensions/b.ts"]; + const result = getUntrustedExtensionPaths("/proj", paths, agentDir); + assert.deepEqual(result, []); + }); + + it("returns empty array when extension paths list is empty regardless of trust", () => { + const result = getUntrustedExtensionPaths("/proj", [], agentDir); + assert.deepEqual(result, []); + }); + + it("trusting one project does not affect another", () => { + trustProject("/project/a", agentDir); + const paths = ["/project/b/.pi/extensions/evil.ts"]; + const result = getUntrustedExtensionPaths("/project/b", paths, agentDir); + assert.deepEqual(result, paths); + }); +}); diff --git a/packages/pi-coding-agent/src/core/extensions/loader.ts b/packages/pi-coding-agent/src/core/extensions/loader.ts index 60877917f..90ff9b4fc 100644 --- a/packages/pi-coding-agent/src/core/extensions/loader.ts +++ b/packages/pi-coding-agent/src/core/extensions/loader.ts @@ -27,6 +27,8 @@ import * as _bundledPiCodingAgent from "../../index.js"; import { createEventBus, type EventBus } from "../event-bus.js"; import type { ExecOptions } from "../exec.js"; import { execCommand } from "../exec.js"; +import { getUntrustedExtensionPaths } from "./project-trust.js"; +export { isProjectTrusted, trustProject, getUntrustedExtensionPaths } from "./project-trust.js"; import type { Extension, ExtensionAPI, @@ -538,8 +540,19 @@ export async function discoverAndLoadExtensions( }; // 1. Project-local extensions: cwd/.pi/extensions/ + // Only loaded when the project path has been explicitly trusted (TOFU model). const localExtDir = path.join(cwd, ".pi", "extensions"); - addPaths(discoverExtensionsInDir(localExtDir)); + const localDiscovered = discoverExtensionsInDir(localExtDir); + if (localDiscovered.length > 0) { + const untrusted = getUntrustedExtensionPaths(cwd, localDiscovered, agentDir); + if (untrusted.length > 0) { + process.stderr.write( + `[pi] Skipping ${untrusted.length} project-local extension(s) in ${localExtDir} — project not trusted. Use trustProject() to enable.\n`, + ); + } + const trusted = localDiscovered.filter((p) => !untrusted.includes(p)); + addPaths(trusted); + } // 2. Global extensions: agentDir/extensions/ const globalExtDir = path.join(agentDir, "extensions"); diff --git a/packages/pi-coding-agent/src/core/extensions/project-trust.ts b/packages/pi-coding-agent/src/core/extensions/project-trust.ts new file mode 100644 index 000000000..e385ea3e9 --- /dev/null +++ b/packages/pi-coding-agent/src/core/extensions/project-trust.ts @@ -0,0 +1,51 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +const TRUSTED_PROJECTS_FILE = "trusted-projects.json"; + +function getTrustedProjectsPath(agentDir: string): string { + return path.join(agentDir, TRUSTED_PROJECTS_FILE); +} + +function readTrustedProjects(agentDir: string): Set { + const filePath = getTrustedProjectsPath(agentDir); + try { + const content = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(content); + if (Array.isArray(parsed)) { + return new Set(parsed.filter((p) => typeof p === "string")); + } + } catch { + // File missing or malformed — start with empty set + } + return new Set(); +} + +function writeTrustedProjects(agentDir: string, trusted: Set): void { + const filePath = getTrustedProjectsPath(agentDir); + fs.mkdirSync(agentDir, { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify([...trusted], null, 2), "utf-8"); +} + +export function isProjectTrusted(projectPath: string, agentDir: string): boolean { + const canonical = path.resolve(projectPath); + return readTrustedProjects(agentDir).has(canonical); +} + +export function trustProject(projectPath: string, agentDir: string): void { + const canonical = path.resolve(projectPath); + const trusted = readTrustedProjects(agentDir); + trusted.add(canonical); + writeTrustedProjects(agentDir, trusted); +} + +export function getUntrustedExtensionPaths( + projectPath: string, + extensionPaths: string[], + agentDir: string, +): string[] { + if (isProjectTrusted(projectPath, agentDir)) { + return []; + } + return extensionPaths; +} diff --git a/packages/pi-coding-agent/src/core/resolve-config-value.test.ts b/packages/pi-coding-agent/src/core/resolve-config-value.test.ts new file mode 100644 index 000000000..ea9899f88 --- /dev/null +++ b/packages/pi-coding-agent/src/core/resolve-config-value.test.ts @@ -0,0 +1,132 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { + resolveConfigValue, + clearConfigValueCache, + SAFE_COMMAND_PREFIXES, +} from "./resolve-config-value.js"; + +beforeEach(() => { + clearConfigValueCache(); +}); + +describe("SAFE_COMMAND_PREFIXES", () => { + it("exports the allowlist array", () => { + assert.ok(Array.isArray(SAFE_COMMAND_PREFIXES)); + assert.ok(SAFE_COMMAND_PREFIXES.length > 0); + }); + + it("includes expected credential tools", () => { + assert.ok(SAFE_COMMAND_PREFIXES.includes("pass")); + assert.ok(SAFE_COMMAND_PREFIXES.includes("op")); + assert.ok(SAFE_COMMAND_PREFIXES.includes("aws")); + }); +}); + +describe("resolveConfigValue — non-command values", () => { + it("returns the literal value when it does not match an env var", () => { + const result = resolveConfigValue("my-literal-key"); + assert.equal(result, "my-literal-key"); + }); + + it("returns the env var value when the config matches an env var name", () => { + process.env["TEST_RESOLVE_CONFIG_VAR"] = "env-value"; + const result = resolveConfigValue("TEST_RESOLVE_CONFIG_VAR"); + assert.equal(result, "env-value"); + delete process.env["TEST_RESOLVE_CONFIG_VAR"]; + }); +}); + +describe("resolveConfigValue — command allowlist enforcement", () => { + it("blocks a disallowed command and returns undefined", () => { + const stderrChunks: string[] = []; + const originalWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk: string | Uint8Array, ...args: unknown[]) => { + stderrChunks.push(chunk.toString()); + return true; + }; + + try { + const result = resolveConfigValue("!curl http://evil.com"); + assert.equal(result, undefined); + assert.ok(stderrChunks.some((line) => line.includes("curl"))); + } finally { + process.stderr.write = originalWrite; + } + }); + + it("blocks another disallowed command (rm)", () => { + const result = resolveConfigValue("!rm -rf /tmp/test"); + assert.equal(result, undefined); + }); + + it("blocks a disallowed command with no arguments", () => { + const result = resolveConfigValue("!wget"); + assert.equal(result, undefined); + }); + + it("allows a safe command prefix to proceed to execution", () => { + // `pass` is unlikely to be installed in CI, so we just verify it does NOT + // return undefined due to the allowlist check — it may return undefined if + // the binary is absent, but the block path must not be taken. + // We confirm by checking no "Blocked" message appears on stderr. + const stderrChunks: string[] = []; + const originalWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk: string | Uint8Array, ...args: unknown[]) => { + stderrChunks.push(chunk.toString()); + return true; + }; + + try { + resolveConfigValue("!pass show nonexistent-entry-for-test"); + const blocked = stderrChunks.some((line) => + line.includes("Blocked disallowed command") + ); + assert.equal(blocked, false, "pass should not be blocked by the allowlist"); + } finally { + process.stderr.write = originalWrite; + } + }); +}); + +describe("resolveConfigValue — caching", () => { + it("caches the result of a blocked command", () => { + const callCount = { n: 0 }; + const originalWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk: string | Uint8Array, ...args: unknown[]) => { + callCount.n++; + return true; + }; + + try { + resolveConfigValue("!curl http://evil.com"); + resolveConfigValue("!curl http://evil.com"); + // The block warning should only fire once; the second call hits the cache + // before reaching the allowlist check, so stderr count is 1. + assert.equal(callCount.n, 1); + } finally { + process.stderr.write = originalWrite; + } + }); + + it("clearConfigValueCache resets cached entries", () => { + const stderrChunks: string[] = []; + const originalWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk: string | Uint8Array, ...args: unknown[]) => { + stderrChunks.push(chunk.toString()); + return true; + }; + + try { + resolveConfigValue("!curl http://evil.com"); + assert.equal(stderrChunks.length, 1); + + clearConfigValueCache(); + + resolveConfigValue("!curl http://evil.com"); + assert.equal(stderrChunks.length, 2); + } finally { + process.stderr.write = originalWrite; + } + }); +}); diff --git a/packages/pi-coding-agent/src/core/resolve-config-value.ts b/packages/pi-coding-agent/src/core/resolve-config-value.ts index da127869b..3b3395ef3 100644 --- a/packages/pi-coding-agent/src/core/resolve-config-value.ts +++ b/packages/pi-coding-agent/src/core/resolve-config-value.ts @@ -8,6 +8,19 @@ import { execSync } from "child_process"; // Cache for shell command results (persists for process lifetime) const commandResultCache = new Map(); +export const SAFE_COMMAND_PREFIXES = [ + "pass", + "op", + "aws", + "gcloud", + "vault", + "security", + "gpg", + "bw", + "gopass", + "lpass", +]; + /** * Resolve a config value (API key, header value, etc.) to an actual value. * - If starts with "!", executes the rest as a shell command and uses stdout (cached) @@ -27,6 +40,13 @@ function executeCommand(commandConfig: string): string | undefined { } const command = commandConfig.slice(1); + const firstToken = command.split(/\s+/)[0]; + if (!SAFE_COMMAND_PREFIXES.includes(firstToken)) { + process.stderr.write(`[resolve-config-value] Blocked disallowed command: "${firstToken}". Allowed: ${SAFE_COMMAND_PREFIXES.join(", ")}\n`); + commandResultCache.set(commandConfig, undefined); + return undefined; + } + let result: string | undefined; try { const output = execSync(command, { diff --git a/packages/pi-coding-agent/src/resources/extensions/memory/storage.test.ts b/packages/pi-coding-agent/src/resources/extensions/memory/storage.test.ts new file mode 100644 index 000000000..f31a40b7b --- /dev/null +++ b/packages/pi-coding-agent/src/resources/extensions/memory/storage.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { describe, it, mock } from "node:test"; +import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { MemoryStorage } from "./storage.js"; + +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "gsd-memory-storage-test-")); +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe("MemoryStorage debounced persistence", () => { + it("multiple rapid mutations only trigger one persist write", async () => { + const dir = makeTmpDir(); + const dbPath = join(dir, "test.db"); + try { + const storage = await MemoryStorage.create(dbPath); + + const initialStat = readFileSync(dbPath); + const initialMtime = initialStat.length; + + storage.upsertThreads([ + { threadId: "t1", filePath: "/a.txt", fileSize: 100, fileMtime: 1000, cwd: "/proj" }, + ]); + storage.upsertThreads([ + { threadId: "t2", filePath: "/b.txt", fileSize: 200, fileMtime: 2000, cwd: "/proj" }, + ]); + storage.upsertThreads([ + { threadId: "t3", filePath: "/c.txt", fileSize: 300, fileMtime: 3000, cwd: "/proj" }, + ]); + + const afterMutationsBuf = readFileSync(dbPath); + assert.deepEqual( + afterMutationsBuf, + initialStat, + "File should not have been written yet (debounce window has not elapsed)", + ); + + await wait(700); + + const afterDebounceBuf = readFileSync(dbPath); + assert.notDeepEqual( + afterDebounceBuf, + initialStat, + "File should have been written after debounce window elapsed", + ); + + const stats = storage.getStats(); + assert.equal(stats.totalThreads, 3); + + storage.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("close() flushes pending changes immediately without waiting for debounce", async () => { + const dir = makeTmpDir(); + const dbPath = join(dir, "test.db"); + try { + const storage = await MemoryStorage.create(dbPath); + + const initialBuf = readFileSync(dbPath); + + storage.upsertThreads([ + { threadId: "t1", filePath: "/a.txt", fileSize: 100, fileMtime: 1000, cwd: "/proj" }, + ]); + + const beforeCloseBuf = readFileSync(dbPath); + assert.deepEqual( + beforeCloseBuf, + initialBuf, + "File should not have been written yet (debounce window has not elapsed)", + ); + + storage.close(); + + const afterCloseBuf = readFileSync(dbPath); + assert.notDeepEqual( + afterCloseBuf, + initialBuf, + "File should have been written immediately on close()", + ); + + const reopened = await MemoryStorage.create(dbPath); + const stats = reopened.getStats(); + assert.equal(stats.totalThreads, 1, "Data should be persisted and readable after close"); + reopened.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/pi-coding-agent/src/resources/extensions/memory/storage.ts b/packages/pi-coding-agent/src/resources/extensions/memory/storage.ts index dae388960..d1b979111 100644 --- a/packages/pi-coding-agent/src/resources/extensions/memory/storage.ts +++ b/packages/pi-coding-agent/src/resources/extensions/memory/storage.ts @@ -46,6 +46,7 @@ export interface JobRow { export class MemoryStorage { private db: SqlJsDatabase; private dbPath: string; + private persistTimer: ReturnType | null = null; private constructor(db: SqlJsDatabase, dbPath: string) { this.db = db; @@ -76,6 +77,16 @@ export class MemoryStorage { writeFileSync(this.dbPath, Buffer.from(data)); } + private schedulePersist(): void { + if (this.persistTimer) { + clearTimeout(this.persistTimer); + } + this.persistTimer = setTimeout(() => { + this.persistTimer = null; + this.persist(); + }, 500); + } + private initSchema(): void { this.db.run(` CREATE TABLE IF NOT EXISTS threads ( @@ -184,7 +195,7 @@ export class MemoryStorage { } } - this.persist(); + this.schedulePersist(); return { inserted, updated, skipped }; } @@ -221,7 +232,7 @@ export class MemoryStorage { [token], ); - this.persist(); + this.schedulePersist(); return rows.map((r) => ({ jobId: r.id, @@ -246,7 +257,7 @@ export class MemoryStorage { "UPDATE threads SET status = 'done', updated_at = datetime('now') WHERE thread_id = ?", [threadId], ); - this.persist(); + this.schedulePersist(); } /** @@ -261,7 +272,7 @@ export class MemoryStorage { "UPDATE threads SET status = 'error', error_message = ?, updated_at = datetime('now') WHERE thread_id = ?", [errorMessage, threadId], ); - this.persist(); + this.schedulePersist(); } /** @@ -305,7 +316,7 @@ export class MemoryStorage { [jobId, workerId, token, expiresAt], ); - this.persist(); + this.schedulePersist(); return { jobId, ownershipToken: token }; } @@ -317,7 +328,7 @@ export class MemoryStorage { "UPDATE jobs SET status = 'done', updated_at = datetime('now') WHERE id = ? AND phase = 'stage2'", [jobId], ); - this.persist(); + this.schedulePersist(); } /** @@ -406,7 +417,7 @@ export class MemoryStorage { this.db.run("DELETE FROM stage1_outputs"); this.db.run("DELETE FROM jobs"); this.db.run("DELETE FROM threads"); - this.persist(); + this.schedulePersist(); } /** @@ -422,7 +433,7 @@ export class MemoryStorage { [cwd], ); this.db.run("DELETE FROM threads WHERE cwd = ?", [cwd]); - this.persist(); + this.schedulePersist(); } /** @@ -453,10 +464,14 @@ export class MemoryStorage { [randomUUID(), t.thread_id], ); } - this.persist(); + this.schedulePersist(); } close(): void { + if (this.persistTimer) { + clearTimeout(this.persistTimer); + this.persistTimer = null; + } this.persist(); this.db.close(); } diff --git a/src/onboarding.ts b/src/onboarding.ts index 7c649530d..2d858c0d8 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -933,32 +933,3 @@ async function runDiscordChannelStep(p: ClackModule, pc: PicoModule, token: stri return channelName ?? null } -// ─── Env hydration (migrated from wizard.ts) ───────────────────────────────── - -/** - * Hydrate process.env from stored auth.json credentials for optional tool keys. - * Runs on every launch so extensions see Brave/Context7/Jina keys stored via the - * wizard on prior launches. - */ -export function loadStoredEnvKeys(authStorage: AuthStorage): void { - const providers: Array<[string, string]> = [ - ['brave', 'BRAVE_API_KEY'], - ['brave_answers', 'BRAVE_ANSWERS_KEY'], - ['context7', 'CONTEXT7_API_KEY'], - ['jina', 'JINA_API_KEY'], - ['slack_bot', 'SLACK_BOT_TOKEN'], - ['discord_bot', 'DISCORD_BOT_TOKEN'], - ['telegram_bot', 'TELEGRAM_BOT_TOKEN'], - ['groq', 'GROQ_API_KEY'], - ['ollama-cloud', 'OLLAMA_API_KEY'], - ['custom-openai', 'CUSTOM_OPENAI_API_KEY'], - ] - for (const [provider, envVar] of providers) { - if (!process.env[envVar]) { - const cred = authStorage.get(provider) - if (cred?.type === 'api_key' && cred.key) { - process.env[envVar] = cred.key - } - } - } -} diff --git a/src/tests/app-smoke.test.ts b/src/tests/app-smoke.test.ts index 69893d360..6e36ae2b2 100644 --- a/src/tests/app-smoke.test.ts +++ b/src/tests/app-smoke.test.ts @@ -173,19 +173,21 @@ test("loadStoredEnvKeys hydrates process.env from auth.json", async () => { brave_answers: { type: "api_key", key: "test-answers-key" }, context7: { type: "api_key", key: "test-ctx7-key" }, tavily: { type: "api_key", key: "test-tavily-key" }, + telegram_bot: { type: "api_key", key: "test-telegram-key" }, + "custom-openai": { type: "api_key", key: "test-custom-openai-key" }, })); // Clear any existing env vars - const origBrave = process.env.BRAVE_API_KEY; - const origBraveAnswers = process.env.BRAVE_ANSWERS_KEY; - const origCtx7 = process.env.CONTEXT7_API_KEY; - const origJina = process.env.JINA_API_KEY; - const origTavily = process.env.TAVILY_API_KEY; - delete process.env.BRAVE_API_KEY; - delete process.env.BRAVE_ANSWERS_KEY; - delete process.env.CONTEXT7_API_KEY; - delete process.env.JINA_API_KEY; - delete process.env.TAVILY_API_KEY; + const envVarsToRestore = [ + "BRAVE_API_KEY", "BRAVE_ANSWERS_KEY", "CONTEXT7_API_KEY", + "JINA_API_KEY", "TAVILY_API_KEY", "TELEGRAM_BOT_TOKEN", + "CUSTOM_OPENAI_API_KEY", + ]; + const origValues: Record = {}; + for (const v of envVarsToRestore) { + origValues[v] = process.env[v]; + delete process.env[v]; + } try { const auth = AuthStorage.create(authPath); @@ -196,13 +198,12 @@ test("loadStoredEnvKeys hydrates process.env from auth.json", async () => { assert.equal(process.env.CONTEXT7_API_KEY, "test-ctx7-key", "CONTEXT7_API_KEY hydrated"); assert.equal(process.env.JINA_API_KEY, undefined, "JINA_API_KEY not set (not in auth)"); assert.equal(process.env.TAVILY_API_KEY, "test-tavily-key", "TAVILY_API_KEY hydrated"); + assert.equal(process.env.TELEGRAM_BOT_TOKEN, "test-telegram-key", "TELEGRAM_BOT_TOKEN hydrated"); + assert.equal(process.env.CUSTOM_OPENAI_API_KEY, "test-custom-openai-key", "CUSTOM_OPENAI_API_KEY hydrated"); } finally { - // Restore original env - if (origBrave) process.env.BRAVE_API_KEY = origBrave; else delete process.env.BRAVE_API_KEY; - if (origBraveAnswers) process.env.BRAVE_ANSWERS_KEY = origBraveAnswers; else delete process.env.BRAVE_ANSWERS_KEY; - if (origCtx7) process.env.CONTEXT7_API_KEY = origCtx7; else delete process.env.CONTEXT7_API_KEY; - if (origJina) process.env.JINA_API_KEY = origJina; else delete process.env.JINA_API_KEY; - if (origTavily) process.env.TAVILY_API_KEY = origTavily; else delete process.env.TAVILY_API_KEY; + for (const v of envVarsToRestore) { + if (origValues[v]) process.env[v] = origValues[v]; else delete process.env[v]; + } rmSync(tmp, { recursive: true, force: true }); } }); diff --git a/src/tool-bootstrap.ts b/src/tool-bootstrap.ts index 349133250..84c80cce5 100644 --- a/src/tool-bootstrap.ts +++ b/src/tool-bootstrap.ts @@ -33,7 +33,8 @@ function getCandidateNames(name: string): string[] { function isRegularFile(path: string): boolean { try { - return lstatSync(path).isFile() || lstatSync(path).isSymbolicLink(); + const stat = lstatSync(path); + return stat.isFile() || stat.isSymbolicLink(); } catch { return false; } diff --git a/src/wizard.ts b/src/wizard.ts index d28a05c58..1b11e1e8d 100644 --- a/src/wizard.ts +++ b/src/wizard.ts @@ -16,8 +16,10 @@ export function loadStoredEnvKeys(authStorage: AuthStorage): void { ['tavily', 'TAVILY_API_KEY'], ['slack_bot', 'SLACK_BOT_TOKEN'], ['discord_bot', 'DISCORD_BOT_TOKEN'], + ['telegram_bot', 'TELEGRAM_BOT_TOKEN'], ['groq', 'GROQ_API_KEY'], ['ollama-cloud', 'OLLAMA_API_KEY'], + ['custom-openai', 'CUSTOM_OPENAI_API_KEY'], ] for (const [provider, envVar] of providers) { if (!process.env[envVar]) { From 9c8a24042f6ad3dd131a805a0e95487d291ecd5f Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 16 Mar 2026 13:25:52 -0500 Subject: [PATCH 2/7] feat: convert browser-tools/core.js to TypeScript, add c8 test coverage - Convert browser-tools/core.js (1058 lines) to native TypeScript with full type annotations from the existing .d.ts file. Remove the separate .d.ts declaration file (types are now inline). - Add c8 test coverage reporting: `npm run test:coverage` generates text + lcov reports with 50% statement threshold baseline. - Add coverage/ to .gitignore All 712 unit tests, 63 browser-tools tests, and 11 integration tests pass with zero regressions. --- package-lock.json | 640 ++++++++++++++++++ package.json | 4 +- .../extensions/browser-tools/core.d.ts | 205 ------ .../browser-tools/{core.js => core.ts} | 504 +++++++++----- 4 files changed, 971 insertions(+), 382 deletions(-) delete mode 100644 src/resources/extensions/browser-tools/core.d.ts rename src/resources/extensions/browser-tools/{core.js => core.ts} (70%) diff --git a/package-lock.json b/package-lock.json index 9052ba45b..0daf95dc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "devDependencies": { "@types/node": "^22.0.0", "@types/picomatch": "^4.0.2", + "c8": "^11.0.0", "jiti": "^2.6.1", "typescript": "^5.4.0" }, @@ -795,6 +796,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@borewit/text-codec": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", @@ -1407,6 +1418,44 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@mariozechner/jiti": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@mariozechner/jiti/-/jiti-2.6.5.tgz", @@ -2207,6 +2256,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime-types": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz", @@ -2320,6 +2376,22 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -2412,6 +2484,40 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/c8": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz", + "integrity": "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": "20 || >=22" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -2424,6 +2530,86 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -2491,6 +2677,13 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -2500,6 +2693,16 @@ "once": "^1.4.0" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escodegen": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", @@ -2684,6 +2887,53 @@ "url": "https://github.com/sindresorhus/file-type?sponsor=1" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -2738,6 +2988,16 @@ "node": ">=18" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", @@ -2837,6 +3097,16 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hosted-git-info": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", @@ -2849,6 +3119,13 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -2913,6 +3190,62 @@ "node": ">= 12" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -2983,6 +3316,22 @@ "url": "https://liberapay.com/Koromix" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -2998,6 +3347,22 @@ "node": "20 || >=22" } }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/marked": { "version": "15.0.12", "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", @@ -3142,6 +3507,38 @@ } } }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -3187,6 +3584,16 @@ "node": ">= 14" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-expression-matcher": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", @@ -3202,6 +3609,16 @@ "node": ">=14.0.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", @@ -3374,6 +3791,16 @@ "once": "^1.3.1" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3468,6 +3895,29 @@ "@img/sharp-win32-x64": "0.34.5" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -3540,6 +3990,44 @@ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "license": "MIT" }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", @@ -3583,6 +4071,34 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/token-types": { "version": "6.1.2", "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", @@ -3654,6 +4170,21 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -3663,6 +4194,63 @@ "node": ">= 8" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -3690,6 +4278,16 @@ } } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", @@ -3705,6 +4303,35 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", @@ -3715,6 +4342,19 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yoctocolors": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", diff --git a/package.json b/package.json index e893507c4..d65a2e23c 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "copy-themes": "node -e \"const{mkdirSync,cpSync}=require('fs');const{resolve}=require('path');const src=resolve(__dirname,'packages/pi-coding-agent/dist/modes/interactive/theme');mkdirSync('pkg/dist/modes/interactive/theme',{recursive:true});cpSync(src,'pkg/dist/modes/interactive/theme',{recursive:true})\"", "copy-export-html": "node -e \"const{mkdirSync,cpSync}=require('fs');const{resolve}=require('path');const src=resolve(__dirname,'packages/pi-coding-agent/dist/core/export-html');mkdirSync('pkg/dist/core/export-html',{recursive:true});cpSync(src,'pkg/dist/core/export-html',{recursive:true})\"", "test:unit": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts", + "test:coverage": "c8 --reporter=text --reporter=lcov --exclude='src/resources/extensions/gsd/tests/**' --exclude='src/tests/**' --exclude='scripts/**' --exclude='native/**' --exclude='node_modules/**' --check-coverage --statements=40 --lines=40 --branches=0 --functions=0 node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts", "test:integration": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*integration*.test.ts src/tests/integration/*.test.ts", "test": "npm run test:unit && npm run test:integration", "test:browser-tools": "node --test src/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs src/resources/extensions/browser-tools/tests/browser-tools-integration.test.mjs", @@ -103,14 +104,15 @@ "devDependencies": { "@types/node": "^22.0.0", "@types/picomatch": "^4.0.2", + "c8": "^11.0.0", "jiti": "^2.6.1", "typescript": "^5.4.0" }, "optionalDependencies": { "@gsd-build/engine-darwin-arm64": ">=2.10.2", "@gsd-build/engine-darwin-x64": ">=2.10.2", - "@gsd-build/engine-linux-x64-gnu": ">=2.10.2", "@gsd-build/engine-linux-arm64-gnu": ">=2.10.2", + "@gsd-build/engine-linux-x64-gnu": ">=2.10.2", "@gsd-build/engine-win32-x64-msvc": ">=2.10.2", "fsevents": "~2.3.3", "koffi": "^2.9.0" diff --git a/src/resources/extensions/browser-tools/core.d.ts b/src/resources/extensions/browser-tools/core.d.ts deleted file mode 100644 index 3f740e355..000000000 --- a/src/resources/extensions/browser-tools/core.d.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Type declarations for core.js — runtime-neutral helper logic for browser-tools. - */ - -export interface ActionTimeline { - limit: number; - nextId: number; - entries: ActionEntry[]; -} - -export interface ActionEntry { - id: number; - tool: string; - paramsSummary: string; - startedAt: number; - finishedAt: number | null; - status: string; - beforeUrl: string; - afterUrl: string; - verificationSummary?: string; - warningSummary?: string; - diffSummary?: string; - changed?: boolean; - error?: string; -} - -export interface ActionPartial { - tool: string; - paramsSummary?: string; - startedAt?: number; - beforeUrl?: string; - afterUrl?: string; - verificationSummary?: string; - warningSummary?: string; - diffSummary?: string; - changed?: boolean; - error?: string; -} - -export interface ActionUpdates { - finishedAt?: number; - status?: string; - afterUrl?: string; - verificationSummary?: string; - warningSummary?: string; - diffSummary?: string; - changed?: boolean; - error?: string; -} - -export interface DiffResult { - changed: boolean; - changes: Array<{ type: string; before: unknown; after: unknown }>; - summary: string; -} - -export interface Threshold { - op: string; - n: number; -} - -export interface PageRegistry { - pages: PageEntry[]; - activePageId: number | null; - nextId: number; -} - -export interface PageEntry { - id: number; - page: any; - title: string; - url: string; - opener: number | null; -} - -export interface PageListEntry { - id: number; - title: string; - url: string; - opener: number | null; - isActive: boolean; -} - -export interface SnapshotModeConfig { - tags: string[]; - roles: string[]; - selectors: string[]; - ariaAttributes: string[]; - useInteractiveFilter: boolean; - visibleOnly?: boolean; - containerExpand?: boolean; -} - -export interface AssertionCheckResult { - name: string; - passed: boolean; - actual: unknown; - expected: unknown; - selector?: string; - text?: string; -} - -export interface AssertionEvaluation { - verified: boolean; - checks: AssertionCheckResult[]; - summary: string; - agentHint: string; -} - -export interface WaitValidationError { - error: string; -} - -export interface BatchStepResult { - ok: boolean; - stopReason: string | null; - failedStepIndex: number | null; - stepResults: unknown[]; - summary: string; -} - -export interface FormattedTimeline { - entries: Array<{ - id: number | null; - tool: string; - status: string; - durationMs: number | null; - beforeUrl: string; - afterUrl: string; - line: string; - }>; - retained: number; - totalRecorded: number; - bounded: boolean; - summary: string; -} - -export interface FailureHypothesis { - hasFailures: boolean; - categories: string[]; - summary: string; - signals: Array<{ category: string; source: string; detail: string }>; -} - -export interface SessionSummary { - counts: { - pages: number; - actions: { total: number; retained: number; success: number; error: number; running: number }; - waits: { total: number; success: number; error: number; running: number }; - assertions: { total: number; passed: number; failed: number; running: number }; - consoleErrors: number; - failedRequests: number; - dialogs: number; - }; - activePage: { id: number | null; title: string; url: string } | null; - caveats: string[]; - failureHypothesis: FailureHypothesis; - summary: string; -} - -export function createActionTimeline(limit?: number): ActionTimeline; -export function beginAction(timeline: ActionTimeline, partial: ActionPartial): ActionEntry; -export function finishAction(timeline: ActionTimeline, actionId: number, updates?: ActionUpdates): ActionEntry | null; -export function findAction(timeline: ActionTimeline, actionId: number): ActionEntry | null; -export function toActionParamsSummary(params: unknown): string; -export function diffCompactStates(before: unknown, after: unknown): DiffResult; -export function includesNeedle(haystack: string, needle: string): boolean; -export function parseThreshold(value: string | null | undefined): Threshold | null; -export function meetsThreshold(count: number, threshold: Threshold): boolean; -export function getEntriesSince( - entries: Array<{ timestamp?: number }>, - sinceActionId: number | undefined, - timeline: ActionTimeline, -): unknown[]; -export function evaluateAssertionChecks(args: { checks: unknown[]; state: unknown }): AssertionEvaluation; -export function validateWaitParams(params: { condition: string; value?: string; threshold?: string }): WaitValidationError | null; -export function createRegionStableScript(selector: string): string; -export function createPageRegistry(): PageRegistry; -export function registryAddPage( - registry: PageRegistry, - info: { page: unknown; title?: string; url?: string; opener?: number | null }, -): PageEntry; -export function registryRemovePage(registry: PageRegistry, pageId: number): { removed: PageEntry; newActiveId: number | null }; -export function registrySetActive(registry: PageRegistry, pageId: number): void; -export function registryGetActive(registry: PageRegistry): PageEntry; -export function registryGetPage(registry: PageRegistry, pageId: number): PageEntry | null; -export function registryListPages(registry: PageRegistry): PageListEntry[]; -export function createBoundedLogPusher(maxSize: number): (array: unknown[], entry: unknown) => void; -export function runBatchSteps(args: { - steps: unknown[]; - executeStep: (step: unknown, index: number) => Promise<{ ok: boolean; [key: string]: unknown }>; - stopOnFailure?: boolean; -}): Promise; - -export declare const SNAPSHOT_MODES: Record; -export function getSnapshotModeConfig(mode: string): SnapshotModeConfig | null; -export function computeContentHash(text: string): string; -export function computeStructuralSignature(tag: string, role: string, childTags: string[]): string; -export function matchFingerprint( - stored: { contentHash?: string; structuralSignature?: string }, - candidate: { contentHash?: string; structuralSignature?: string }, -): boolean; -export function formatTimelineEntries(entries?: unknown[], options?: Record): FormattedTimeline; -export function buildFailureHypothesis(session?: Record): FailureHypothesis; -export function summarizeBrowserSession(session?: Record): SessionSummary; diff --git a/src/resources/extensions/browser-tools/core.js b/src/resources/extensions/browser-tools/core.ts similarity index 70% rename from src/resources/extensions/browser-tools/core.js rename to src/resources/extensions/browser-tools/core.ts index 016e209fa..7cb654361 100644 --- a/src/resources/extensions/browser-tools/core.js +++ b/src/resources/extensions/browser-tools/core.ts @@ -4,7 +4,171 @@ * Kept free of pi-specific imports so it can be exercised with node:test. */ -export function createActionTimeline(limit = 60) { +// --------------------------------------------------------------------------- +// Interfaces & Types +// --------------------------------------------------------------------------- + +export interface ActionTimeline { + limit: number; + nextId: number; + entries: ActionEntry[]; +} + +export interface ActionEntry { + id: number; + tool: string; + paramsSummary: string; + startedAt: number; + finishedAt: number | null; + status: string; + beforeUrl: string; + afterUrl: string; + verificationSummary?: string; + warningSummary?: string; + diffSummary?: string; + changed?: boolean; + error?: string; +} + +export interface ActionPartial { + tool: string; + paramsSummary?: string; + startedAt?: number; + beforeUrl?: string; + afterUrl?: string; + verificationSummary?: string; + warningSummary?: string; + diffSummary?: string; + changed?: boolean; + error?: string; +} + +export interface ActionUpdates { + finishedAt?: number; + status?: string; + afterUrl?: string; + verificationSummary?: string; + warningSummary?: string; + diffSummary?: string; + changed?: boolean; + error?: string; +} + +export interface DiffResult { + changed: boolean; + changes: Array<{ type: string; before: unknown; after: unknown }>; + summary: string; +} + +export interface Threshold { + op: string; + n: number; +} + +export interface PageRegistry { + pages: PageEntry[]; + activePageId: number | null; + nextId: number; +} + +export interface PageEntry { + id: number; + page: unknown; + title: string; + url: string; + opener: number | null; +} + +export interface PageListEntry { + id: number; + title: string; + url: string; + opener: number | null; + isActive: boolean; +} + +export interface SnapshotModeConfig { + tags: string[]; + roles: string[]; + selectors: string[]; + ariaAttributes: string[]; + useInteractiveFilter: boolean; + visibleOnly?: boolean; + containerExpand?: boolean; +} + +export interface AssertionCheckResult { + name: string; + passed: boolean; + actual: unknown; + expected: unknown; + selector?: string; + text?: string; +} + +export interface AssertionEvaluation { + verified: boolean; + checks: AssertionCheckResult[]; + summary: string; + agentHint: string; +} + +export interface WaitValidationError { + error: string; +} + +export interface BatchStepResult { + ok: boolean; + stopReason: string | null; + failedStepIndex: number | null; + stepResults: unknown[]; + summary: string; +} + +export interface FormattedTimeline { + entries: Array<{ + id: number | null; + tool: string; + status: string; + durationMs: number | null; + beforeUrl: string; + afterUrl: string; + line: string; + }>; + retained: number; + totalRecorded: number; + bounded: boolean; + summary: string; +} + +export interface FailureHypothesis { + hasFailures: boolean; + categories: string[]; + summary: string; + signals: Array<{ category: string; source: string; detail: string }>; +} + +export interface SessionSummary { + counts: { + pages: number; + actions: { total: number; retained: number; success: number; error: number; running: number }; + waits: { total: number; success: number; error: number; running: number }; + assertions: { total: number; passed: number; failed: number; running: number }; + consoleErrors: number; + failedRequests: number; + dialogs: number; + }; + activePage: { id: number | null; title: string; url: string } | null; + caveats: string[]; + failureHypothesis: FailureHypothesis; + summary: string; +} + +// --------------------------------------------------------------------------- +// Action Timeline +// --------------------------------------------------------------------------- + +export function createActionTimeline(limit = 60): ActionTimeline { return { limit, nextId: 1, @@ -12,8 +176,8 @@ export function createActionTimeline(limit = 60) { }; } -export function beginAction(timeline, partial) { - const entry = { +export function beginAction(timeline: ActionTimeline, partial: ActionPartial): ActionEntry { + const entry: ActionEntry = { id: timeline.nextId++, tool: partial.tool, paramsSummary: partial.paramsSummary ?? "", @@ -35,7 +199,7 @@ export function beginAction(timeline, partial) { return entry; } -export function finishAction(timeline, actionId, updates = {}) { +export function finishAction(timeline: ActionTimeline, actionId: number, updates: ActionUpdates = {}): ActionEntry | null { const entry = timeline.entries.find((item) => item.id === actionId); if (!entry) return null; Object.assign(entry, updates, { @@ -51,14 +215,14 @@ export function finishAction(timeline, actionId, updates = {}) { return entry; } -export function findAction(timeline, actionId) { +export function findAction(timeline: ActionTimeline, actionId: number): ActionEntry | null { return timeline.entries.find((item) => item.id === actionId) ?? null; } -export function toActionParamsSummary(params) { +export function toActionParamsSummary(params: unknown): string { if (!params || typeof params !== "object") return ""; - const entries = []; - for (const [key, value] of Object.entries(params)) { + const entries: string[] = []; + for (const [key, value] of Object.entries(params as Record)) { if (value === undefined || value === null) continue; if (typeof value === "string") { entries.push(`${key}=${JSON.stringify(value.length > 60 ? `${value.slice(0, 57)}...` : value)}`); @@ -77,8 +241,22 @@ export function toActionParamsSummary(params) { return entries.slice(0, 6).join(", "); } -export function diffCompactStates(before, after) { - const changes = []; +// --------------------------------------------------------------------------- +// Compact State Diffing +// --------------------------------------------------------------------------- + +interface CompactStateForDiff { + url?: string; + title?: string; + focus?: string; + dialog?: { count?: number; title?: string }; + counts?: Record; + headings?: string[]; + bodyText?: string; +} + +export function diffCompactStates(before: CompactStateForDiff | null | undefined, after: CompactStateForDiff | null | undefined): DiffResult { + const changes: Array<{ type: string; before: unknown; after: unknown }> = []; if (!before || !after) { return { changed: false, @@ -159,11 +337,15 @@ export function diffCompactStates(before, after) { return { changed, changes, summary }; } -function normalizeString(value) { +// --------------------------------------------------------------------------- +// String helpers +// --------------------------------------------------------------------------- + +function normalizeString(value: unknown): string { return String(value ?? "").trim(); } -export function includesNeedle(haystack, needle) { +export function includesNeedle(haystack: string, needle: string): boolean { return normalizeString(haystack).toLowerCase().includes(normalizeString(needle).toLowerCase()); } @@ -173,10 +355,8 @@ export function includesNeedle(haystack, needle) { /** * Parse a threshold expression like ">=3", "==0", "<5", or bare "3" (defaults to ">="). - * @param {string} value - * @returns {{ op: string, n: number } | null} — null if malformed */ -export function parseThreshold(value) { +export function parseThreshold(value: string | null | undefined): Threshold | null { if (value == null) return null; const str = String(value).trim(); if (str === "") return null; @@ -189,11 +369,8 @@ export function parseThreshold(value) { /** * Evaluate whether a count meets a parsed threshold. - * @param {number} count - * @param {{ op: string, n: number }} threshold - * @returns {boolean} */ -export function meetsThreshold(count, threshold) { +export function meetsThreshold(count: number, threshold: Threshold): boolean { switch (threshold.op) { case ">=": return count >= threshold.n; case "<=": return count <= threshold.n; @@ -207,12 +384,12 @@ export function meetsThreshold(count, threshold) { /** * Filter entries that occurred at or after a given action's start time. * If sinceActionId is missing or the action isn't found, returns all entries. - * @param {Array<{ timestamp?: number }>} entries - * @param {number | undefined} sinceActionId - * @param {{ entries: Array<{ id: number, startedAt: number }> }} timeline - * @returns {Array} */ -export function getEntriesSince(entries, sinceActionId, timeline) { +export function getEntriesSince( + entries: Array<{ timestamp?: number }>, + sinceActionId: number | undefined, + timeline: ActionTimeline, +): Array<{ timestamp?: number }> { if (!entries || !Array.isArray(entries)) return []; if (sinceActionId == null || !timeline) return entries; const action = findAction(timeline, sinceActionId); @@ -221,8 +398,34 @@ export function getEntriesSince(entries, sinceActionId, timeline) { return entries.filter((e) => (e.timestamp ?? 0) >= since); } -export function evaluateAssertionChecks({ checks, state }) { - const results = []; +// --------------------------------------------------------------------------- +// Assertion Evaluation +// --------------------------------------------------------------------------- + +interface AssertionCheckInput { + kind: string; + selector?: string; + value?: string; + text?: string; + checked?: boolean; + sinceActionId?: number; +} + +interface AssertionState { + url?: string; + title?: string; + bodyText?: string; + focus?: string; + selectorStates?: Record; + consoleEntries?: Array<{ type?: string; text?: string; message?: string; timestamp?: number }>; + networkEntries?: Array<{ type?: string; url?: string; status?: number; failed?: boolean; timestamp?: number }>; + allConsoleEntries?: Array<{ type?: string; text?: string; message?: string; timestamp?: number }>; + allNetworkEntries?: Array<{ type?: string; url?: string; status?: number; failed?: boolean; timestamp?: number }>; + actionTimeline?: ActionTimeline | null; +} + +export function evaluateAssertionChecks({ checks, state }: { checks: AssertionCheckInput[]; state: AssertionState }): AssertionEvaluation { + const results: AssertionCheckResult[] = []; const selectorStates = state.selectorStates ?? {}; const consoleEntries = state.consoleEntries ?? []; const networkEntries = state.networkEntries ?? []; @@ -233,29 +436,29 @@ export function evaluateAssertionChecks({ checks, state }) { for (const check of checks) { const selectorState = check.selector ? selectorStates[check.selector] ?? null : null; let passed = false; - let actual; - let expected; + let actual: unknown; + let expected: unknown; switch (check.kind) { case "url_contains": actual = state.url ?? ""; expected = check.value ?? ""; - passed = includesNeedle(actual, expected); + passed = includesNeedle(actual as string, expected as string); break; case "title_contains": actual = state.title ?? ""; expected = check.value ?? ""; - passed = includesNeedle(actual, expected); + passed = includesNeedle(actual as string, expected as string); break; case "text_visible": actual = state.bodyText ?? ""; expected = check.text ?? ""; - passed = includesNeedle(actual, expected); + passed = includesNeedle(actual as string, expected as string); break; case "text_not_visible": actual = state.bodyText ?? ""; expected = check.text ?? ""; - passed = !includesNeedle(actual, expected); + passed = !includesNeedle(actual as string, expected as string); break; case "selector_visible": actual = selectorState?.visible ?? false; @@ -275,12 +478,12 @@ export function evaluateAssertionChecks({ checks, state }) { case "value_contains": actual = selectorState?.value ?? ""; expected = check.value ?? ""; - passed = includesNeedle(actual, expected); + passed = includesNeedle(actual as string, expected as string); break; case "focused_matches": actual = state.focus ?? ""; expected = check.value ?? ""; - passed = includesNeedle(actual, expected); + passed = includesNeedle(actual as string, expected as string); break; case "checked_equals": actual = selectorState?.checked ?? null; @@ -301,8 +504,8 @@ export function evaluateAssertionChecks({ checks, state }) { // --- S02: New structured network/console assertion kinds --- case "request_url_seen": { - const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline); - const matches = filtered.filter((e) => includesNeedle(e.url ?? "", check.text ?? "")); + const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline!); + const matches = (filtered as typeof allNetworkEntries).filter((e) => includesNeedle(e.url ?? "", check.text ?? "")); actual = matches.length > 0; expected = true; passed = actual === true; @@ -310,9 +513,9 @@ export function evaluateAssertionChecks({ checks, state }) { } case "response_status": { - const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline); - const statusNum = parseInt(check.value, 10); - const matches = filtered.filter( + const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline!); + const statusNum = parseInt(check.value!, 10); + const matches = (filtered as typeof allNetworkEntries).filter( (e) => includesNeedle(e.url ?? "", check.text ?? "") && typeof e.status === "number" && e.status === statusNum ); actual = matches.length > 0 ? `found (status=${matches[0].status})` : `not found`; @@ -322,8 +525,8 @@ export function evaluateAssertionChecks({ checks, state }) { } case "console_message_matches": { - const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline); - const matches = filtered.filter((e) => includesNeedle(e.text ?? "", check.text ?? "")); + const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline!); + const matches = (filtered as typeof allConsoleEntries).filter((e) => includesNeedle(e.text ?? "", check.text ?? "")); actual = matches.length > 0; expected = true; passed = actual === true; @@ -331,8 +534,8 @@ export function evaluateAssertionChecks({ checks, state }) { } case "network_count": { - const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline); - const matches = filtered.filter((e) => includesNeedle(e.url ?? "", check.text ?? "")); + const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline!); + const matches = (filtered as typeof allNetworkEntries).filter((e) => includesNeedle(e.url ?? "", check.text ?? "")); const threshold = parseThreshold(check.value); if (!threshold) { actual = `invalid threshold: ${check.value}`; @@ -347,8 +550,8 @@ export function evaluateAssertionChecks({ checks, state }) { } case "console_count": { - const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline); - const matches = filtered.filter((e) => includesNeedle(e.text ?? "", check.text ?? "")); + const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline!); + const matches = (filtered as typeof allConsoleEntries).filter((e) => includesNeedle(e.text ?? "", check.text ?? "")); const threshold = parseThreshold(check.value); if (!threshold) { actual = `invalid threshold: ${check.value}`; @@ -363,8 +566,8 @@ export function evaluateAssertionChecks({ checks, state }) { } case "no_console_errors_since": { - const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline); - const errors = filtered.filter((e) => e.type === "error" || e.type === "pageerror"); + const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline!); + const errors = (filtered as typeof allConsoleEntries).filter((e) => e.type === "error" || e.type === "pageerror"); actual = errors.length; expected = 0; passed = errors.length === 0; @@ -372,8 +575,8 @@ export function evaluateAssertionChecks({ checks, state }) { } case "no_failed_requests_since": { - const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline); - const failures = filtered.filter((e) => e.failed || (typeof e.status === "number" && e.status >= 400)); + const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline!); + const failures = (filtered as typeof allNetworkEntries).filter((e) => e.failed || (typeof e.status === "number" && e.status >= 400)); actual = failures.length; expected = 0; passed = failures.length === 0; @@ -417,11 +620,16 @@ export function evaluateAssertionChecks({ checks, state }) { // Wait-condition validation // --------------------------------------------------------------------------- +interface WaitConditionSpec { + needsValue: boolean; + valueLabel: string; + needsThreshold?: boolean; +} + /** * All recognized wait conditions with their parameter requirements. - * Each entry: { needsValue: bool, valueLabel: string, needsThreshold?: bool } */ -const WAIT_CONDITIONS = { +const WAIT_CONDITIONS: Record = { // Existing 5 conditions selector_visible: { needsValue: true, valueLabel: "CSS selector" }, selector_hidden: { needsValue: true, valueLabel: "CSS selector" }, @@ -440,10 +648,8 @@ const WAIT_CONDITIONS = { /** * Validate parameters for a browser_wait_for condition. - * @param {{ condition: string, value?: string, threshold?: string }} params - * @returns {null | { error: string }} — null if valid, structured error otherwise */ -export function validateWaitParams(params) { +export function validateWaitParams(params: { condition: string; value?: string; threshold?: string }): WaitValidationError | null { const { condition, value, threshold } = params ?? {}; if (!condition) { @@ -477,14 +683,8 @@ export function validateWaitParams(params) { /** * Generate a JS expression string for page.waitForFunction() that detects * DOM stability by comparing snapshot hashes across polling intervals. - * - * The script stores a snapshot on a namespaced window key. When the snapshot - * matches the previous value, the region is considered stable. - * - * @param {string} selector — CSS selector for the target element - * @returns {string} — self-contained JS function body suitable for waitForFunction */ -export function createRegionStableScript(selector) { +export function createRegionStableScript(selector: string): string { // Create a stable key from the selector (simple hash to avoid special chars) const safeKey = Array.from(selector).reduce((h, c) => ((h << 5) - h + c.charCodeAt(0)) | 0, 0) >>> 0; const windowKey = `__pw_region_stable_${safeKey}`; @@ -504,40 +704,20 @@ export function createRegionStableScript(selector) { // Page Registry — pure-logic operations for multi-page/tab management // --------------------------------------------------------------------------- -/** - * Create a fresh page registry. - * @returns {{ pages: Array, activePageId: number | null, nextId: number }} - */ -export function createPageRegistry() { +export function createPageRegistry(): PageRegistry { return { pages: [], activePageId: null, nextId: 1 }; } -/** - * @typedef {{ id: number, page: any, title: string, url: string, opener: number | null }} PageEntry - */ - -/** - * Add a page to the registry. Assigns an auto-incrementing ID. - * @param {ReturnType} registry - * @param {{ page: any, title?: string, url?: string, opener?: number | null }} info - * @returns {PageEntry} - */ -export function registryAddPage(registry, { page, title = "", url = "", opener = null }) { - const entry = { id: registry.nextId++, page, title, url, opener }; +export function registryAddPage( + registry: PageRegistry, + { page, title = "", url = "", opener = null }: { page: unknown; title?: string; url?: string; opener?: number | null }, +): PageEntry { + const entry: PageEntry = { id: registry.nextId++, page, title, url, opener }; registry.pages.push(entry); return entry; } -/** - * Remove a page from the registry by ID. - * If the removed page was active, falls back to the opener (if still present) - * or the last remaining page. - * Orphans any pages whose opener was the removed page (sets their opener to null). - * @param {ReturnType} registry - * @param {number} pageId - * @returns {{ removed: PageEntry, newActiveId: number | null }} - */ -export function registryRemovePage(registry, pageId) { +export function registryRemovePage(registry: PageRegistry, pageId: number): { removed: PageEntry; newActiveId: number | null } { const idx = registry.pages.findIndex((p) => p.id === pageId); if (idx === -1) { const available = registry.pages.map((p) => p.id); @@ -571,12 +751,7 @@ export function registryRemovePage(registry, pageId) { return { removed, newActiveId }; } -/** - * Set the active page by ID. Throws if the page is not in the registry. - * @param {ReturnType} registry - * @param {number} pageId - */ -export function registrySetActive(registry, pageId) { +export function registrySetActive(registry: PageRegistry, pageId: number): void { const entry = registry.pages.find((p) => p.id === pageId); if (!entry) { const available = registry.pages.map((p) => p.id); @@ -589,12 +764,7 @@ export function registrySetActive(registry, pageId) { registry.activePageId = pageId; } -/** - * Get the active page entry. Throws if no active page or active page not found. - * @param {ReturnType} registry - * @returns {PageEntry} - */ -export function registryGetActive(registry) { +export function registryGetActive(registry: PageRegistry): PageEntry { if (registry.activePageId === null) { throw new Error( `registryGetActive: no active page. ` + @@ -613,22 +783,11 @@ export function registryGetActive(registry) { return entry; } -/** - * Get a page entry by ID, or null if not found. - * @param {ReturnType} registry - * @param {number} pageId - * @returns {PageEntry | null} - */ -export function registryGetPage(registry, pageId) { +export function registryGetPage(registry: PageRegistry, pageId: number): PageEntry | null { return registry.pages.find((p) => p.id === pageId) ?? null; } -/** - * List all pages (without the raw `page` reference). - * @param {ReturnType} registry - * @returns {Array<{ id: number, title: string, url: string, opener: number | null, isActive: boolean }>} - */ -export function registryListPages(registry) { +export function registryListPages(registry: PageRegistry): PageListEntry[] { return registry.pages.map((entry) => ({ id: entry.id, title: entry.title, @@ -642,13 +801,8 @@ export function registryListPages(registry) { // FIFO Bounded Log Pusher // --------------------------------------------------------------------------- -/** - * Create a push function that enforces FIFO eviction at push-time. - * @param {number} maxSize — maximum number of entries to retain - * @returns {(array: Array, entry: any) => void} - */ -export function createBoundedLogPusher(maxSize) { - return function push(array, entry) { +export function createBoundedLogPusher(maxSize: number): (array: unknown[], entry: unknown) => void { + return function push(array: unknown[], entry: unknown): void { array.push(entry); if (array.length > maxSize) { array.splice(0, array.length - maxSize); @@ -656,10 +810,14 @@ export function createBoundedLogPusher(maxSize) { }; } -export async function runBatchSteps({ steps, executeStep, stopOnFailure = true }) { - const results = []; +export async function runBatchSteps({ steps, executeStep, stopOnFailure = true }: { + steps: unknown[]; + executeStep: (step: unknown, index: number) => Promise<{ ok: boolean; [key: string]: unknown }>; + stopOnFailure?: boolean; +}): Promise { + const results: unknown[] = []; for (let i = 0; i < steps.length; i += 1) { - const step = steps[i]; + const step = steps[i] as { action: string }; const result = await executeStep(step, i); results.push(result); if (result.ok === false && stopOnFailure) { @@ -685,15 +843,7 @@ export async function runBatchSteps({ steps, executeStep, stopOnFailure = true } // Snapshot Modes — semantic element filtering for browser_snapshot_refs // --------------------------------------------------------------------------- -/** - * Pre-defined snapshot modes that filter elements by semantic category. - * Each mode config defines which elements should be captured. - * - * Shape: { tags: string[], roles: string[], selectors: string[], - * ariaAttributes: string[], useInteractiveFilter: boolean, - * visibleOnly?: boolean, containerExpand?: boolean } - */ -export const SNAPSHOT_MODES = { +export const SNAPSHOT_MODES: Record = { interactive: { tags: [], roles: [], @@ -748,12 +898,7 @@ export const SNAPSHOT_MODES = { }, }; -/** - * Get the snapshot mode config by name. - * @param {string} mode — mode name (e.g. "form", "dialog", "interactive") - * @returns {{ tags: string[], roles: string[], selectors: string[], ariaAttributes: string[], useInteractiveFilter: boolean, visibleOnly?: boolean, containerExpand?: boolean } | null} - */ -export function getSnapshotModeConfig(mode) { +export function getSnapshotModeConfig(mode: string): SnapshotModeConfig | null { return SNAPSHOT_MODES[mode] ?? null; } @@ -761,13 +906,7 @@ export function getSnapshotModeConfig(mode) { // Fingerprint functions — structural identity for ref resolution // --------------------------------------------------------------------------- -/** - * Compute a content hash from visible text using djb2. - * Caller is expected to pre-truncate to ~200 chars and normalize whitespace. - * @param {string} text — visible text content - * @returns {string} — hex string hash, or "0" for empty input - */ -export function computeContentHash(text) { +export function computeContentHash(text: string): string { if (!text) return "0"; let h = 5381; for (let i = 0; i < text.length; i++) { @@ -776,15 +915,7 @@ export function computeContentHash(text) { return (h >>> 0).toString(16); } -/** - * Compute a structural signature from tag, role, and immediate child tag names. - * Uses djb2 hash on the concatenated string `tag|role|child1,child2,...`. - * @param {string} tag — element tag name (lowercase) - * @param {string} role — ARIA role or empty string - * @param {string[]} childTags — array of immediate child tag names (lowercase) - * @returns {string} — hex string hash - */ -export function computeStructuralSignature(tag, role, childTags) { +export function computeStructuralSignature(tag: string, role: string, childTags: string[]): string { const input = `${tag}|${role}|${childTags.join(",")}`; let h = 5381; for (let i = 0; i < input.length; i++) { @@ -793,14 +924,10 @@ export function computeStructuralSignature(tag, role, childTags) { return (h >>> 0).toString(16); } -/** - * Match two fingerprint objects by contentHash and structuralSignature. - * Returns true only when both fields are present on both objects and both match. - * @param {{ contentHash?: string, structuralSignature?: string }} stored - * @param {{ contentHash?: string, structuralSignature?: string }} candidate - * @returns {boolean} - */ -export function matchFingerprint(stored, candidate) { +export function matchFingerprint( + stored: { contentHash?: string; structuralSignature?: string }, + candidate: { contentHash?: string; structuralSignature?: string }, +): boolean { if (!stored || !candidate) return false; if (!stored.contentHash || !stored.structuralSignature) return false; if (!candidate.contentHash || !candidate.structuralSignature) return false; @@ -808,28 +935,32 @@ export function matchFingerprint(stored, candidate) { stored.structuralSignature === candidate.structuralSignature; } -function formatDurationMs(entry) { +// --------------------------------------------------------------------------- +// Timeline Formatting +// --------------------------------------------------------------------------- + +function formatDurationMs(entry: { startedAt?: number; finishedAt?: number | null }): number | null { const startedAt = typeof entry?.startedAt === "number" ? entry.startedAt : null; const finishedAt = typeof entry?.finishedAt === "number" ? entry.finishedAt : null; if (startedAt == null || finishedAt == null || finishedAt < startedAt) return null; return finishedAt - startedAt; } -function summarizeActionStatus(status) { +function summarizeActionStatus(status: string | undefined): string { if (status === "error") return "error"; if (status === "running") return "running"; return "success"; } -function looksBoundedWarning(value) { +function looksBoundedWarning(value: unknown): boolean { return /bounded .*history/i.test(String(value ?? "")); } -function uniqueStrings(values) { - return [...new Set(values.filter(Boolean))]; +function uniqueStrings(values: (string | undefined)[]): string[] { + return [...new Set(values.filter(Boolean))] as string[]; } -export function formatTimelineEntries(entries = [], options = {}) { +export function formatTimelineEntries(entries: ActionEntry[] = [], options: { retained?: number; totalRecorded?: number } = {}): FormattedTimeline { const retained = options.retained ?? entries.length; const totalRecorded = options.totalRecorded ?? retained; const bounded = totalRecorded > retained; @@ -847,7 +978,7 @@ export function formatTimelineEntries(entries = [], options = {}) { const formattedEntries = entries.map((entry) => { const status = summarizeActionStatus(entry.status); const durationMs = formatDurationMs(entry); - const parts = [ + const parts: string[] = [ `#${entry.id ?? "?"}`, entry.tool ?? "unknown_tool", status, @@ -884,12 +1015,23 @@ export function formatTimelineEntries(entries = [], options = {}) { }; } -export function buildFailureHypothesis(session = {}) { +// --------------------------------------------------------------------------- +// Failure Hypothesis +// --------------------------------------------------------------------------- + +interface SessionForHypothesis { + actionTimeline?: { entries: ActionEntry[] }; + consoleEntries?: Array<{ type?: string; text?: string; message?: string }>; + networkEntries?: Array<{ url?: string; status?: number; failed?: boolean }>; + dialogEntries?: Array<{ type?: string; message?: string }>; +} + +export function buildFailureHypothesis(session: SessionForHypothesis = {}): FailureHypothesis { const timelineEntries = session.actionTimeline?.entries ?? []; const consoleEntries = session.consoleEntries ?? []; const networkEntries = session.networkEntries ?? []; const dialogEntries = session.dialogEntries ?? []; - const signals = []; + const signals: Array<{ category: string; source: string; detail: string }> = []; for (const entry of timelineEntries) { if (entry?.status !== "error") continue; @@ -920,7 +1062,7 @@ export function buildFailureHypothesis(session = {}) { if (entry?.type !== "error" && entry?.type !== "pageerror") continue; signals.push({ category: "console", - source: entry.type, + source: entry.type!, detail: entry.text || "Console error recorded", }); } @@ -957,8 +1099,18 @@ export function buildFailureHypothesis(session = {}) { }; } -export function summarizeBrowserSession(session = {}) { - const actionTimeline = session.actionTimeline ?? { limit: 0, entries: [] }; +// --------------------------------------------------------------------------- +// Session Summary +// --------------------------------------------------------------------------- + +interface SessionForSummary extends SessionForHypothesis { + retainedActionCount?: number; + totalActionCount?: number; + pages?: Array<{ id?: number; title?: string; url?: string; isActive?: boolean }>; +} + +export function summarizeBrowserSession(session: SessionForSummary = {}): SessionSummary { + const actionTimeline = session.actionTimeline ?? { limit: 0, entries: [] as ActionEntry[] }; const actionEntries = actionTimeline.entries ?? []; const retainedActionCount = session.retainedActionCount ?? actionEntries.length; const totalActionCount = session.totalActionCount ?? retainedActionCount; @@ -973,7 +1125,7 @@ export function summarizeBrowserSession(session = {}) { acc[status] = (acc[status] ?? 0) + 1; return acc; }, - { success: 0, error: 0, running: 0 }, + { success: 0, error: 0, running: 0 } as Record, ); const waitEntries = actionEntries.filter((entry) => entry.tool === "browser_wait_for"); @@ -982,7 +1134,7 @@ export function summarizeBrowserSession(session = {}) { const failedRequests = networkEntries.filter((entry) => entry.failed || (typeof entry.status === "number" && entry.status >= 400)); const activePage = pages.find((page) => page.isActive) ?? pages[0] ?? null; - const caveats = []; + const caveats: string[] = []; if (totalActionCount > retainedActionCount) { caveats.push(`Showing ${retainedActionCount} of ${totalActionCount} recorded actions; older actions were discarded due to bounded history.`); } From a79e953caaa8f4f8f6c184fe0a8749876c16e079 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 16 Mar 2026 13:29:31 -0500 Subject: [PATCH 3/7] refactor: deduplicate help text, cross-platform validate-pack, fix dev.js - Extract duplicated help text from loader.ts and cli.ts into shared help-text.ts module (single source of truth) - Convert validate-pack.sh to Node.js for Windows compatibility - Fix dev.js using unnecessary npx for tsc (it's a devDependency, use node_modules/.bin/tsc directly) --- package.json | 2 +- scripts/dev.js | 2 +- scripts/validate-pack.js | 116 +++++++++++++++++++++++++++++++++++++++ src/cli.ts | 18 +----- src/help-text.ts | 18 ++++++ src/loader.ts | 18 +----- 6 files changed, 140 insertions(+), 34 deletions(-) create mode 100644 scripts/validate-pack.js create mode 100644 src/help-text.ts diff --git a/package.json b/package.json index d65a2e23c..1b67d91f8 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "pi:uninstall-global": "node scripts/uninstall-pi-global.js", "sync-pkg-version": "node scripts/sync-pkg-version.cjs", "sync-platform-versions": "node native/scripts/sync-platform-versions.cjs", - "validate-pack": "bash scripts/validate-pack.sh", + "validate-pack": "node scripts/validate-pack.js", "typecheck:extensions": "tsc --noEmit --project tsconfig.extensions.json", "prepublishOnly": "npm run sync-pkg-version && npm run sync-platform-versions && git diff --exit-code || (echo 'ERROR: version sync changed files — commit them before publishing' && exit 1) && npm run build && npm run typecheck:extensions && npm run validate-pack" }, diff --git a/scripts/dev.js b/scripts/dev.js index dc87dce60..faf9a75d2 100644 --- a/scripts/dev.js +++ b/scripts/dev.js @@ -19,7 +19,7 @@ const procs = [ spawn('node', [resolve(__dirname, 'watch-resources.js')], { cwd: root, stdio: 'inherit' }), - spawn('npx', ['tsc', '--watch'], { + spawn(resolve(root, 'node_modules', '.bin', 'tsc'), ['--watch'], { cwd: root, stdio: 'inherit' }) ] diff --git a/scripts/validate-pack.js b/scripts/validate-pack.js new file mode 100644 index 000000000..71a2e6754 --- /dev/null +++ b/scripts/validate-pack.js @@ -0,0 +1,116 @@ +// validate-pack.js — Verify the npm tarball is installable before publishing. +// +// Usage: npm run validate-pack (or node scripts/validate-pack.js) +// Exit 0 = safe to publish, Exit 1 = broken package. + +import { execSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const ROOT = resolve(__dirname, '..'); + +let tarball = null; +let installDir = null; + +try { + // --- Guard: workspace packages must not have @gsd/* cross-deps --- + console.log('==> Checking workspace packages for @gsd/* cross-deps...'); + const workspaces = ['native', 'pi-agent-core', 'pi-ai', 'pi-coding-agent', 'pi-tui']; + let crossFailed = false; + + for (const ws of workspaces) { + const pkgPath = join(ROOT, 'packages', ws, 'package.json'); + if (!existsSync(pkgPath)) continue; + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + const deps = Object.keys(pkg.dependencies || {}).filter(d => d.startsWith('@gsd/')); + if (deps.length) { + console.log(` LEAKED in ${ws}: ${deps.join(', ')}`); + crossFailed = true; + } + } + + if (crossFailed) { + console.log('ERROR: Workspace packages have @gsd/* cross-dependencies.'); + console.log(' These cause 404s when npm resolves them from the registry.'); + process.exit(1); + } + console.log(' No @gsd/* cross-dependencies.'); + + // --- Pack tarball --- + console.log('==> Packing tarball...'); + const packOutput = execSync('npm pack --ignore-scripts', { + cwd: ROOT, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + const tarballName = packOutput.trim().split('\n').pop(); + tarball = join(ROOT, tarballName); + + if (!existsSync(tarball)) { + console.log('ERROR: npm pack produced no tarball'); + process.exit(1); + } + + const stats = execSync(`du -h "${tarball}"`, { encoding: 'utf8' }).split('\t')[0].trim(); + console.log(`==> Tarball: ${tarballName} (${stats} compressed)`); + + // --- Check critical files using tar listing --- + console.log('==> Checking critical files...'); + const tarList = execSync(`tar tzf "${tarball}"`, { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 }); + + const requiredFiles = [ + 'dist/loader.js', + 'packages/pi-coding-agent/dist/index.js', + 'scripts/link-workspace-packages.cjs', + ]; + + let missing = false; + for (const required of requiredFiles) { + if (!tarList.includes(`package/${required}`)) { + console.log(` MISSING: ${required}`); + missing = true; + } + } + + if (missing) { + console.log('ERROR: Critical files missing from tarball.'); + process.exit(1); + } + console.log(' Critical files present.'); + + // --- Install test --- + console.log('==> Testing install in isolated directory...'); + installDir = mkdtempSync(join(tmpdir(), 'validate-pack-')); + writeFileSync(join(installDir, 'package.json'), JSON.stringify({ name: 'test-install', version: '1.0.0', private: true }, null, 2)); + + try { + const installOutput = execSync(`npm install "${tarball}"`, { + cwd: installDir, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + console.log(installOutput); + console.log('==> Install succeeded.'); + } catch (err) { + console.log(''); + console.log('ERROR: npm install of tarball failed.'); + if (err.stdout) console.log(err.stdout); + if (err.stderr) console.log(err.stderr); + process.exit(1); + } + + console.log(''); + console.log('Package is installable. Safe to publish.'); + process.exit(0); +} finally { + if (installDir && existsSync(installDir)) { + rmSync(installDir, { recursive: true, force: true }); + } + if (tarball && existsSync(tarball)) { + rmSync(tarball, { force: true }); + } +} diff --git a/src/cli.ts b/src/cli.ts index 83f8b4de9..2d7b42f26 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,6 +19,7 @@ import { getPiDefaultModelAndProvider, migratePiCredentials } from './pi-migrati import { shouldRunOnboarding, runOnboarding } from './onboarding.js' import chalk from 'chalk' import { checkForUpdates } from './update-check.js' +import { printHelp } from './help-text.js' // --------------------------------------------------------------------------- // Minimal CLI arg parser — detects print/subagent mode flags @@ -79,22 +80,7 @@ function parseCliArgs(argv: string[]): CliFlags { process.stdout.write((process.env.GSD_VERSION || '0.0.0') + '\n') process.exit(0) } else if (arg === '--help' || arg === '-h') { - process.stdout.write(`GSD v${process.env.GSD_VERSION || '0.0.0'} — Get Shit Done\n\n`) - process.stdout.write('Usage: gsd [options] [message...]\n\n') - process.stdout.write('Options:\n') - process.stdout.write(' --mode Output mode (default: interactive)\n') - process.stdout.write(' --print, -p Single-shot print mode\n') - process.stdout.write(' --continue, -c Resume the most recent session\n') - process.stdout.write(' --model Override model (e.g. claude-opus-4-6)\n') - process.stdout.write(' --no-session Disable session persistence\n') - process.stdout.write(' --extension Load additional extension\n') - process.stdout.write(' --tools Restrict available tools\n') - process.stdout.write(' --list-models [search] List available models and exit\n') - process.stdout.write(' --version, -v Print version and exit\n') - process.stdout.write(' --help, -h Print this help and exit\n') - process.stdout.write('\nSubcommands:\n') - process.stdout.write(' config Re-run the setup wizard\n') - process.stdout.write(' update Update GSD to the latest version\n') + printHelp(process.env.GSD_VERSION || '0.0.0') process.exit(0) } else if (!arg.startsWith('--') && !arg.startsWith('-')) { flags.messages.push(arg) diff --git a/src/help-text.ts b/src/help-text.ts new file mode 100644 index 000000000..e35b652f2 --- /dev/null +++ b/src/help-text.ts @@ -0,0 +1,18 @@ +export function printHelp(version: string): void { + process.stdout.write(`GSD v${version} — Get Shit Done\n\n`) + process.stdout.write('Usage: gsd [options] [message...]\n\n') + process.stdout.write('Options:\n') + process.stdout.write(' --mode Output mode (default: interactive)\n') + process.stdout.write(' --print, -p Single-shot print mode\n') + process.stdout.write(' --continue, -c Resume the most recent session\n') + process.stdout.write(' --model Override model (e.g. claude-opus-4-6)\n') + process.stdout.write(' --no-session Disable session persistence\n') + process.stdout.write(' --extension Load additional extension\n') + process.stdout.write(' --tools Restrict available tools\n') + process.stdout.write(' --list-models [search] List available models and exit\n') + process.stdout.write(' --version, -v Print version and exit\n') + process.stdout.write(' --help, -h Print this help and exit\n') + process.stdout.write('\nSubcommands:\n') + process.stdout.write(' config Re-run the setup wizard\n') + process.stdout.write(' update Update GSD to the latest version\n') +} diff --git a/src/loader.ts b/src/loader.ts index 5bf3e5611..9d6b4ca50 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -28,22 +28,8 @@ if (firstArg === '--help' || firstArg === '-h') { const pkg = JSON.parse(readFileSync(join(gsdRoot, 'package.json'), 'utf-8')) version = pkg.version || version } catch { /* ignore */ } - process.stdout.write(`GSD v${version} — Get Shit Done\n\n`) - process.stdout.write('Usage: gsd [options] [message...]\n\n') - process.stdout.write('Options:\n') - process.stdout.write(' --mode Output mode (default: interactive)\n') - process.stdout.write(' --print, -p Single-shot print mode\n') - process.stdout.write(' --continue, -c Resume the most recent session\n') - process.stdout.write(' --model Override model (e.g. claude-opus-4-6)\n') - process.stdout.write(' --no-session Disable session persistence\n') - process.stdout.write(' --extension Load additional extension\n') - process.stdout.write(' --tools Restrict available tools\n') - process.stdout.write(' --list-models [search] List available models and exit\n') - process.stdout.write(' --version, -v Print version and exit\n') - process.stdout.write(' --help, -h Print this help and exit\n') - process.stdout.write('\nSubcommands:\n') - process.stdout.write(' config Re-run the setup wizard\n') - process.stdout.write(' update Update GSD to the latest version\n') + const { printHelp } = await import('./help-text.js') + printHelp(version) process.exit(0) } From 4af3e5b741c5c52ede6ea6dbfb09fed408aa64f1 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 16 Mar 2026 13:31:15 -0500 Subject: [PATCH 4/7] fix: move @types/mime-types to devDependencies, align chalk versions - Move @types/mime-types from dependencies to devDependencies in pi-tui (type declarations are only needed at compile time) - Align chalk version: upgrade root from ^5.5.0 to ^5.6.2 to match pi-ai and avoid version skew --- package-lock.json | 6 ++++-- package.json | 2 +- packages/pi-tui/package.json | 4 +++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0daf95dc0..eb60bc755 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "@types/mime-types": "^2.1.4", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", - "chalk": "^5.5.0", + "chalk": "^5.6.2", "diff": "^8.0.2", "extract-zip": "^2.0.1", "file-type": "^21.1.1", @@ -4444,12 +4444,14 @@ "name": "@gsd/pi-tui", "version": "0.57.1", "dependencies": { - "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", "get-east-asian-width": "^1.3.0", "marked": "^15.0.12", "mime-types": "^3.0.1" }, + "devDependencies": { + "@types/mime-types": "^2.1.4" + }, "optionalDependencies": { "koffi": "^2.9.0" } diff --git a/package.json b/package.json index 1b67d91f8..3d3988c2a 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "@types/mime-types": "^2.1.4", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", - "chalk": "^5.5.0", + "chalk": "^5.6.2", "diff": "^8.0.2", "extract-zip": "^2.0.1", "file-type": "^21.1.1", diff --git a/packages/pi-tui/package.json b/packages/pi-tui/package.json index c5611eff7..c6e52babb 100644 --- a/packages/pi-tui/package.json +++ b/packages/pi-tui/package.json @@ -9,12 +9,14 @@ "build": "tsc -p tsconfig.json" }, "dependencies": { - "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", "get-east-asian-width": "^1.3.0", "marked": "^15.0.12", "mime-types": "^3.0.1" }, + "devDependencies": { + "@types/mime-types": "^2.1.4" + }, "optionalDependencies": { "koffi": "^2.9.0" } From d41338cafb16279152abb30033e2c3cf5f218e4b Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 16 Mar 2026 13:34:05 -0500 Subject: [PATCH 5/7] refactor: extract inline build scripts from package.json to files - Extract copy-resources, copy-themes, copy-export-html from root package.json inline node -e commands to proper .cjs script files - Extract pi-coding-agent copy-assets (356-char inline command) to scripts/copy-assets.cjs with readable multi-line formatting - All scripts use .cjs extension for CommonJS compatibility in ESM package context --- package.json | 6 ++--- packages/pi-coding-agent/package.json | 2 +- .../pi-coding-agent/scripts/copy-assets.cjs | 24 +++++++++++++++++++ scripts/copy-export-html.cjs | 6 +++++ scripts/copy-resources.cjs | 4 ++++ scripts/copy-themes.cjs | 6 +++++ 6 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 packages/pi-coding-agent/scripts/copy-assets.cjs create mode 100644 scripts/copy-export-html.cjs create mode 100644 scripts/copy-resources.cjs create mode 100644 scripts/copy-themes.cjs diff --git a/package.json b/package.json index 3d3988c2a..672aa0ed5 100644 --- a/package.json +++ b/package.json @@ -44,9 +44,9 @@ "build:native-pkg": "npm run build -w @gsd/native", "build:pi": "npm run build:native-pkg && npm run build:pi-tui && npm run build:pi-ai && npm run build:pi-agent-core && npm run build:pi-coding-agent", "build": "npm run build:pi && tsc && npm run copy-resources && npm run copy-themes && npm run copy-export-html", - "copy-resources": "node -e \"const{cpSync,rmSync}=require('fs');rmSync('dist/resources',{recursive:true,force:true});cpSync('src/resources','dist/resources',{recursive:true,force:true})\"", - "copy-themes": "node -e \"const{mkdirSync,cpSync}=require('fs');const{resolve}=require('path');const src=resolve(__dirname,'packages/pi-coding-agent/dist/modes/interactive/theme');mkdirSync('pkg/dist/modes/interactive/theme',{recursive:true});cpSync(src,'pkg/dist/modes/interactive/theme',{recursive:true})\"", - "copy-export-html": "node -e \"const{mkdirSync,cpSync}=require('fs');const{resolve}=require('path');const src=resolve(__dirname,'packages/pi-coding-agent/dist/core/export-html');mkdirSync('pkg/dist/core/export-html',{recursive:true});cpSync(src,'pkg/dist/core/export-html',{recursive:true})\"", + "copy-resources": "node scripts/copy-resources.cjs", + "copy-themes": "node scripts/copy-themes.cjs", + "copy-export-html": "node scripts/copy-export-html.cjs", "test:unit": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts", "test:coverage": "c8 --reporter=text --reporter=lcov --exclude='src/resources/extensions/gsd/tests/**' --exclude='src/tests/**' --exclude='scripts/**' --exclude='native/**' --exclude='node_modules/**' --check-coverage --statements=40 --lines=40 --branches=0 --functions=0 node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts", "test:integration": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*integration*.test.ts src/tests/integration/*.test.ts", diff --git a/packages/pi-coding-agent/package.json b/packages/pi-coding-agent/package.json index e31a8a095..3db502944 100644 --- a/packages/pi-coding-agent/package.json +++ b/packages/pi-coding-agent/package.json @@ -21,7 +21,7 @@ }, "scripts": { "build": "tsc -p tsconfig.json && npm run copy-assets", - "copy-assets": "node -e \"const{mkdirSync,cpSync}=require('fs');mkdirSync('dist/modes/interactive/theme',{recursive:true});cpSync('src/modes/interactive/theme','dist/modes/interactive/theme',{recursive:true,filter:(s)=>!s.endsWith('.ts')});mkdirSync('dist/core/export-html/vendor',{recursive:true});cpSync('src/core/export-html/template.html','dist/core/export-html/template.html');cpSync('src/core/export-html/template.css','dist/core/export-html/template.css');cpSync('src/core/export-html/template.js','dist/core/export-html/template.js');cpSync('src/core/export-html/vendor','dist/core/export-html/vendor',{recursive:true,filter:(s)=>!s.endsWith('.ts')});mkdirSync('dist/core/lsp',{recursive:true});cpSync('src/core/lsp/defaults.json','dist/core/lsp/defaults.json');cpSync('src/core/lsp/lsp.md','dist/core/lsp/lsp.md')\"" + "copy-assets": "node scripts/copy-assets.cjs" }, "dependencies": { "@mariozechner/jiti": "^2.6.2", diff --git a/packages/pi-coding-agent/scripts/copy-assets.cjs b/packages/pi-coding-agent/scripts/copy-assets.cjs new file mode 100644 index 000000000..fe331539e --- /dev/null +++ b/packages/pi-coding-agent/scripts/copy-assets.cjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node +const { mkdirSync, cpSync } = require('fs'); + +// Theme assets +mkdirSync('dist/modes/interactive/theme', { recursive: true }); +cpSync('src/modes/interactive/theme', 'dist/modes/interactive/theme', { + recursive: true, + filter: (s) => !s.endsWith('.ts'), +}); + +// Export HTML templates and vendor files +mkdirSync('dist/core/export-html/vendor', { recursive: true }); +cpSync('src/core/export-html/template.html', 'dist/core/export-html/template.html'); +cpSync('src/core/export-html/template.css', 'dist/core/export-html/template.css'); +cpSync('src/core/export-html/template.js', 'dist/core/export-html/template.js'); +cpSync('src/core/export-html/vendor', 'dist/core/export-html/vendor', { + recursive: true, + filter: (s) => !s.endsWith('.ts'), +}); + +// LSP defaults +mkdirSync('dist/core/lsp', { recursive: true }); +cpSync('src/core/lsp/defaults.json', 'dist/core/lsp/defaults.json'); +cpSync('src/core/lsp/lsp.md', 'dist/core/lsp/lsp.md'); diff --git a/scripts/copy-export-html.cjs b/scripts/copy-export-html.cjs new file mode 100644 index 000000000..97fb728be --- /dev/null +++ b/scripts/copy-export-html.cjs @@ -0,0 +1,6 @@ +#!/usr/bin/env node +const { mkdirSync, cpSync } = require('fs'); +const { resolve } = require('path'); +const src = resolve(__dirname, '..', 'packages', 'pi-coding-agent', 'dist', 'core', 'export-html'); +mkdirSync('pkg/dist/core/export-html', { recursive: true }); +cpSync(src, 'pkg/dist/core/export-html', { recursive: true }); diff --git a/scripts/copy-resources.cjs b/scripts/copy-resources.cjs new file mode 100644 index 000000000..62e2d6812 --- /dev/null +++ b/scripts/copy-resources.cjs @@ -0,0 +1,4 @@ +#!/usr/bin/env node +const { cpSync, rmSync } = require('fs'); +rmSync('dist/resources', { recursive: true, force: true }); +cpSync('src/resources', 'dist/resources', { recursive: true, force: true }); diff --git a/scripts/copy-themes.cjs b/scripts/copy-themes.cjs new file mode 100644 index 000000000..05b443d5e --- /dev/null +++ b/scripts/copy-themes.cjs @@ -0,0 +1,6 @@ +#!/usr/bin/env node +const { mkdirSync, cpSync } = require('fs'); +const { resolve } = require('path'); +const src = resolve(__dirname, '..', 'packages', 'pi-coding-agent', 'dist', 'modes', 'interactive', 'theme'); +mkdirSync('pkg/dist/modes/interactive/theme', { recursive: true }); +cpSync(src, 'pkg/dist/modes/interactive/theme', { recursive: true }); From ebbcbe363afb64b1606e20d9368c22b0e3ab96d3 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 16 Mar 2026 13:35:48 -0500 Subject: [PATCH 6/7] security: add SSRF protection to fetch_page tool Block requests to private/internal addresses in the fetch_page tool: - Private IP ranges (10.x, 172.16-31.x, 192.168.x, 127.x, 169.254.x) - Cloud metadata endpoints (metadata.google.internal, instance-data) - localhost - Non-HTTP protocols (file://, ftp://) - IPv6 private ranges (::1, fc00:, fd, fe80:) Add isBlockedUrl() to url-utils.ts with 11 new tests covering all blocked and allowed URL patterns. --- .../search-the-web/tool-fetch-page.ts | 10 +++- .../extensions/search-the-web/url-utils.ts | 36 ++++++++++- src/tests/url-utils.test.ts | 59 +++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 src/tests/url-utils.test.ts diff --git a/src/resources/extensions/search-the-web/tool-fetch-page.ts b/src/resources/extensions/search-the-web/tool-fetch-page.ts index 6e42d5415..0934af736 100644 --- a/src/resources/extensions/search-the-web/tool-fetch-page.ts +++ b/src/resources/extensions/search-the-web/tool-fetch-page.ts @@ -15,7 +15,7 @@ import { Type } from "@sinclair/typebox"; import { LRUTTLCache } from "./cache.js"; import { fetchSimple, HttpError } from "./http.js"; -import { extractDomain } from "./url-utils.js"; +import { extractDomain, isBlockedUrl } from "./url-utils.js"; import { formatPageContent, type FormatPageOptions } from "./format.js"; import { getOllamaApiKey } from "./provider.js"; @@ -416,6 +416,14 @@ export function registerFetchPageTool(pi: ExtensionAPI) { }; } + if (isBlockedUrl(url)) { + return { + content: [{ type: "text", text: `Blocked URL: requests to private/internal addresses are not allowed.` }], + isError: true, + details: { error: "SSRF blocked", url } satisfies Partial, + }; + } + // ------------------------------------------------------------------ // Cache lookup (full content cached, offset/truncation applied after) // ------------------------------------------------------------------ diff --git a/src/resources/extensions/search-the-web/url-utils.ts b/src/resources/extensions/search-the-web/url-utils.ts index eda74be4f..24b3caedd 100644 --- a/src/resources/extensions/search-the-web/url-utils.ts +++ b/src/resources/extensions/search-the-web/url-utils.ts @@ -1,7 +1,41 @@ /** - * URL normalization and query utilities. + * URL normalization, query utilities, and SSRF protection. */ +const BLOCKED_HOSTNAMES = new Set([ + "localhost", + "metadata.google.internal", + "instance-data", +]); + +const PRIVATE_IP_PATTERNS = [ + /^127\./, + /^10\./, + /^172\.(1[6-9]|2\d|3[01])\./, + /^192\.168\./, + /^169\.254\./, + /^0\./, + /^::1$/, + /^fc00:/i, + /^fd/i, + /^fe80:/i, +]; + +export function isBlockedUrl(url: string): boolean { + try { + const parsed = new URL(url); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return true; + const hostname = parsed.hostname.toLowerCase(); + if (BLOCKED_HOSTNAMES.has(hostname)) return true; + for (const pattern of PRIVATE_IP_PATTERNS) { + if (pattern.test(hostname)) return true; + } + return false; + } catch { + return true; + } +} + /** Normalize a search query into a stable cache key. */ export function normalizeQuery(query: string): string { return query.trim().toLowerCase().replace(/\s+/g, " ").normalize("NFC"); diff --git a/src/tests/url-utils.test.ts b/src/tests/url-utils.test.ts new file mode 100644 index 000000000..c73b359a7 --- /dev/null +++ b/src/tests/url-utils.test.ts @@ -0,0 +1,59 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { isBlockedUrl } from "../resources/extensions/search-the-web/url-utils.ts"; + +describe("isBlockedUrl — SSRF protection", () => { + it("blocks localhost", () => { + assert.equal(isBlockedUrl("http://localhost/admin"), true); + assert.equal(isBlockedUrl("http://localhost:8080/"), true); + }); + + it("blocks 127.0.0.0/8", () => { + assert.equal(isBlockedUrl("http://127.0.0.1/"), true); + assert.equal(isBlockedUrl("http://127.0.0.2:3000/path"), true); + }); + + it("blocks 10.0.0.0/8 (private)", () => { + assert.equal(isBlockedUrl("http://10.0.0.1/"), true); + assert.equal(isBlockedUrl("http://10.255.255.255/"), true); + }); + + it("blocks 172.16-31.x.x (private)", () => { + assert.equal(isBlockedUrl("http://172.16.0.1/"), true); + assert.equal(isBlockedUrl("http://172.31.255.255/"), true); + }); + + it("blocks 192.168.x.x (private)", () => { + assert.equal(isBlockedUrl("http://192.168.1.1/"), true); + assert.equal(isBlockedUrl("http://192.168.0.100:9200/"), true); + }); + + it("blocks 169.254.x.x (link-local / cloud metadata)", () => { + assert.equal(isBlockedUrl("http://169.254.169.254/latest/meta-data/"), true); + }); + + it("blocks cloud metadata hostnames", () => { + assert.equal(isBlockedUrl("http://metadata.google.internal/computeMetadata/"), true); + }); + + it("blocks non-http protocols", () => { + assert.equal(isBlockedUrl("file:///etc/passwd"), true); + assert.equal(isBlockedUrl("ftp://internal.server/data"), true); + }); + + it("blocks invalid URLs", () => { + assert.equal(isBlockedUrl("not-a-url"), true); + assert.equal(isBlockedUrl(""), true); + }); + + it("allows public URLs", () => { + assert.equal(isBlockedUrl("https://example.com"), false); + assert.equal(isBlockedUrl("https://api.github.com/repos"), false); + assert.equal(isBlockedUrl("http://docs.python.org/3/"), false); + }); + + it("allows public IPs", () => { + assert.equal(isBlockedUrl("http://8.8.8.8/"), false); + assert.equal(isBlockedUrl("https://1.1.1.1/"), false); + }); +}); From 6a46c9df1aa5156bff6f91d4602307c2cf51a7a0 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 16 Mar 2026 13:39:14 -0500 Subject: [PATCH 7/7] fix: resolve browser-tools TypeScript type errors in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix type compatibility issues introduced in the JS→TS conversion: - Restore PageEntry.page to `any` (holds Playwright Page instance) - Use Record for session parameters in buildFailureHypothesis and summarizeBrowserSession (callers pass rich objects with extra properties) - Use Record for formatTimelineEntries options - Add explicit type annotations to local variables and callbacks to satisfy noImplicitAny in tsconfig.extensions.json --- .../extensions/browser-tools/core.ts | 53 +++++++------------ 1 file changed, 20 insertions(+), 33 deletions(-) diff --git a/src/resources/extensions/browser-tools/core.ts b/src/resources/extensions/browser-tools/core.ts index 7cb654361..7fd031fd5 100644 --- a/src/resources/extensions/browser-tools/core.ts +++ b/src/resources/extensions/browser-tools/core.ts @@ -73,7 +73,7 @@ export interface PageRegistry { export interface PageEntry { id: number; - page: unknown; + page: any; title: string; url: string; opener: number | null; @@ -960,9 +960,9 @@ function uniqueStrings(values: (string | undefined)[]): string[] { return [...new Set(values.filter(Boolean))] as string[]; } -export function formatTimelineEntries(entries: ActionEntry[] = [], options: { retained?: number; totalRecorded?: number } = {}): FormattedTimeline { - const retained = options.retained ?? entries.length; - const totalRecorded = options.totalRecorded ?? retained; +export function formatTimelineEntries(entries: ActionEntry[] = [], options: Record = {}): FormattedTimeline { + const retained = (options.retained as number) ?? entries.length; + const totalRecorded = (options.totalRecorded as number) ?? retained; const bounded = totalRecorded > retained; if (!entries.length) { @@ -1019,14 +1019,7 @@ export function formatTimelineEntries(entries: ActionEntry[] = [], options: { re // Failure Hypothesis // --------------------------------------------------------------------------- -interface SessionForHypothesis { - actionTimeline?: { entries: ActionEntry[] }; - consoleEntries?: Array<{ type?: string; text?: string; message?: string }>; - networkEntries?: Array<{ url?: string; status?: number; failed?: boolean }>; - dialogEntries?: Array<{ type?: string; message?: string }>; -} - -export function buildFailureHypothesis(session: SessionForHypothesis = {}): FailureHypothesis { +export function buildFailureHypothesis(session: Record = {}): FailureHypothesis { const timelineEntries = session.actionTimeline?.entries ?? []; const consoleEntries = session.consoleEntries ?? []; const networkEntries = session.networkEntries ?? []; @@ -1103,36 +1096,30 @@ export function buildFailureHypothesis(session: SessionForHypothesis = {}): Fail // Session Summary // --------------------------------------------------------------------------- -interface SessionForSummary extends SessionForHypothesis { - retainedActionCount?: number; - totalActionCount?: number; - pages?: Array<{ id?: number; title?: string; url?: string; isActive?: boolean }>; -} - -export function summarizeBrowserSession(session: SessionForSummary = {}): SessionSummary { +export function summarizeBrowserSession(session: Record = {}): SessionSummary { const actionTimeline = session.actionTimeline ?? { limit: 0, entries: [] as ActionEntry[] }; - const actionEntries = actionTimeline.entries ?? []; - const retainedActionCount = session.retainedActionCount ?? actionEntries.length; - const totalActionCount = session.totalActionCount ?? retainedActionCount; - const pages = session.pages ?? []; - const consoleEntries = session.consoleEntries ?? []; - const networkEntries = session.networkEntries ?? []; - const dialogEntries = session.dialogEntries ?? []; + const actionEntries: ActionEntry[] = actionTimeline.entries ?? []; + const retainedActionCount: number = session.retainedActionCount ?? actionEntries.length; + const totalActionCount: number = session.totalActionCount ?? retainedActionCount; + const pages: Array> = session.pages ?? []; + const consoleEntries: Array> = session.consoleEntries ?? []; + const networkEntries: Array> = session.networkEntries ?? []; + const dialogEntries: Array> = session.dialogEntries ?? []; const actionStatusCounts = actionEntries.reduce( - (acc, entry) => { + (acc: Record, entry: ActionEntry) => { const status = summarizeActionStatus(entry.status); acc[status] = (acc[status] ?? 0) + 1; return acc; }, - { success: 0, error: 0, running: 0 } as Record, + { success: 0, error: 0, running: 0 }, ); - const waitEntries = actionEntries.filter((entry) => entry.tool === "browser_wait_for"); - const assertEntries = actionEntries.filter((entry) => entry.tool === "browser_assert"); - const consoleErrors = consoleEntries.filter((entry) => entry.type === "error" || entry.type === "pageerror"); - const failedRequests = networkEntries.filter((entry) => entry.failed || (typeof entry.status === "number" && entry.status >= 400)); - const activePage = pages.find((page) => page.isActive) ?? pages[0] ?? null; + const waitEntries = actionEntries.filter((entry: ActionEntry) => entry.tool === "browser_wait_for"); + const assertEntries = actionEntries.filter((entry: ActionEntry) => entry.tool === "browser_assert"); + const consoleErrors = consoleEntries.filter((entry: Record) => entry.type === "error" || entry.type === "pageerror"); + const failedRequests = networkEntries.filter((entry: Record) => entry.failed || (typeof entry.status === "number" && entry.status >= 400)); + const activePage = pages.find((page: Record) => page.isActive) ?? pages[0] ?? null; const caveats: string[] = []; if (totalActionCount > retainedActionCount) {