fix: Phase 1 quick wins — bug fixes, security hardening, and performance
- 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
This commit is contained in:
parent
26facfca51
commit
2c926c12e3
12 changed files with 504 additions and 56 deletions
|
|
@ -6,8 +6,11 @@ export type { SlashCommandInfo, SlashCommandLocation, SlashCommandSource } from
|
|||
export {
|
||||
createExtensionRuntime,
|
||||
discoverAndLoadExtensions,
|
||||
getUntrustedExtensionPaths,
|
||||
isProjectTrusted,
|
||||
loadExtensionFromFactory,
|
||||
loadExtensions,
|
||||
trustProject,
|
||||
} from "./loader.js";
|
||||
export type {
|
||||
ExtensionErrorListener,
|
||||
|
|
|
|||
141
packages/pi-coding-agent/src/core/extensions/loader.test.ts
Normal file
141
packages/pi-coding-agent/src/core/extensions/loader.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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<string>): 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;
|
||||
}
|
||||
132
packages/pi-coding-agent/src/core/resolve-config-value.test.ts
Normal file
132
packages/pi-coding-agent/src/core/resolve-config-value.test.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,19 @@ import { execSync } from "child_process";
|
|||
// Cache for shell command results (persists for process lifetime)
|
||||
const commandResultCache = new Map<string, string | undefined>();
|
||||
|
||||
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, {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -46,6 +46,7 @@ export interface JobRow {
|
|||
export class MemoryStorage {
|
||||
private db: SqlJsDatabase;
|
||||
private dbPath: string;
|
||||
private persistTimer: ReturnType<typeof setTimeout> | 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string | undefined> = {};
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue