Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
1041 lines
46 KiB
JavaScript
1041 lines
46 KiB
JavaScript
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const path = require("node:path");
|
|
const fs = require("node:fs");
|
|
const { spawnSync } = require("node:child_process");
|
|
const tempDirBridge = require("./tempDirBridge.cjs");
|
|
|
|
function isolatedTempChildEnv(root, home, extra = {}) {
|
|
return {
|
|
...process.env,
|
|
TMPDIR: root,
|
|
TEMP: root,
|
|
TMP: root,
|
|
HOME: home,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
test("getTempFilePath is unique for duplicate names in the same millisecond", () => {
|
|
const originalNow = Date.now;
|
|
Date.now = () => 1234567890;
|
|
try {
|
|
const first = tempDirBridge.getTempFilePath("upload.txt");
|
|
const second = tempDirBridge.getTempFilePath("upload.txt");
|
|
|
|
assert.notEqual(first, second);
|
|
assert.equal(path.basename(first).endsWith("_upload.txt"), true);
|
|
assert.equal(path.basename(second).endsWith("_upload.txt"), true);
|
|
} finally {
|
|
Date.now = originalNow;
|
|
}
|
|
});
|
|
|
|
test("Netcatty temp root is a private directory owned by the current user", () => {
|
|
const tempRoot = tempDirBridge.getTempDir();
|
|
const stat = fs.lstatSync(tempRoot);
|
|
assert.equal(stat.isDirectory(), true);
|
|
assert.equal(stat.isSymbolicLink(), false);
|
|
assert.equal(stat.mode & 0o777, 0o700);
|
|
if (typeof process.getuid === "function") assert.equal(stat.uid, process.getuid());
|
|
assert.equal(tempDirBridge.getTempDir(), tempRoot);
|
|
});
|
|
|
|
test("shared system temp roots resolve to a stable path under the user's home", async () => {
|
|
const root = await fs.promises.mkdtemp(path.join(require("node:os").tmpdir(), "netcatty-shared-root-"));
|
|
const fakeHome = path.join(root, "home");
|
|
await fs.promises.mkdir(fakeHome);
|
|
await fs.promises.chmod(root, 0o777);
|
|
try {
|
|
if (typeof process.getuid === "function") {
|
|
assert.equal(tempDirBridge.resolvePrivateTempDir(root, fakeHome), path.join(fakeHome, ".netcatty", "tmp", "Netcatty"));
|
|
}
|
|
} finally {
|
|
await fs.promises.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("cached temp root is recreated after OS cleanup", async () => {
|
|
const root = await fs.promises.mkdtemp(path.join(require("node:os").tmpdir(), "netcatty-private-root-"));
|
|
const fakeHome = path.join(root, "home");
|
|
await fs.promises.mkdir(fakeHome);
|
|
await fs.promises.chmod(root, 0o700);
|
|
try {
|
|
const script = [
|
|
'const fs = require("node:fs");',
|
|
'const bridge = require("./electron/bridges/tempDirBridge.cjs");',
|
|
'const first = bridge.getTempDir();',
|
|
'fs.rmSync(first, { recursive: true, force: true });',
|
|
'const second = bridge.getTempDir();',
|
|
'if (first !== second || !fs.statSync(second).isDirectory()) process.exit(2);',
|
|
].join("\n");
|
|
const result = spawnSync(process.execPath, ["-e", script], {
|
|
cwd: path.resolve(__dirname, "../.."),
|
|
env: isolatedTempChildEnv(root, fakeHome),
|
|
encoding: "utf8",
|
|
});
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
} finally {
|
|
await fs.promises.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("cached temp root is rebound after inode replacement", async () => {
|
|
const root = await fs.promises.mkdtemp(path.join(require("node:os").tmpdir(), "netcatty-replaced-root-"));
|
|
const fakeHome = path.join(root, "home");
|
|
await fs.promises.mkdir(fakeHome);
|
|
await fs.promises.chmod(root, 0o700);
|
|
try {
|
|
const script = [
|
|
'const fs = require("node:fs");',
|
|
'const path = require("node:path");',
|
|
'const bridge = require("./electron/bridges/tempDirBridge.cjs");',
|
|
'const first = bridge.getTempDir();',
|
|
'const oldPath = `${first}.old`;',
|
|
'fs.renameSync(first, oldPath);',
|
|
'fs.mkdirSync(first, { recursive: true, mode: 0o700 });',
|
|
'const oldStat = fs.lstatSync(oldPath);',
|
|
'const second = bridge.getTempDir();',
|
|
'const newStat = fs.lstatSync(second);',
|
|
'if (!newStat.isDirectory()) process.exit(2);',
|
|
'if (oldStat.dev === newStat.dev && oldStat.ino === newStat.ino) process.exit(4);',
|
|
'const downloadPath = bridge.getTempFilePath("download.bin");',
|
|
'const relative = path.relative(second, downloadPath);',
|
|
'if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) process.exit(3);',
|
|
].join("\n");
|
|
const result = spawnSync(process.execPath, ["-e", script], {
|
|
cwd: path.resolve(__dirname, "../.."),
|
|
env: isolatedTempChildEnv(root, fakeHome),
|
|
encoding: "utf8",
|
|
});
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
} finally {
|
|
await fs.promises.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("tool output signing key reloads when the temp root inode is replaced", async () => {
|
|
const root = await fs.promises.mkdtemp(path.join(require("node:os").tmpdir(), "netcatty-rebind-key-"));
|
|
const fakeHome = path.join(root, "home");
|
|
await fs.promises.mkdir(fakeHome);
|
|
await fs.promises.chmod(root, 0o700);
|
|
const script = [
|
|
'const fs = require("node:fs");',
|
|
'const path = require("node:path");',
|
|
'const bridge = require("./electron/bridges/tempDirBridge.cjs");',
|
|
'const handlers = new Map();',
|
|
'const secret = process.env.FAKE_SAFE_STORAGE_SECRET;',
|
|
'const safeStorage = {',
|
|
' isEncryptionAvailable: () => true,',
|
|
' encryptString: value => Buffer.from(`${secret}:${value}`, "utf8"),',
|
|
' decryptString: value => {',
|
|
' const text = value.toString("utf8");',
|
|
' if (!text.startsWith(`${secret}:`)) throw new Error("wrong key");',
|
|
' return text.slice(secret.length + 1);',
|
|
' },',
|
|
'};',
|
|
'bridge.registerHandlers({ handle: (channel, handler) => handlers.set(channel, handler) }, undefined, { safeStorage });',
|
|
'(async () => {',
|
|
' if (process.env.PHASE === "write") {',
|
|
' const first = bridge.getTempDir();',
|
|
' const now = Date.now();',
|
|
' const before = await handlers.get("netcatty:tempdir:toolOutputWrite")({}, {',
|
|
' record: { schemaVersion: 1, handleId: "before-rebind", chatSessionId: "rebind-chat", capabilityId: "terminal.execute", totalChars: 6, storedChars: 6, sourceTruncated: false, preview: "before", storedAt: now, accessedAt: now },',
|
|
' content: "before",',
|
|
' });',
|
|
' if (!before.ok) throw new Error(before.error);',
|
|
' const oldPath = `${first}.old`;',
|
|
' fs.renameSync(first, oldPath);',
|
|
' fs.mkdirSync(first, { recursive: true, mode: 0o700 });',
|
|
' const replacementKey = Buffer.alloc(32, 7);',
|
|
' fs.writeFileSync(path.join(first, ".tool-output-signing-key"), safeStorage.encryptString(replacementKey.toString("base64")), { mode: 0o600 });',
|
|
' bridge.getTempDir();',
|
|
' const after = await handlers.get("netcatty:tempdir:toolOutputWrite")({}, {',
|
|
' record: { schemaVersion: 1, handleId: "after-rebind", chatSessionId: "rebind-chat", capabilityId: "terminal.execute", totalChars: 5, storedChars: 5, sourceTruncated: false, preview: "after", storedAt: now, accessedAt: now },',
|
|
' content: "after",',
|
|
' });',
|
|
' console.log(`RESULT:${JSON.stringify(after)}`);',
|
|
' return;',
|
|
' }',
|
|
' const restored = await handlers.get("netcatty:tempdir:toolOutputRestore")({}, { handleId: "after-rebind", chatSessionId: "rebind-chat" });',
|
|
' const content = restored ? await handlers.get("netcatty:tempdir:toolOutputRead")({}, { path: restored.path }) : null;',
|
|
' console.log(`RESULT:${JSON.stringify({ restored: Boolean(restored), content })}`);',
|
|
'})().catch(error => { console.error(error); process.exit(1); });',
|
|
].join("\n");
|
|
const run = phase => spawnSync(process.execPath, ["-e", script], {
|
|
cwd: path.resolve(__dirname, "../.."),
|
|
env: isolatedTempChildEnv(root, fakeHome, {
|
|
PHASE: phase,
|
|
FAKE_SAFE_STORAGE_SECRET: "rebind-secret",
|
|
}),
|
|
encoding: "utf8",
|
|
});
|
|
try {
|
|
const written = run("write");
|
|
assert.equal(written.status, 0, written.stderr || written.stdout);
|
|
assert.match(written.stdout, /RESULT:.*"ok":true/);
|
|
const reopened = run("read");
|
|
assert.equal(reopened.status, 0, reopened.stderr || reopened.stdout);
|
|
assert.match(reopened.stdout, /RESULT:.*"restored":true.*"content":"after"/);
|
|
} finally {
|
|
await fs.promises.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("tool output write retries when the temp root is replaced after key loading starts", async () => {
|
|
const root = await fs.promises.mkdtemp(path.join(require("node:os").tmpdir(), "netcatty-rebind-write-"));
|
|
const fakeHome = path.join(root, "home");
|
|
await fs.promises.mkdir(fakeHome);
|
|
await fs.promises.chmod(root, 0o700);
|
|
const script = [
|
|
'const fs = require("node:fs");',
|
|
'const path = require("node:path");',
|
|
'const bridge = require("./electron/bridges/tempDirBridge.cjs");',
|
|
'const handlers = new Map();',
|
|
'const secret = process.env.FAKE_SAFE_STORAGE_SECRET;',
|
|
'const safeStorage = {',
|
|
' isEncryptionAvailable: () => true,',
|
|
' encryptString: value => Buffer.from(`${secret}:${value}`, "utf8"),',
|
|
' decryptString: value => {',
|
|
' const text = value.toString("utf8");',
|
|
' if (!text.startsWith(`${secret}:`)) throw new Error("wrong key");',
|
|
' return text.slice(secret.length + 1);',
|
|
' },',
|
|
'};',
|
|
'bridge.registerHandlers({ handle: (channel, handler) => handlers.set(channel, handler) }, undefined, { safeStorage });',
|
|
'(async () => {',
|
|
' const now = Date.now();',
|
|
' const first = bridge.getTempDir();',
|
|
' const before = await handlers.get("netcatty:tempdir:toolOutputWrite")({}, {',
|
|
' record: { schemaVersion: 1, handleId: "before-rebind-write", chatSessionId: "rebind-write-chat", capabilityId: "terminal.execute", totalChars: 6, storedChars: 6, sourceTruncated: false, preview: "before", storedAt: now, accessedAt: now },',
|
|
' content: "before",',
|
|
' });',
|
|
' if (!before.ok) throw new Error(before.error);',
|
|
' const originalOpen = fs.promises.open;',
|
|
' let replaced = false;',
|
|
' fs.promises.open = async (filePath, ...args) => {',
|
|
' const opened = await originalOpen(filePath, ...args);',
|
|
' if (replaced || path.basename(filePath) !== ".tool-output-signing-key") return opened;',
|
|
' const originalRead = opened.read.bind(opened);',
|
|
' opened.read = async (...readArgs) => {',
|
|
' const oldKeyFile = await originalRead(...readArgs);',
|
|
' replaced = true;',
|
|
' const oldPath = `${first}.old`;',
|
|
' fs.renameSync(first, oldPath);',
|
|
' fs.mkdirSync(first, { recursive: true, mode: 0o700 });',
|
|
' const replacementKey = Buffer.alloc(32, 7);',
|
|
' fs.writeFileSync(path.join(first, ".tool-output-signing-key"), safeStorage.encryptString(replacementKey.toString("base64")), { mode: 0o600 });',
|
|
' return oldKeyFile;',
|
|
' };',
|
|
' return opened;',
|
|
' };',
|
|
' bridge.registerHandlers({ handle: (channel, handler) => handlers.set(channel, handler) }, undefined, { safeStorage });',
|
|
' const after = await handlers.get("netcatty:tempdir:toolOutputWrite")({}, {',
|
|
' record: { schemaVersion: 1, handleId: "after-rebind-write", chatSessionId: "rebind-write-chat", capabilityId: "terminal.execute", totalChars: 5, storedChars: 5, sourceTruncated: false, preview: "after", storedAt: now, accessedAt: now },',
|
|
' content: "after",',
|
|
' });',
|
|
' const restored = await handlers.get("netcatty:tempdir:toolOutputRestore")({}, { handleId: "after-rebind-write", chatSessionId: "rebind-write-chat" });',
|
|
' const content = restored ? await handlers.get("netcatty:tempdir:toolOutputRead")({}, { path: restored.path }) : null;',
|
|
' console.log(`RESULT:${JSON.stringify({ write: after, replaced, restored: Boolean(restored), content })}`);',
|
|
'})().catch(error => { console.error(error); process.exit(1); });',
|
|
].join("\n");
|
|
try {
|
|
const result = spawnSync(process.execPath, ["-e", script], {
|
|
cwd: path.resolve(__dirname, "../.."),
|
|
env: isolatedTempChildEnv(root, fakeHome, { FAKE_SAFE_STORAGE_SECRET: "rebind-write-secret" }),
|
|
encoding: "utf8",
|
|
});
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
assert.match(result.stdout, /RESULT:.*"write":\{"ok":true,.*"replaced":true.*"restored":true.*"content":"after"/);
|
|
} finally {
|
|
await fs.promises.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("tool output restore retries when the temp root is replaced during content verification", async () => {
|
|
const root = await fs.promises.mkdtemp(path.join(require("node:os").tmpdir(), "netcatty-rebind-restore-"));
|
|
const fakeHome = path.join(root, "home");
|
|
await fs.promises.mkdir(fakeHome);
|
|
await fs.promises.chmod(root, 0o700);
|
|
const script = [
|
|
'const fs = require("node:fs");',
|
|
'const path = require("node:path");',
|
|
'const crypto = require("node:crypto");',
|
|
'const bridge = require("./electron/bridges/tempDirBridge.cjs");',
|
|
'const handlers = new Map();',
|
|
'const secret = process.env.FAKE_SAFE_STORAGE_SECRET;',
|
|
'const safeStorage = {',
|
|
' isEncryptionAvailable: () => true,',
|
|
' encryptString: value => Buffer.from(`${secret}:${value}`, "utf8"),',
|
|
' decryptString: value => {',
|
|
' const text = value.toString("utf8");',
|
|
' if (!text.startsWith(`${secret}:`)) throw new Error("wrong key");',
|
|
' return text.slice(secret.length + 1);',
|
|
' },',
|
|
'};',
|
|
'bridge.registerHandlers({ handle: (channel, handler) => handlers.set(channel, handler) }, undefined, { safeStorage });',
|
|
'(async () => {',
|
|
' const now = Date.now();',
|
|
' const first = bridge.getTempDir();',
|
|
' const record = { schemaVersion: 1, handleId: "restore-rebind", chatSessionId: "restore-rebind-chat", capabilityId: "terminal.execute", totalChars: 6, storedChars: 6, sourceTruncated: false, preview: "before", storedAt: now, accessedAt: now };',
|
|
' const saved = await handlers.get("netcatty:tempdir:toolOutputWrite")({}, { record, content: "before" });',
|
|
' if (!saved.ok) throw new Error(saved.error);',
|
|
' const originalOpen = fs.promises.open;',
|
|
' let rebound = false;',
|
|
' fs.promises.open = async (filePath, ...args) => {',
|
|
' const opened = await originalOpen(filePath, ...args);',
|
|
' if (rebound || path.resolve(String(filePath)) !== path.resolve(saved.path)) return opened;',
|
|
' rebound = true;',
|
|
' const oldPath = `${first}.old`;',
|
|
' fs.renameSync(first, oldPath);',
|
|
' fs.mkdirSync(first, { recursive: true, mode: 0o700 });',
|
|
' const replacementKey = Buffer.alloc(32, 7);',
|
|
' fs.writeFileSync(path.join(first, ".tool-output-signing-key"), safeStorage.encryptString(replacementKey.toString("base64")), { mode: 0o600 });',
|
|
' bridge.getTempDir();',
|
|
' const contentBuffer = Buffer.from("after", "utf16le");',
|
|
' fs.writeFileSync(saved.path, contentBuffer, { mode: 0o600, flag: "wx" });',
|
|
' const manifest = {',
|
|
' record,',
|
|
' contentFile: path.basename(saved.path),',
|
|
' contentBytes: contentBuffer.length,',
|
|
' contentSha256: crypto.createHash("sha256").update(contentBuffer).digest("hex"),',
|
|
' };',
|
|
' manifest.signature = crypto.createHmac("sha256", replacementKey)',
|
|
' .update(JSON.stringify({ record: manifest.record, contentFile: manifest.contentFile, contentBytes: manifest.contentBytes, contentSha256: manifest.contentSha256 }))',
|
|
' .digest("hex");',
|
|
' fs.writeFileSync(`${saved.path}.meta.json`, JSON.stringify(manifest), { mode: 0o600, flag: "wx" });',
|
|
' return opened;',
|
|
' };',
|
|
' const restored = await handlers.get("netcatty:tempdir:toolOutputRestore")({}, { handleId: "restore-rebind", chatSessionId: "restore-rebind-chat" });',
|
|
' const content = restored ? await handlers.get("netcatty:tempdir:toolOutputRead")({}, { path: restored.path }) : null;',
|
|
' console.log(`RESULT:${JSON.stringify({ rebound, restored: Boolean(restored), content })}`);',
|
|
'})().catch(error => { console.error(error); process.exit(1); });',
|
|
].join("\n");
|
|
try {
|
|
const result = spawnSync(process.execPath, ["-e", script], {
|
|
cwd: path.resolve(__dirname, "../.."),
|
|
env: isolatedTempChildEnv(root, fakeHome, { FAKE_SAFE_STORAGE_SECRET: "rebind-restore-secret" }),
|
|
encoding: "utf8",
|
|
});
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
assert.match(result.stdout, /RESULT:.*"rebound":true.*"restored":true.*"content":"after"/);
|
|
} finally {
|
|
await fs.promises.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("tool output write keeps the loaded key during a temporary keychain failure", async () => {
|
|
const root = await fs.promises.mkdtemp(path.join(require("node:os").tmpdir(), "netcatty-keychain-write-"));
|
|
const fakeHome = path.join(root, "home");
|
|
await fs.promises.mkdir(fakeHome);
|
|
await fs.promises.chmod(root, 0o700);
|
|
const script = [
|
|
'const bridge = require("./electron/bridges/tempDirBridge.cjs");',
|
|
'const handlers = new Map();',
|
|
'const secret = process.env.FAKE_SAFE_STORAGE_SECRET;',
|
|
'let keychainLocked = false;',
|
|
'const safeStorage = {',
|
|
' isEncryptionAvailable: () => true,',
|
|
' encryptString: value => Buffer.from(`${secret}:${value}`, "utf8"),',
|
|
' decryptString: value => {',
|
|
' if (keychainLocked) throw new Error("keychain locked");',
|
|
' const text = value.toString("utf8");',
|
|
' if (!text.startsWith(`${secret}:`)) throw new Error("wrong key");',
|
|
' return text.slice(secret.length + 1);',
|
|
' },',
|
|
'};',
|
|
'bridge.registerHandlers({ handle: (channel, handler) => handlers.set(channel, handler) }, undefined, { safeStorage });',
|
|
'(async () => {',
|
|
' const now = Date.now();',
|
|
' const before = await handlers.get("netcatty:tempdir:toolOutputWrite")({}, {',
|
|
' record: { schemaVersion: 1, handleId: "before-keychain-lock", chatSessionId: "keychain-write-chat", capabilityId: "terminal.execute", totalChars: 6, storedChars: 6, sourceTruncated: false, preview: "before", storedAt: now, accessedAt: now },',
|
|
' content: "before",',
|
|
' });',
|
|
' if (!before.ok) throw new Error(before.error);',
|
|
' keychainLocked = true;',
|
|
' const after = await handlers.get("netcatty:tempdir:toolOutputWrite")({}, {',
|
|
' record: { schemaVersion: 1, handleId: "after-keychain-lock", chatSessionId: "keychain-write-chat", capabilityId: "terminal.execute", totalChars: 5, storedChars: 5, sourceTruncated: false, preview: "after", storedAt: now, accessedAt: now },',
|
|
' content: "after",',
|
|
' });',
|
|
' console.log(`RESULT:${JSON.stringify(after)}`);',
|
|
'})().catch(error => { console.error(error); process.exit(1); });',
|
|
].join("\n");
|
|
try {
|
|
const result = spawnSync(process.execPath, ["-e", script], {
|
|
cwd: path.resolve(__dirname, "../.."),
|
|
env: isolatedTempChildEnv(root, fakeHome, { FAKE_SAFE_STORAGE_SECRET: "keychain-write-secret" }),
|
|
encoding: "utf8",
|
|
});
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
assert.match(result.stdout, /RESULT:.*"ok":true/);
|
|
} finally {
|
|
await fs.promises.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("tool output temp handlers write, read, and delete only Netcatty temp files", async () => {
|
|
const handlers = new Map();
|
|
tempDirBridge.registerHandlers({
|
|
handle(channel, handler) {
|
|
handlers.set(channel, handler);
|
|
},
|
|
});
|
|
|
|
const write = handlers.get("netcatty:tempdir:toolOutputWrite");
|
|
const read = handlers.get("netcatty:tempdir:toolOutputRead");
|
|
const restore = handlers.get("netcatty:tempdir:toolOutputRestore");
|
|
const remove = handlers.get("netcatty:tempdir:toolOutputDelete");
|
|
const record = {
|
|
schemaVersion: 1,
|
|
handleId: "tool-output-1",
|
|
chatSessionId: "chat-1",
|
|
capabilityId: "terminal.execute",
|
|
totalChars: 21,
|
|
storedChars: 21,
|
|
sourceTruncated: false,
|
|
preview: "large terminal output",
|
|
storedAt: Date.now(),
|
|
accessedAt: Date.now(),
|
|
};
|
|
const saved = await write({}, { record, content: "large terminal output" });
|
|
|
|
assert.equal(saved.ok, true);
|
|
assert.deepEqual(await restore({}, { handleId: "tool-output-1", chatSessionId: "chat-other" }), null);
|
|
const restored = await restore({}, { handleId: "tool-output-1", chatSessionId: "chat-1" });
|
|
assert.equal(restored.path, saved.path);
|
|
assert.deepEqual({ ...restored.record, accessedAt: record.accessedAt }, record);
|
|
assert.equal(restored.record.accessedAt >= record.accessedAt, true);
|
|
assert.equal(await read({}, { path: saved.path }), "large terminal output");
|
|
assert.deepEqual(await read({}, {
|
|
path: saved.path,
|
|
request: { mode: "range", offset: 6, maxChars: 8 },
|
|
}), {
|
|
mode: "range",
|
|
content: "terminal",
|
|
totalChars: 21,
|
|
startOffset: 6,
|
|
endOffset: 14,
|
|
nextOffset: 14,
|
|
hasMore: true,
|
|
});
|
|
assert.deepEqual(await remove({}, { path: saved.path }), { ok: true });
|
|
assert.equal(await restore({}, { handleId: "tool-output-1", chatSessionId: "chat-1" }), null);
|
|
assert.equal(await read({}, { path: saved.path }), null);
|
|
assert.deepEqual(await read({}, { path: "/etc/passwd" }), null);
|
|
});
|
|
|
|
test("tool output temp reader rejects symlinks that point outside Netcatty temp", async () => {
|
|
const handlers = new Map();
|
|
tempDirBridge.registerHandlers({ handle(channel, handler) { handlers.set(channel, handler); } });
|
|
const linkPath = tempDirBridge.getTempFilePath("tool-output-link.log");
|
|
await fs.promises.symlink("/etc/hosts", linkPath);
|
|
try {
|
|
assert.equal(await handlers.get("netcatty:tempdir:toolOutputRead")({}, { path: linkPath }), null);
|
|
} finally {
|
|
await fs.promises.unlink(linkPath);
|
|
}
|
|
});
|
|
|
|
test("tool output temp writer rejects output arriving after terminal close", async () => {
|
|
const handlers = new Map();
|
|
tempDirBridge.registerHandlers({ handle(channel, handler) { handlers.set(channel, handler); } });
|
|
const terminalSessionId = "terminal-closed-before-write";
|
|
await handlers.get("netcatty:tempdir:toolOutputDeleteTerminal")({}, { terminalSessionId });
|
|
const now = Date.now();
|
|
const saved = await handlers.get("netcatty:tempdir:toolOutputWrite")({}, {
|
|
record: {
|
|
schemaVersion: 1,
|
|
handleId: "late-terminal-output",
|
|
chatSessionId: "chat-late-terminal",
|
|
capabilityId: "terminal.execute",
|
|
terminalSessionId,
|
|
totalChars: 4,
|
|
storedChars: 4,
|
|
sourceTruncated: false,
|
|
preview: "late",
|
|
storedAt: now,
|
|
accessedAt: now,
|
|
},
|
|
content: "late",
|
|
});
|
|
|
|
assert.equal(saved.ok, false);
|
|
assert.match(saved.error, /already closed/);
|
|
});
|
|
|
|
test("chat deletion wins when a persisted output write is already in flight", async () => {
|
|
const handlers = new Map();
|
|
tempDirBridge.registerHandlers({ handle(channel, handler) { handlers.set(channel, handler); } });
|
|
const chatSessionId = `chat-deleted-during-write-${Date.now()}`;
|
|
const handleId = `write-delete-race-${Date.now()}`;
|
|
const originalWriteFile = fs.promises.writeFile;
|
|
let signalWriteStarted;
|
|
const writeStarted = new Promise(resolve => { signalWriteStarted = resolve; });
|
|
let releaseWrite;
|
|
const writeReleased = new Promise(resolve => { releaseWrite = resolve; });
|
|
fs.promises.writeFile = async (filePath, ...args) => {
|
|
if (String(filePath).endsWith('.log')) {
|
|
signalWriteStarted();
|
|
await writeReleased;
|
|
}
|
|
return originalWriteFile(filePath, ...args);
|
|
};
|
|
|
|
try {
|
|
const now = Date.now();
|
|
const writing = handlers.get("netcatty:tempdir:toolOutputWrite")({}, {
|
|
record: {
|
|
schemaVersion: 1,
|
|
handleId,
|
|
chatSessionId,
|
|
capabilityId: "terminal.execute",
|
|
totalChars: 6,
|
|
storedChars: 6,
|
|
sourceTruncated: false,
|
|
preview: "secret",
|
|
storedAt: now,
|
|
accessedAt: now,
|
|
},
|
|
content: "secret",
|
|
});
|
|
await writeStarted;
|
|
await handlers.get("netcatty:tempdir:toolOutputDeleteSession")({}, { chatSessionId });
|
|
releaseWrite();
|
|
|
|
const saved = await writing;
|
|
assert.equal(saved.ok, false);
|
|
assert.match(saved.error, /cleared while output was being saved/);
|
|
assert.equal(
|
|
await handlers.get("netcatty:tempdir:toolOutputRestore")({}, { handleId, chatSessionId }),
|
|
null,
|
|
);
|
|
|
|
const later = await handlers.get("netcatty:tempdir:toolOutputWrite")({}, {
|
|
record: {
|
|
schemaVersion: 1,
|
|
handleId: `${handleId}-later`,
|
|
chatSessionId,
|
|
capabilityId: "terminal.execute",
|
|
totalChars: 5,
|
|
storedChars: 5,
|
|
sourceTruncated: false,
|
|
preview: "later",
|
|
storedAt: Date.now(),
|
|
accessedAt: Date.now(),
|
|
},
|
|
content: "later",
|
|
});
|
|
assert.equal(later.ok, true);
|
|
await handlers.get("netcatty:tempdir:toolOutputDelete")({}, { path: later.path });
|
|
} finally {
|
|
fs.promises.writeFile = originalWriteFile;
|
|
releaseWrite?.();
|
|
}
|
|
});
|
|
|
|
test("terminal close wins when a persisted output read is already in flight", async () => {
|
|
const handlers = new Map();
|
|
tempDirBridge.registerHandlers({ handle(channel, handler) { handlers.set(channel, handler); } });
|
|
const terminalSessionId = "terminal-close-during-read";
|
|
const now = Date.now();
|
|
const saved = await handlers.get("netcatty:tempdir:toolOutputWrite")({}, {
|
|
record: {
|
|
schemaVersion: 1,
|
|
handleId: "read-close-race",
|
|
chatSessionId: "chat-read-close-race",
|
|
capabilityId: "terminal.execute",
|
|
terminalSessionId,
|
|
totalChars: 7,
|
|
storedChars: 7,
|
|
sourceTruncated: false,
|
|
preview: "private",
|
|
storedAt: now,
|
|
accessedAt: now,
|
|
},
|
|
content: "private",
|
|
});
|
|
const originalOpen = fs.promises.open;
|
|
let signalReadStarted;
|
|
const readStarted = new Promise(resolve => { signalReadStarted = resolve; });
|
|
let releaseRead;
|
|
const readReleased = new Promise(resolve => { releaseRead = resolve; });
|
|
fs.promises.open = async (...args) => {
|
|
const handle = await originalOpen(...args);
|
|
if (path.resolve(String(args[0])) !== path.resolve(saved.path)) return handle;
|
|
return {
|
|
stat: handle.stat.bind(handle),
|
|
close: handle.close.bind(handle),
|
|
read: handle.read.bind(handle),
|
|
readFile: async (...readArgs) => {
|
|
signalReadStarted();
|
|
await readReleased;
|
|
return handle.readFile(...readArgs);
|
|
},
|
|
};
|
|
};
|
|
|
|
try {
|
|
const reading = handlers.get("netcatty:tempdir:toolOutputRead")({}, { path: saved.path });
|
|
await readStarted;
|
|
await handlers.get("netcatty:tempdir:toolOutputDeleteTerminal")({}, { terminalSessionId });
|
|
releaseRead();
|
|
|
|
assert.equal(await reading, null);
|
|
assert.equal(fs.existsSync(saved.path), false);
|
|
assert.equal(fs.existsSync(saved.manifestPath), false);
|
|
} finally {
|
|
fs.promises.open = originalOpen;
|
|
releaseRead?.();
|
|
await Promise.allSettled([
|
|
fs.promises.unlink(saved.path),
|
|
fs.promises.unlink(saved.manifestPath),
|
|
]);
|
|
}
|
|
});
|
|
|
|
test("startup cleanup removes expired orphaned tool output files", async () => {
|
|
const filePath = tempDirBridge.getTempFilePath("tool-output-expired.log");
|
|
await fs.promises.writeFile(filePath, "secret");
|
|
const old = new Date(Date.now() - 31 * 60 * 1_000);
|
|
await fs.promises.utimes(filePath, old, old);
|
|
const deleted = await tempDirBridge.cleanupExpiredToolOutputFiles();
|
|
assert.equal(deleted >= 1, true);
|
|
assert.equal(fs.existsSync(filePath), false);
|
|
});
|
|
|
|
test("startup cleanup removes abandoned pending manifests", async () => {
|
|
const filePath = tempDirBridge.getTempFilePath("tool-output-abandoned.manifest.pending");
|
|
await fs.promises.writeFile(filePath, "pending");
|
|
const old = new Date(Date.now() - 31 * 60 * 1_000);
|
|
await fs.promises.utimes(filePath, old, old);
|
|
|
|
const deleted = await tempDirBridge.cleanupExpiredToolOutputFiles();
|
|
|
|
assert.equal(deleted >= 1, true);
|
|
assert.equal(fs.existsSync(filePath), false);
|
|
});
|
|
|
|
test("startup cleanup still expires tool outputs when secure storage is unavailable", async () => {
|
|
const root = await fs.promises.mkdtemp(path.join(require("node:os").tmpdir(), "netcatty-key-unavailable-"));
|
|
const fakeHome = path.join(root, "home");
|
|
await fs.promises.mkdir(fakeHome);
|
|
await fs.promises.chmod(root, 0o700);
|
|
try {
|
|
const script = [
|
|
'const fs = require("node:fs");',
|
|
'const bridge = require("./electron/bridges/tempDirBridge.cjs");',
|
|
'bridge.registerHandlers({ handle() {} }, undefined, { safeStorage: { isEncryptionAvailable: () => false } });',
|
|
'const contentPath = bridge.getTempFilePath("tool-output-key-lost.log");',
|
|
'const manifestPath = `${contentPath}.meta.json`;',
|
|
'fs.writeFileSync(contentPath, "secret");',
|
|
'fs.writeFileSync(manifestPath, "unreadable without key");',
|
|
'const old = new Date(Date.now() - 31 * 24 * 60 * 60 * 1_000);',
|
|
'fs.utimesSync(contentPath, old, old);',
|
|
'fs.utimesSync(manifestPath, old, old);',
|
|
'(async () => {',
|
|
' const deleted = await bridge.cleanupExpiredToolOutputFiles();',
|
|
' console.log(JSON.stringify({ deleted, contentExists: fs.existsSync(contentPath), manifestExists: fs.existsSync(manifestPath) }));',
|
|
'})();',
|
|
].join("\n");
|
|
const result = spawnSync(process.execPath, ["-e", script], {
|
|
cwd: path.resolve(__dirname, "../.."),
|
|
env: isolatedTempChildEnv(root, fakeHome),
|
|
encoding: "utf8",
|
|
});
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
assert.match(result.stdout, /"deleted":2,"contentExists":false,"manifestExists":false/);
|
|
} finally {
|
|
await fs.promises.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("managed tool output survives short restarts and expires after thirty days", async () => {
|
|
const handlers = new Map();
|
|
tempDirBridge.registerHandlers({ handle(channel, handler) { handlers.set(channel, handler); } });
|
|
const now = Date.now();
|
|
const saved = await handlers.get("netcatty:tempdir:toolOutputWrite")({}, {
|
|
record: {
|
|
schemaVersion: 1,
|
|
handleId: "tool-output-ttl",
|
|
chatSessionId: "chat-ttl",
|
|
capabilityId: "terminal.execute",
|
|
totalChars: 7,
|
|
storedChars: 7,
|
|
sourceTruncated: false,
|
|
preview: "durable",
|
|
storedAt: now,
|
|
accessedAt: now,
|
|
},
|
|
content: "durable",
|
|
});
|
|
const thirtyOneMinutesAgo = new Date(Date.now() - 31 * 60 * 1_000);
|
|
await Promise.all([
|
|
fs.promises.utimes(saved.path, thirtyOneMinutesAgo, thirtyOneMinutesAgo),
|
|
fs.promises.utimes(saved.manifestPath, thirtyOneMinutesAgo, thirtyOneMinutesAgo),
|
|
]);
|
|
|
|
await tempDirBridge.cleanupExpiredToolOutputFiles();
|
|
assert.equal(fs.existsSync(saved.path), true);
|
|
assert.equal(fs.existsSync(saved.manifestPath), true);
|
|
|
|
const forgedFutureMtime = new Date(now + 365 * 24 * 60 * 60 * 1_000);
|
|
await fs.promises.utimes(saved.manifestPath, forgedFutureMtime, forgedFutureMtime);
|
|
await tempDirBridge.cleanupExpiredToolOutputFiles(now + 31 * 24 * 60 * 60 * 1_000);
|
|
assert.equal(fs.existsSync(saved.path), false);
|
|
assert.equal(fs.existsSync(saved.manifestPath), false);
|
|
});
|
|
|
|
test("persisted tool output search advances only past rendered matches", async () => {
|
|
const handlers = new Map();
|
|
tempDirBridge.registerHandlers({ handle(channel, handler) { handlers.set(channel, handler); } });
|
|
const write = handlers.get("netcatty:tempdir:toolOutputWrite");
|
|
const read = handlers.get("netcatty:tempdir:toolOutputRead");
|
|
const remove = handlers.get("netcatty:tempdir:toolOutputDelete");
|
|
const now = Date.now();
|
|
const saved = await write({}, {
|
|
record: {
|
|
schemaVersion: 1,
|
|
handleId: "search-pagination",
|
|
chatSessionId: "chat-search",
|
|
capabilityId: "terminal.execute",
|
|
totalChars: 23,
|
|
storedChars: 23,
|
|
sourceTruncated: false,
|
|
preview: "match middle match tail",
|
|
storedAt: now,
|
|
accessedAt: now,
|
|
},
|
|
content: "match middle match tail",
|
|
});
|
|
|
|
try {
|
|
const first = await read({}, {
|
|
path: saved.path,
|
|
request: { mode: "search", query: "match", maxChars: 1 },
|
|
});
|
|
assert.doesNotMatch(first.content, /No matches found/);
|
|
assert.deepEqual(first.matchOffsets, [0]);
|
|
assert.equal(first.nextOffset, 5);
|
|
assert.equal(first.hasMore, true);
|
|
|
|
const second = await read({}, {
|
|
path: saved.path,
|
|
request: { mode: "search", query: "match", offset: first.nextOffset, maxChars: 30 },
|
|
});
|
|
assert.deepEqual(second.matchOffsets, [13]);
|
|
} finally {
|
|
await remove({}, { path: saved.path });
|
|
}
|
|
});
|
|
|
|
test("tool output temp restore rejects content tampering and deletes by chat session", async () => {
|
|
const handlers = new Map();
|
|
tempDirBridge.registerHandlers({ handle(channel, handler) { handlers.set(channel, handler); } });
|
|
const now = Date.now();
|
|
const record = {
|
|
schemaVersion: 1,
|
|
handleId: "tool-output-restart",
|
|
chatSessionId: "chat-restart",
|
|
capabilityId: "terminal.execute",
|
|
terminalSessionId: "terminal-1",
|
|
totalChars: 16,
|
|
storedChars: 16,
|
|
sourceTruncated: false,
|
|
preview: "restart content",
|
|
storedAt: now,
|
|
accessedAt: now,
|
|
};
|
|
const saved = await handlers.get("netcatty:tempdir:toolOutputWrite")({}, {
|
|
record,
|
|
content: "restart content!",
|
|
});
|
|
|
|
assert.ok(saved.manifestPath);
|
|
assert.equal((await fs.promises.stat(saved.path)).mode & 0o777, 0o600);
|
|
assert.equal((await fs.promises.stat(saved.manifestPath)).mode & 0o777, 0o600);
|
|
|
|
await fs.promises.writeFile(saved.path, "tampered content", { encoding: "utf16le" });
|
|
assert.equal(await handlers.get("netcatty:tempdir:toolOutputRestore")({}, {
|
|
handleId: record.handleId,
|
|
chatSessionId: record.chatSessionId,
|
|
}), null);
|
|
assert.equal(fs.existsSync(saved.path), false);
|
|
assert.equal(fs.existsSync(saved.manifestPath), false);
|
|
|
|
const deleted = await handlers.get("netcatty:tempdir:toolOutputDeleteSession")({}, {
|
|
chatSessionId: record.chatSessionId,
|
|
});
|
|
assert.equal(deleted.deletedCount, 0);
|
|
assert.equal(fs.existsSync(saved.path), false);
|
|
assert.equal(fs.existsSync(saved.manifestPath), false);
|
|
});
|
|
|
|
test("tool output ownership metadata cannot be reassigned to another chat", async () => {
|
|
const handlers = new Map();
|
|
tempDirBridge.registerHandlers({ handle(channel, handler) { handlers.set(channel, handler); } });
|
|
const now = Date.now();
|
|
const saved = await handlers.get("netcatty:tempdir:toolOutputWrite")({}, {
|
|
record: {
|
|
schemaVersion: 1,
|
|
handleId: "tool-output-owned",
|
|
chatSessionId: "chat-owner",
|
|
capabilityId: "terminal.execute",
|
|
totalChars: 7,
|
|
storedChars: 7,
|
|
sourceTruncated: false,
|
|
preview: "private",
|
|
storedAt: now,
|
|
accessedAt: now,
|
|
},
|
|
content: "private",
|
|
});
|
|
const manifest = JSON.parse(await fs.promises.readFile(saved.manifestPath, "utf8"));
|
|
manifest.record.chatSessionId = "chat-attacker";
|
|
await fs.promises.writeFile(saved.manifestPath, JSON.stringify(manifest), "utf8");
|
|
|
|
assert.equal(await handlers.get("netcatty:tempdir:toolOutputRestore")({}, {
|
|
handleId: "tool-output-owned",
|
|
chatSessionId: "chat-attacker",
|
|
}), null);
|
|
assert.equal(await handlers.get("netcatty:tempdir:toolOutputRead")({}, { path: saved.path }), null);
|
|
|
|
await Promise.allSettled([
|
|
fs.promises.unlink(saved.path),
|
|
fs.promises.unlink(saved.manifestPath),
|
|
]);
|
|
});
|
|
|
|
test("tool output verification detects same-size edits even when mtime is restored", async () => {
|
|
const handlers = new Map();
|
|
tempDirBridge.registerHandlers({ handle(channel, handler) { handlers.set(channel, handler); } });
|
|
const now = Date.now();
|
|
const saved = await handlers.get("netcatty:tempdir:toolOutputWrite")({}, {
|
|
record: {
|
|
schemaVersion: 1,
|
|
handleId: "tool-output-same-mtime",
|
|
chatSessionId: "chat-same-mtime",
|
|
capabilityId: "terminal.execute",
|
|
totalChars: 8,
|
|
storedChars: 8,
|
|
sourceTruncated: false,
|
|
preview: "original",
|
|
storedAt: now,
|
|
accessedAt: now,
|
|
},
|
|
content: "original",
|
|
});
|
|
const originalStat = await fs.promises.stat(saved.path);
|
|
await fs.promises.writeFile(saved.path, "modified", { encoding: "utf16le" });
|
|
await fs.promises.utimes(saved.path, originalStat.atime, originalStat.mtime);
|
|
|
|
assert.equal(await handlers.get("netcatty:tempdir:toolOutputRead")({}, { path: saved.path }), null);
|
|
assert.equal(fs.existsSync(saved.path), false);
|
|
assert.equal(fs.existsSync(saved.manifestPath), false);
|
|
});
|
|
|
|
test("tool output writer reports when quota enforcement evicts the new output", async () => {
|
|
const handlers = new Map();
|
|
tempDirBridge.registerHandlers({ handle(channel, handler) { handlers.set(channel, handler); } });
|
|
const chatSessionId = `quota-eviction-${Date.now()}`;
|
|
const write = handlers.get("netcatty:tempdir:toolOutputWrite");
|
|
try {
|
|
for (let index = 0; index < 64; index += 1) {
|
|
const saved = await write({}, {
|
|
record: {
|
|
schemaVersion: 1,
|
|
handleId: `quota-newer-${index}`,
|
|
chatSessionId,
|
|
capabilityId: "terminal.execute",
|
|
totalChars: 1,
|
|
storedChars: 1,
|
|
sourceTruncated: false,
|
|
preview: "x",
|
|
storedAt: Date.now(),
|
|
accessedAt: Date.now() + 60_000,
|
|
},
|
|
content: "x",
|
|
});
|
|
assert.equal(saved.ok, true);
|
|
}
|
|
|
|
const evicted = await write({}, {
|
|
record: {
|
|
schemaVersion: 1,
|
|
handleId: "quota-old-clock",
|
|
chatSessionId,
|
|
capabilityId: "terminal.execute",
|
|
totalChars: 1,
|
|
storedChars: 1,
|
|
sourceTruncated: false,
|
|
preview: "y",
|
|
storedAt: 1,
|
|
accessedAt: 1,
|
|
},
|
|
content: "y",
|
|
});
|
|
assert.equal(evicted.ok, false);
|
|
assert.match(evicted.error, /removed while enforcing storage limits/);
|
|
} finally {
|
|
await handlers.get("netcatty:tempdir:toolOutputDeleteSession")({}, { chatSessionId });
|
|
}
|
|
});
|
|
|
|
test("tool output signing key survives a real process restart", async () => {
|
|
const root = await fs.promises.mkdtemp(path.join(require("node:os").tmpdir(), "netcatty-tool-output-restart-"));
|
|
const fakeHome = path.join(root, "home");
|
|
await fs.promises.mkdir(fakeHome);
|
|
await fs.promises.chmod(root, 0o700);
|
|
const script = [
|
|
'const bridge = require("./electron/bridges/tempDirBridge.cjs");',
|
|
'const handlers = new Map();',
|
|
'const secret = process.env.FAKE_SAFE_STORAGE_SECRET;',
|
|
'let keychainLocked = process.env.PHASE === "unlock";',
|
|
'const safeStorage = {',
|
|
' isEncryptionAvailable: () => true,',
|
|
' encryptString: value => Buffer.from(`${secret}:${value}`, "utf8"),',
|
|
' decryptString: value => {',
|
|
' if (keychainLocked) throw new Error("keychain locked");',
|
|
' const text = value.toString("utf8");',
|
|
' if (!text.startsWith(`${secret}:`)) throw new Error("wrong key");',
|
|
' return text.slice(secret.length + 1);',
|
|
' },',
|
|
'};',
|
|
'bridge.registerHandlers({ handle: (channel, handler) => handlers.set(channel, handler) }, undefined, { safeStorage });',
|
|
'(async () => {',
|
|
' if (process.env.PHASE === "write") {',
|
|
' const now = Date.now();',
|
|
' const saved = await handlers.get("netcatty:tempdir:toolOutputWrite")({}, {',
|
|
' record: { schemaVersion: 1, handleId: "real-restart", chatSessionId: "restart-chat", capabilityId: "terminal.execute", terminalSessionId: "terminal-one", totalChars: 14, storedChars: 14, sourceTruncated: false, preview: "across restart", storedAt: now, accessedAt: now },',
|
|
' content: "across restart",',
|
|
' });',
|
|
' await handlers.get("netcatty:tempdir:toolOutputWrite")({}, {',
|
|
' record: { schemaVersion: 1, handleId: "real-restart-two", chatSessionId: "restart-chat", capabilityId: "terminal.execute", terminalSessionId: "terminal-two", totalChars: 13, storedChars: 13, sourceTruncated: false, preview: "second output", storedAt: now, accessedAt: now },',
|
|
' content: "second output",',
|
|
' });',
|
|
' console.log(`RESULT:${JSON.stringify(saved)}`);',
|
|
' return;',
|
|
' }',
|
|
' if (process.env.PHASE === "delete-terminal") {',
|
|
' const deleted = await handlers.get("netcatty:tempdir:toolOutputDeleteTerminal")({}, { terminalSessionId: "terminal-one" });',
|
|
' console.log(`RESULT:${JSON.stringify(deleted)}`);',
|
|
' return;',
|
|
' }',
|
|
' if (process.env.PHASE === "delete-chat") {',
|
|
' const deleted = await handlers.get("netcatty:tempdir:toolOutputDeleteSession")({}, { chatSessionId: "restart-chat" });',
|
|
' console.log(`RESULT:${JSON.stringify(deleted)}`);',
|
|
' return;',
|
|
' }',
|
|
' if (process.env.PHASE === "clear") {',
|
|
' const cleared = await handlers.get("netcatty:tempdir:clear")();',
|
|
' const status = await handlers.get("netcatty:tempdir:toolOutputPersistenceStatus")();',
|
|
' console.log(`RESULT:${JSON.stringify({ cleared, status })}`);',
|
|
' return;',
|
|
' }',
|
|
' if (process.env.PHASE === "unlock") {',
|
|
' const locked = await handlers.get("netcatty:tempdir:toolOutputPersistenceStatus")();',
|
|
' keychainLocked = false;',
|
|
' const unlocked = await handlers.get("netcatty:tempdir:toolOutputPersistenceStatus")();',
|
|
' const restored = await handlers.get("netcatty:tempdir:toolOutputRestore")({}, { handleId: "real-restart", chatSessionId: "restart-chat" });',
|
|
' const content = restored ? await handlers.get("netcatty:tempdir:toolOutputRead")({}, { path: restored.path }) : null;',
|
|
' console.log(`RESULT:${JSON.stringify({ locked, unlocked, content })}`);',
|
|
' return;',
|
|
' }',
|
|
' const restored = await handlers.get("netcatty:tempdir:toolOutputRestore")({}, { handleId: "real-restart", chatSessionId: "restart-chat" });',
|
|
' const content = restored ? await handlers.get("netcatty:tempdir:toolOutputRead")({}, { path: restored.path }) : null;',
|
|
' await bridge.cleanupExpiredToolOutputFiles();',
|
|
' console.log(`RESULT:${JSON.stringify({ restored: Boolean(restored), content })}`);',
|
|
'})().catch(error => { console.error(error); process.exit(1); });',
|
|
].join("\n");
|
|
const run = (phase, secret) => spawnSync(process.execPath, ["-e", script], {
|
|
cwd: path.resolve(__dirname, "../.."),
|
|
env: isolatedTempChildEnv(root, fakeHome, {
|
|
PHASE: phase,
|
|
FAKE_SAFE_STORAGE_SECRET: secret,
|
|
}),
|
|
encoding: "utf8",
|
|
});
|
|
try {
|
|
const written = run("write", "stable-secret");
|
|
assert.equal(written.status, 0, written.stderr || written.stdout);
|
|
assert.match(written.stdout, /RESULT:.*"ok":true/);
|
|
|
|
const reopened = run("read", "stable-secret");
|
|
assert.equal(reopened.status, 0, reopened.stderr || reopened.stdout);
|
|
assert.match(reopened.stdout, /RESULT:.*"restored":true.*"content":"across restart"/);
|
|
|
|
const unlocked = run("unlock", "stable-secret");
|
|
assert.equal(unlocked.status, 0, unlocked.stderr || unlocked.stdout);
|
|
assert.match(unlocked.stdout, /RESULT:.*"locked":\{"durable":false.*"unlocked":\{"durable":true\}.*"content":"across restart"/);
|
|
|
|
const persistedDir = path.join(root, "Netcatty");
|
|
const signingKeyPath = path.join(persistedDir, ".tool-output-signing-key");
|
|
const signingKeyBeforeFailure = await fs.promises.readFile(signingKeyPath);
|
|
const old = new Date(Date.now() - 31 * 60 * 1_000);
|
|
for (const file of await fs.promises.readdir(persistedDir)) {
|
|
if (file.endsWith(".log") || file.endsWith(".log.meta.json")) {
|
|
await fs.promises.utimes(path.join(persistedDir, file), old, old);
|
|
}
|
|
}
|
|
const wrongKey = run("read", "different-secret");
|
|
assert.equal(wrongKey.status, 0, wrongKey.stderr || wrongKey.stdout);
|
|
assert.match(wrongKey.stdout, /RESULT:\{"restored":false,"content":null\}/);
|
|
assert.deepEqual(await fs.promises.readFile(signingKeyPath), signingKeyBeforeFailure);
|
|
const remainingFiles = await fs.promises.readdir(persistedDir);
|
|
assert.equal(remainingFiles.filter(file => file.endsWith(".log")).length, 2);
|
|
assert.equal(remainingFiles.filter(file => file.endsWith(".log.meta.json")).length, 2);
|
|
|
|
const recoveredRead = run("read", "stable-secret");
|
|
assert.equal(recoveredRead.status, 0, recoveredRead.stderr || recoveredRead.stdout);
|
|
assert.match(recoveredRead.stdout, /RESULT:.*"restored":true.*"content":"across restart"/);
|
|
|
|
const reset = run("clear", "different-secret");
|
|
assert.equal(reset.status, 0, reset.stderr || reset.stdout);
|
|
assert.match(reset.stdout, /RESULT:.*"status":\{"durable":true\}/);
|
|
assert.notDeepEqual(await fs.promises.readFile(signingKeyPath), signingKeyBeforeFailure);
|
|
|
|
const rewritten = run("write", "different-secret");
|
|
assert.equal(rewritten.status, 0, rewritten.stderr || rewritten.stdout);
|
|
assert.match(rewritten.stdout, /RESULT:.*"ok":true/);
|
|
|
|
const deletedTerminal = run("delete-terminal", "different-secret");
|
|
assert.equal(deletedTerminal.status, 0, deletedTerminal.stderr || deletedTerminal.stdout);
|
|
assert.match(deletedTerminal.stdout, /RESULT:\{"deletedCount":1\}/);
|
|
const afterTerminalDelete = await fs.promises.readdir(persistedDir);
|
|
assert.equal(afterTerminalDelete.filter(file => file.endsWith(".log")).length, 1);
|
|
|
|
const deletedChat = run("delete-chat", "different-secret");
|
|
assert.equal(deletedChat.status, 0, deletedChat.stderr || deletedChat.stdout);
|
|
assert.match(deletedChat.stdout, /RESULT:\{"deletedCount":1\}/);
|
|
const afterChatDelete = await fs.promises.readdir(persistedDir);
|
|
assert.equal(afterChatDelete.some(file => file.endsWith(".log") || file.endsWith(".log.meta.json")), false);
|
|
} finally {
|
|
await fs.promises.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("tool output persistence reports when secure storage is unavailable", async () => {
|
|
const handlers = new Map();
|
|
tempDirBridge.registerHandlers(
|
|
{ handle(channel, handler) { handlers.set(channel, handler); } },
|
|
undefined,
|
|
{ safeStorage: { isEncryptionAvailable: () => false } },
|
|
);
|
|
|
|
assert.deepEqual(
|
|
await handlers.get("netcatty:tempdir:toolOutputPersistenceStatus")(),
|
|
{ durable: false, reason: "Secure local storage is unavailable." },
|
|
);
|
|
});
|
|
|
|
test("Linux basic_text and unknown backends are not treated as secure storage", () => {
|
|
const storage = backend => ({
|
|
isEncryptionAvailable: () => true,
|
|
getSelectedStorageBackend: () => backend,
|
|
});
|
|
|
|
assert.equal(tempDirBridge.isSecureToolOutputStorageAvailable(storage("basic_text"), "linux"), false);
|
|
assert.equal(tempDirBridge.isSecureToolOutputStorageAvailable(storage("unknown"), "linux"), false);
|
|
assert.equal(tempDirBridge.isSecureToolOutputStorageAvailable(storage("gnome_libsecret"), "linux"), true);
|
|
assert.equal(tempDirBridge.isSecureToolOutputStorageAvailable(storage("basic_text"), "darwin"), true);
|
|
});
|