[Init] Initial commit - NetMesh terminal manager
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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
"use strict";
/**
* Electron's window-all-closed event includes hidden plugin host windows. Use
* the WindowManager's explicit app-content registry to decide when the user
* has closed the last Netcatty window instead.
*/
function createAppContentWindowClosedHandler(options) {
const { app, windowManager, platform = process.platform } = options;
if (!app || typeof app.quit !== "function") {
throw new TypeError("App content window lifecycle requires app.quit()");
}
if (!windowManager || typeof windowManager.getAppContentWindows !== "function") {
throw new TypeError("App content window lifecycle requires WindowManager content tracking");
}
return function handleAppContentWindowClosed() {
if (platform === "darwin" || windowManager.getIsQuitting?.()) return false;
const remaining = windowManager.getAppContentWindows();
if (!Array.isArray(remaining) || remaining.length > 0) return false;
app.quit();
return true;
};
}
module.exports = { createAppContentWindowClosedHandler };

View File

@@ -0,0 +1,50 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { createAppContentWindowClosedHandler } = require("./appWindowLifecycle.cjs");
test("last app content window closes the app even while a hidden plugin host window exists", () => {
const hiddenPluginWindow = { isVisible: () => false };
let quitCalls = 0;
const handler = createAppContentWindowClosedHandler({
app: { quit() { quitCalls += 1; } },
platform: "linux",
windowManager: {
getAppContentWindows: () => [],
getIsQuitting: () => false,
// A hidden BrowserWindow still exists globally, but it is intentionally
// not part of Netcatty's app-content registry.
getAllBrowserWindows: () => [hiddenPluginWindow],
},
});
assert.equal(handler(), true);
assert.equal(quitCalls, 1);
});
test("app content close handler preserves macOS and active app windows", () => {
let quitCalls = 0;
const app = { quit() { quitCalls += 1; } };
const macHandler = createAppContentWindowClosedHandler({
app,
platform: "darwin",
windowManager: { getAppContentWindows: () => [], getIsQuitting: () => false },
});
const remainingWindowHandler = createAppContentWindowClosedHandler({
app,
platform: "win32",
windowManager: { getAppContentWindows: () => [{}], getIsQuitting: () => false },
});
const alreadyQuittingHandler = createAppContentWindowClosedHandler({
app,
platform: "win32",
windowManager: { getAppContentWindows: () => [], getIsQuitting: () => true },
});
assert.equal(macHandler(), false);
assert.equal(remainingWindowHandler(), false);
assert.equal(alreadyQuittingHandler(), false);
assert.equal(quitCalls, 0);
});

195
electron/autoLaunch.cjs Normal file
View File

@@ -0,0 +1,195 @@
/**
* Auto Launch - Registers Netcatty to start at system login, hidden to the
* tray. Thin wrapper around Electron's app.setLoginItemSettings/
* getLoginItemSettings so main.cjs and the settings IPC handlers share one
* source of truth.
*
* Development runs (`electron .`) are unsupported: process.execPath points
* at the transient electron.exe, so a login item registered there would
* break (or silently do nothing) after the dev process exits.
*
* Platform support is further limited to macOS and Windows: Electron's
* login-item API is a no-op on Linux, which ships as AppImage/deb/rpm/pacman
* with no first-party autostart hook, so reporting it as supported there
* would show an enabled toggle that does nothing.
*/
const HIDDEN_LAUNCH_ARG = "--hidden";
function isAutoLaunchSupported({ defaultApp = process.defaultApp, platform = process.platform } = {}) {
if (defaultApp) return false;
return platform === "darwin" || platform === "win32";
}
function argsEqual(a, b) {
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((value, index) => value === b[index]);
}
/**
* Find the specific Windows run-key entry our own writes create (path +
* --hidden args), not just any entry for this executable.
*/
function findMatchingLaunchItem(settings, execPath) {
if (!Array.isArray(settings?.launchItems)) return null;
return settings.launchItems.find(
(item) => item?.path === execPath && argsEqual(item?.args, [HIDDEN_LAUNCH_ARG]),
) ?? null;
}
/**
* Resolve the OS's effective auto-launch state, not just whether a
* registration exists.
*
* Windows can retain the run-key entry while the user disables it via Task
* Manager's Startup Apps UI. Electron's executableWillLaunchAtLogin surfaces
* an effective state too, but it deliberately ignores the args option and
* reports true if the executable would launch with ANY arguments — so it
* can false-positive on an unrelated no-argument entry for the same exe.
* launchItems[].enabled is scoped to one specific path+args registration
* (the same combination buildLoginItemQueryOptions queries), so look up our
* own --hidden entry there instead and use its enabled flag.
*/
function resolveEffectiveLoginState(settings, platform, execPath) {
if (platform === "win32") {
const matchingItem = findMatchingLaunchItem(settings, execPath);
if (matchingItem) return Boolean(matchingItem.enabled);
// No matching --hidden entry: openAtLogin was queried with the same
// path+args (see buildLoginItemQueryOptions), so it is still correctly
// scoped to "is that exact entry registered" — false if absent.
return Boolean(settings?.openAtLogin);
}
if (platform === "darwin" && settings?.status === "requires-approval") {
// macOS 13+ (SMAppService): a freshly registered login item sits in
// "requires-approval" until the user approves it in System Settings,
// during which openAtLogin reports false even though registration
// itself succeeded. Reporting false here would make the renderer's
// push effect see a mismatch against what the user just requested and
// immediately fire an opposite write, unregistering the pending item
// before the user ever gets a chance to approve it. Treat pending
// approval as enabled; a later read naturally reflects the real state
// once the user approves it (or the OS drops the pending request).
return true;
}
return Boolean(settings?.openAtLogin);
}
/**
* Electron's getLoginItemSettings() only reports openAtLogin for the
* specific path+args combination you ask about — it does not mean "is
* anything registered for this app". Our code only ever registers a login
* item with args:[HIDDEN_LAUNCH_ARG], so every read must query that exact
* combination (matching what setAutoLaunchEnabled writes) or Windows
* reports a false negative.
*/
function buildLoginItemQueryOptions(execPath) {
return { path: execPath, args: [HIDDEN_LAUNCH_ARG] };
}
function getAutoLaunchEnabled({
app,
execPath = process.execPath,
defaultApp = process.defaultApp,
platform = process.platform,
} = {}) {
if (!isAutoLaunchSupported({ defaultApp, platform })) {
return { success: true, enabled: false, supported: false };
}
try {
const settings = app.getLoginItemSettings(buildLoginItemQueryOptions(execPath));
return { success: true, enabled: resolveEffectiveLoginState(settings, platform, execPath), supported: true };
} catch (err) {
console.warn("[AutoLaunch] Failed to read login item settings:", err?.message || err);
// success:false signals "OS state unknown" (as opposed to a confirmed
// disabled state) so callers — notably the renderer's mount-time
// hydration — know not to trust `enabled` here. Blindly applying
// enabled:false on a transient read failure would overwrite the
// renderer's cached value and cascade into an unwanted disable write.
return { success: false, enabled: false, supported: true };
}
}
function setAutoLaunchEnabled(enabled, {
app,
execPath = process.execPath,
defaultApp = process.defaultApp,
platform = process.platform,
} = {}) {
if (!isAutoLaunchSupported({ defaultApp, platform })) {
// enabled:false is a confirmed fact here (the feature genuinely isn't
// available), not an unknown state — matches getAutoLaunchEnabled's
// equivalent branch so both functions agree on what `success` means.
return { success: true, enabled: false, supported: false };
}
const wantEnabled = Boolean(enabled);
try {
app.setLoginItemSettings({
openAtLogin: wantEnabled,
// openAsHidden only applies on macOS App Store builds; Windows relies
// on the --hidden arg below, which main.cjs checks on cold start.
openAsHidden: wantEnabled,
path: execPath,
args: wantEnabled ? [HIDDEN_LAUNCH_ARG] : [],
});
const settings = app.getLoginItemSettings(buildLoginItemQueryOptions(execPath));
return { success: true, enabled: resolveEffectiveLoginState(settings, platform, execPath), supported: true };
} catch (err) {
console.warn("[AutoLaunch] Failed to update login item settings:", err?.message || err);
// `success` means "is `enabled` trustworthy", not "did the write
// succeed" — the write itself failing does not make a subsequent,
// independent read failing too. Surface whatever the fallback read
// determines: if it succeeds, `enabled` reflects the real (unwritten)
// state and the renderer's push effect correctly rolls the optimistic
// toggle back instead of leaving it stuck on a change that never
// actually happened. Only a genuine double failure (write AND fallback
// read both throw) reports success:false, so the renderer preserves its
// last-known state instead of guessing.
const fallback = getAutoLaunchEnabled({ app, execPath, defaultApp, platform });
return { success: fallback.success, enabled: fallback.enabled, supported: true };
}
}
/**
* True when this process was launched by the OS login item (cold start
* only). Windows relies on the --hidden arg (macOS never puts args from
* setLoginItemSettings() into argv for a login launch, so it needs a
* different signal).
*
* macOS's own hidden-launch flags (openAsHidden/wasOpenedAsHidden) are
* deprecated and stop working on macOS 13+ per Electron's docs, so they
* cannot be trusted to detect an actual hidden launch there. wasOpenedAtLogin
* still works on 13+ and is not deprecated; since Netcatty only ever
* registers a macOS login item to satisfy this "launch hidden" feature (there
* is no scenario where it registers one for a normal, visible startup), any
* automatic login launch should apply our own hidden-window behavior.
*/
function wasLaunchedHidden({ argv = process.argv, app, platform = process.platform } = {}) {
if (Array.isArray(argv) && argv.includes(HIDDEN_LAUNCH_ARG)) return true;
if (platform !== "darwin" || typeof app?.getLoginItemSettings !== "function") return false;
try {
return Boolean(app.getLoginItemSettings().wasOpenedAtLogin);
} catch (err) {
console.warn("[AutoLaunch] Failed to read macOS login-item launch state:", err?.message || err);
return false;
}
}
function registerHandlers(ipcMain, { app, platform = process.platform }) {
ipcMain.handle("netcatty:autoLaunch:get", async () => {
return getAutoLaunchEnabled({ app, platform });
});
ipcMain.handle("netcatty:autoLaunch:set", async (_event, { enabled }) => {
return setAutoLaunchEnabled(enabled, { app, platform });
});
}
module.exports = {
HIDDEN_LAUNCH_ARG,
isAutoLaunchSupported,
resolveEffectiveLoginState,
buildLoginItemQueryOptions,
getAutoLaunchEnabled,
setAutoLaunchEnabled,
wasLaunchedHidden,
registerHandlers,
};

View File

@@ -0,0 +1,404 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
HIDDEN_LAUNCH_ARG,
isAutoLaunchSupported,
resolveEffectiveLoginState,
buildLoginItemQueryOptions,
getAutoLaunchEnabled,
setAutoLaunchEnabled,
wasLaunchedHidden,
registerHandlers,
} = require("./autoLaunch.cjs");
const EXEC_PATH = "C:\\Netcatty\\Netcatty.exe";
function hiddenLaunchItem(overrides = {}) {
return { name: "Netcatty", path: EXEC_PATH, args: [HIDDEN_LAUNCH_ARG], scope: "user", enabled: true, ...overrides };
}
test("isAutoLaunchSupported is false when running unpackaged (electron .)", () => {
assert.equal(isAutoLaunchSupported({ defaultApp: true, platform: "win32" }), false);
assert.equal(isAutoLaunchSupported({ defaultApp: true, platform: "darwin" }), false);
});
test("isAutoLaunchSupported is true on macOS and Windows when packaged", () => {
assert.equal(isAutoLaunchSupported({ defaultApp: false, platform: "darwin" }), true);
assert.equal(isAutoLaunchSupported({ defaultApp: false, platform: "win32" }), true);
});
test("isAutoLaunchSupported is false on Linux — Electron's login-item API is a no-op there", () => {
assert.equal(isAutoLaunchSupported({ defaultApp: false, platform: "linux" }), false);
});
test("resolveEffectiveLoginState uses the matching launchItems entry's enabled flag on Windows", () => {
const settings = { openAtLogin: true, launchItems: [hiddenLaunchItem({ enabled: false })] };
assert.equal(
resolveEffectiveLoginState(settings, "win32", EXEC_PATH),
false,
"Task Manager can disable the specific --hidden entry while openAtLogin stays true",
);
});
test("resolveEffectiveLoginState reports true when the matching launchItems entry is enabled", () => {
const settings = { openAtLogin: true, launchItems: [hiddenLaunchItem({ enabled: true })] };
assert.equal(resolveEffectiveLoginState(settings, "win32", EXEC_PATH), true);
});
test("resolveEffectiveLoginState ignores unrelated launchItems entries (different path or args)", () => {
const settings = {
openAtLogin: false,
launchItems: [
hiddenLaunchItem({ path: "C:\\Other\\App.exe", enabled: true }),
hiddenLaunchItem({ args: [], enabled: true }),
],
};
assert.equal(
resolveEffectiveLoginState(settings, "win32", EXEC_PATH),
false,
"a differently-scoped entry (wrong path or wrong args) must not count as our own registration",
);
});
test("resolveEffectiveLoginState falls back to openAtLogin when no matching launchItems entry exists", () => {
assert.equal(resolveEffectiveLoginState({ openAtLogin: true, launchItems: [] }, "win32", EXEC_PATH), true);
assert.equal(resolveEffectiveLoginState({ openAtLogin: false, launchItems: [] }, "win32", EXEC_PATH), false);
assert.equal(resolveEffectiveLoginState({ openAtLogin: true }, "win32", EXEC_PATH), true, "no launchItems array at all");
});
test("resolveEffectiveLoginState does not consult launchItems on non-Windows platforms", () => {
const settings = { openAtLogin: true, launchItems: [hiddenLaunchItem({ enabled: false })] };
assert.equal(
resolveEffectiveLoginState(settings, "darwin", EXEC_PATH),
true,
"launchItems is Windows-only; macOS must read openAtLogin directly",
);
});
test("resolveEffectiveLoginState treats a macOS pending-approval registration as enabled", () => {
const settings = { openAtLogin: false, status: "requires-approval" };
assert.equal(
resolveEffectiveLoginState(settings, "darwin", EXEC_PATH),
true,
"openAtLogin reports false while SMAppService awaits System Settings approval, even though registration succeeded; " +
"reporting false here would make the renderer's push effect immediately unregister the pending item",
);
});
test("resolveEffectiveLoginState ignores the requires-approval special-case on non-macOS platforms", () => {
const settings = { openAtLogin: false, status: "requires-approval" };
assert.equal(resolveEffectiveLoginState(settings, "win32", EXEC_PATH), false);
});
test("resolveEffectiveLoginState reads openAtLogin normally for other macOS statuses", () => {
assert.equal(resolveEffectiveLoginState({ openAtLogin: true, status: "enabled" }, "darwin", EXEC_PATH), true);
assert.equal(resolveEffectiveLoginState({ openAtLogin: false, status: "not-registered" }, "darwin", EXEC_PATH), false);
assert.equal(resolveEffectiveLoginState({ openAtLogin: false, status: "not-found" }, "darwin", EXEC_PATH), false);
});
test("buildLoginItemQueryOptions matches the path+args our own writes always register", () => {
assert.deepEqual(
buildLoginItemQueryOptions(EXEC_PATH),
{ path: EXEC_PATH, args: [HIDDEN_LAUNCH_ARG] },
);
});
test("getAutoLaunchEnabled reports unsupported without touching app in dev", () => {
let called = false;
const app = { getLoginItemSettings: () => { called = true; return { openAtLogin: true }; } };
const result = getAutoLaunchEnabled({ app, defaultApp: true, platform: "win32" });
assert.deepEqual(result, { success: true, enabled: false, supported: false });
assert.equal(called, false);
});
test("getAutoLaunchEnabled queries the same path+args the login item was registered with", () => {
let capturedOptions = null;
const app = {
getLoginItemSettings: (options) => { capturedOptions = options; return { openAtLogin: true }; },
};
getAutoLaunchEnabled({ app, execPath: EXEC_PATH, defaultApp: false, platform: "win32" });
assert.deepEqual(capturedOptions, { path: EXEC_PATH, args: [HIDDEN_LAUNCH_ARG] });
});
test("getAutoLaunchEnabled reports unsupported on Linux without touching app", () => {
let called = false;
const app = { getLoginItemSettings: () => { called = true; return { openAtLogin: true }; } };
const result = getAutoLaunchEnabled({ app, defaultApp: false, platform: "linux" });
assert.deepEqual(result, { success: true, enabled: false, supported: false });
assert.equal(called, false);
});
test("getAutoLaunchEnabled reflects the current login item state on macOS", () => {
const app = { getLoginItemSettings: () => ({ openAtLogin: true }) };
const result = getAutoLaunchEnabled({ app, defaultApp: false, platform: "darwin" });
assert.deepEqual(result, { success: true, enabled: true, supported: true });
});
test("getAutoLaunchEnabled reports disabled when Windows Startup Apps has disabled the matching entry", () => {
const app = {
getLoginItemSettings: () => ({ openAtLogin: true, launchItems: [hiddenLaunchItem({ enabled: false })] }),
};
const result = getAutoLaunchEnabled({ app, execPath: EXEC_PATH, defaultApp: false, platform: "win32" });
assert.deepEqual(result, { success: true, enabled: false, supported: true });
});
test("getAutoLaunchEnabled is not fooled by an unrelated no-argument entry for the same executable", () => {
const app = {
getLoginItemSettings: () => ({
openAtLogin: false,
launchItems: [hiddenLaunchItem({ args: [], enabled: true })],
}),
};
const result = getAutoLaunchEnabled({ app, execPath: EXEC_PATH, defaultApp: false, platform: "win32" });
assert.deepEqual(
result,
{ success: true, enabled: false, supported: true },
"executableWillLaunchAtLogin-style any-args matching would wrongly report true here",
);
});
test("getAutoLaunchEnabled reports success:false (not a confirmed disabled state) when the app API throws", () => {
const app = { getLoginItemSettings: () => { throw new Error("boom"); } };
const result = getAutoLaunchEnabled({ app, defaultApp: false, platform: "win32" });
assert.deepEqual(
result,
{ success: false, enabled: false, supported: true },
"callers (renderer hydration) must be able to tell a transient read failure apart from a confirmed enabled:false, or they will overwrite a cached true and cascade into an unwanted disable write",
);
});
test("setAutoLaunchEnabled(true) registers the hidden launch arg", () => {
let capturedSettings = null;
const app = {
setLoginItemSettings: (settings) => { capturedSettings = settings; },
getLoginItemSettings: () => ({ openAtLogin: true, launchItems: [hiddenLaunchItem({ enabled: true })] }),
};
const result = setAutoLaunchEnabled(true, {
app,
execPath: EXEC_PATH,
defaultApp: false,
platform: "win32",
});
assert.deepEqual(capturedSettings, {
openAtLogin: true,
openAsHidden: true,
path: EXEC_PATH,
args: [HIDDEN_LAUNCH_ARG],
});
assert.deepEqual(result, { success: true, enabled: true, supported: true });
});
test("setAutoLaunchEnabled(true) verifies with the same path+args it just wrote", () => {
let capturedQueryOptions = null;
const app = {
setLoginItemSettings: () => {},
getLoginItemSettings: (options) => {
capturedQueryOptions = options;
// Electron 42 contract: openAtLogin only reflects true for the exact
// path+args queried — a bare getLoginItemSettings() call (no args)
// would incorrectly report false right after enabling with --hidden.
return options?.args?.includes(HIDDEN_LAUNCH_ARG)
? { openAtLogin: true, launchItems: [hiddenLaunchItem({ enabled: true })] }
: { openAtLogin: false, launchItems: [] };
},
};
const result = setAutoLaunchEnabled(true, {
app,
execPath: EXEC_PATH,
defaultApp: false,
platform: "win32",
});
assert.deepEqual(capturedQueryOptions, { path: EXEC_PATH, args: [HIDDEN_LAUNCH_ARG] });
assert.equal(result.enabled, true, "must not report false just because the query omitted matching args");
});
test("setAutoLaunchEnabled(true) reports disabled when Windows Startup Apps blocks the matching entry", () => {
const app = {
setLoginItemSettings: () => {},
getLoginItemSettings: () => ({ openAtLogin: true, launchItems: [hiddenLaunchItem({ enabled: false })] }),
};
const result = setAutoLaunchEnabled(true, { app, execPath: EXEC_PATH, defaultApp: false, platform: "win32" });
assert.deepEqual(result, { success: true, enabled: false, supported: true });
});
test("setAutoLaunchEnabled(true) on macOS does not report disabled while approval is pending", () => {
const app = {
setLoginItemSettings: () => {},
getLoginItemSettings: () => ({ openAtLogin: false, status: "requires-approval" }),
};
const result = setAutoLaunchEnabled(true, { app, defaultApp: false, platform: "darwin" });
assert.deepEqual(
result,
{ success: true, enabled: true, supported: true },
"must match what the user requested, or the renderer's push effect fires an unregistering write before approval",
);
});
test("setAutoLaunchEnabled(false) clears the hidden launch arg", () => {
let capturedSettings = null;
const app = {
setLoginItemSettings: (settings) => { capturedSettings = settings; },
getLoginItemSettings: () => ({ openAtLogin: false, launchItems: [] }),
};
const result = setAutoLaunchEnabled(false, { app, defaultApp: false, platform: "win32" });
assert.deepEqual(capturedSettings.args, []);
assert.equal(capturedSettings.openAtLogin, false);
assert.deepEqual(result, { success: true, enabled: false, supported: true });
});
test("setAutoLaunchEnabled is a no-op in dev and does not call the app API", () => {
let called = false;
const app = { setLoginItemSettings: () => { called = true; } };
const result = setAutoLaunchEnabled(true, { app, defaultApp: true, platform: "win32" });
assert.equal(called, false);
assert.deepEqual(
result,
{ success: true, enabled: false, supported: false },
"enabled:false is a confirmed fact when unsupported, not an unknown state — matches getAutoLaunchEnabled",
);
});
test("setAutoLaunchEnabled is a no-op on Linux and does not call the app API", () => {
let called = false;
const app = { setLoginItemSettings: () => { called = true; } };
const result = setAutoLaunchEnabled(true, { app, defaultApp: false, platform: "linux" });
assert.equal(called, false);
assert.deepEqual(result, { success: true, enabled: false, supported: false });
});
test("setAutoLaunchEnabled reports the real (unwritten) state when the write fails but a fallback read succeeds", () => {
const app = {
setLoginItemSettings: () => { throw new Error("registry locked"); },
getLoginItemSettings: () => ({ openAtLogin: false, launchItems: [] }),
};
const result = setAutoLaunchEnabled(true, { app, execPath: EXEC_PATH, defaultApp: false, platform: "win32" });
assert.deepEqual(
result,
{ success: true, enabled: false, supported: true },
"success means \"enabled is trustworthy\", not \"the write succeeded\" — the renderer's push effect relies on " +
"this to roll an optimistic toggle back to the real state instead of leaving it stuck on a failed write",
);
});
test("setAutoLaunchEnabled reports success:false only when the write AND the fallback read both fail", () => {
const app = {
setLoginItemSettings: () => { throw new Error("registry locked"); },
getLoginItemSettings: () => { throw new Error("registry unreadable"); },
};
const result = setAutoLaunchEnabled(true, { app, defaultApp: false, platform: "win32" });
assert.deepEqual(
result,
{ success: false, enabled: false, supported: true },
"a genuine double failure leaves the real state unknown — the renderer must preserve its last-known value",
);
});
test("wasLaunchedHidden detects the --hidden cold-start flag", () => {
assert.equal(wasLaunchedHidden({ argv: ["node", "main.js", "--hidden"], platform: "win32" }), true);
assert.equal(wasLaunchedHidden({ argv: ["node", "main.js"], platform: "win32" }), false);
assert.equal(wasLaunchedHidden({ argv: undefined, platform: "win32" }), false);
});
test("wasLaunchedHidden detects a macOS login-item launch via wasOpenedAtLogin", () => {
const app = { getLoginItemSettings: () => ({ wasOpenedAtLogin: true }) };
const result = wasLaunchedHidden({ argv: ["node", "main.js"], app, platform: "darwin" });
assert.equal(
result,
true,
"openAsHidden/wasOpenedAsHidden are deprecated and stop working on macOS 13+, so wasOpenedAtLogin is the only reliable signal",
);
});
test("wasLaunchedHidden does not consult macOS login-item state on other platforms", () => {
let called = false;
const app = { getLoginItemSettings: () => { called = true; return { wasOpenedAtLogin: true }; } };
const result = wasLaunchedHidden({ argv: ["node", "main.js"], app, platform: "win32" });
assert.equal(result, false);
assert.equal(called, false);
});
test("wasLaunchedHidden tolerates a throwing macOS login-item lookup", () => {
const app = { getLoginItemSettings: () => { throw new Error("boom"); } };
const result = wasLaunchedHidden({ argv: [], app, platform: "darwin" });
assert.equal(result, false);
});
test("registerHandlers wires get/set IPC channels", async () => {
const handlers = new Map();
const ipcMain = { handle: (channel, fn) => handlers.set(channel, fn) };
const app = {
getLoginItemSettings: () => ({ openAtLogin: false, launchItems: [] }),
setLoginItemSettings: () => {},
};
registerHandlers(ipcMain, { app, platform: "win32" });
assert.ok(handlers.has("netcatty:autoLaunch:get"));
assert.ok(handlers.has("netcatty:autoLaunch:set"));
const getResult = await handlers.get("netcatty:autoLaunch:get")();
assert.deepEqual(getResult, { success: true, enabled: false, supported: true });
app.getLoginItemSettings = () => ({ openAtLogin: true, launchItems: [hiddenLaunchItem({ enabled: true })] });
const setResult = await handlers.get("netcatty:autoLaunch:set")(null, { enabled: true });
assert.deepEqual(setResult, { success: true, enabled: true, supported: true });
});
test("registerHandlers respects the real process.platform when no override is given", async () => {
const handlers = new Map();
const ipcMain = { handle: (channel, fn) => handlers.set(channel, fn) };
const app = { getLoginItemSettings: () => ({ openAtLogin: false }) };
registerHandlers(ipcMain, { app });
const result = await handlers.get("netcatty:autoLaunch:get")();
assert.equal(
result.supported,
process.platform === "darwin" || process.platform === "win32",
);
});

View File

@@ -0,0 +1,36 @@
const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = __dirname;
function read(relativePath) {
return readFileSync(path.join(root, relativePath), 'utf8');
}
test('terminal windows do not throttle work while hidden or unfocused', () => {
const windowSources = [
'bridges/windowManager/mainWindow.cjs',
'bridges/windowManager/terminalPopupWindow.cjs',
];
for (const relativePath of windowSources) {
const source = read(relativePath);
assert.match(source, /webPreferences:\s*\{[\s\S]*?backgroundThrottling:\s*false/, relativePath);
}
});
test('non-terminal windows and the app itself do not block ordinary power saving', () => {
const sources = [
'main.cjs',
'bridges/windowManager/settingsWindow.cjs',
'bridges/windowManager/externalWindows.cjs',
'bridges/globalShortcutBridge.cjs',
];
for (const relativePath of sources) {
const source = read(relativePath);
assert.doesNotMatch(source, /backgroundThrottling:\s*false|powerSaveBlocker/, relativePath);
}
});

View File

@@ -0,0 +1,50 @@
"use strict";
/**
* Claude Code auth/config detection helpers (main process).
*
* Claude SDK launches can authenticate from env (ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN)
* or from credentials stored under CLAUDE_CONFIG_DIR (default ~/.claude). We use
* this to turn opaque "-32603 Internal error" failures into an actionable message
* when no auth is configured. NOTE: macOS may store credentials in the Keychain
* rather than a file, so 'none' is a heuristic — callers must NOT hard-block on it;
* only use it to improve the error message after an actual failure.
*/
const { existsSync } = require("node:fs");
const os = require("node:os");
const path = require("node:path");
/**
* Expand a leading "~" to the user's home directory. Env vars handed to a
* child process are NOT shell-expanded, so "~/.claude" would otherwise be
* treated as a literal directory named "~". Only a leading "~", "~/" or "~\"
* is expanded (not "~user"); other values pass through unchanged.
*/
function expandHomePath(p) {
if (typeof p !== "string") return p;
const trimmed = p.trim();
if (trimmed === "~") return os.homedir();
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
return path.join(os.homedir(), trimmed.slice(2));
}
return p;
}
function getClaudeConfigDir(env) {
const custom = typeof env?.CLAUDE_CONFIG_DIR === "string" ? env.CLAUDE_CONFIG_DIR.trim() : "";
return custom ? expandHomePath(custom) : path.join(os.homedir(), ".claude");
}
/**
* @returns {'env'|'credentials-file'|'none'}
*/
function detectClaudeAuthPresence(env, fileExists = existsSync) {
const apiKey = typeof env?.ANTHROPIC_API_KEY === "string" ? env.ANTHROPIC_API_KEY.trim() : "";
const authToken = typeof env?.ANTHROPIC_AUTH_TOKEN === "string" ? env.ANTHROPIC_AUTH_TOKEN.trim() : "";
if (apiKey || authToken) return "env";
if (fileExists(path.join(getClaudeConfigDir(env), ".credentials.json"))) return "credentials-file";
return "none";
}
module.exports = { detectClaudeAuthPresence, getClaudeConfigDir, expandHomePath };

View File

@@ -0,0 +1,56 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const os = require("node:os");
const { detectClaudeAuthPresence, getClaudeConfigDir, expandHomePath } = require("./claudeAuth.cjs");
test("getClaudeConfigDir: defaults to ~/.claude", () => {
assert.equal(getClaudeConfigDir({}), path.join(os.homedir(), ".claude"));
});
test("getClaudeConfigDir: honors CLAUDE_CONFIG_DIR", () => {
assert.equal(getClaudeConfigDir({ CLAUDE_CONFIG_DIR: "/custom/dir" }), "/custom/dir");
});
test("getClaudeConfigDir: expands a leading ~ in CLAUDE_CONFIG_DIR", () => {
assert.equal(
getClaudeConfigDir({ CLAUDE_CONFIG_DIR: "~/.claude-work" }),
path.join(os.homedir(), ".claude-work"),
);
});
test("expandHomePath: expands '~' and '~/...', leaves others unchanged", () => {
assert.equal(expandHomePath("~"), os.homedir());
assert.equal(expandHomePath("~/x/y"), path.join(os.homedir(), "x/y"));
assert.equal(expandHomePath("/abs/path"), "/abs/path");
assert.equal(expandHomePath("~user/x"), "~user/x");
assert.equal(expandHomePath(""), "");
});
test("detectClaudeAuthPresence: ANTHROPIC_API_KEY in env => 'env'", () => {
assert.equal(detectClaudeAuthPresence({ ANTHROPIC_API_KEY: "sk-x" }, () => false), "env");
});
test("detectClaudeAuthPresence: ANTHROPIC_AUTH_TOKEN in env => 'env'", () => {
assert.equal(detectClaudeAuthPresence({ ANTHROPIC_AUTH_TOKEN: "tok" }, () => false), "env");
});
test("detectClaudeAuthPresence: blank env token is ignored", () => {
assert.equal(detectClaudeAuthPresence({ ANTHROPIC_API_KEY: " " }, () => false), "none");
});
test("detectClaudeAuthPresence: credentials file under config dir => 'credentials-file'", () => {
const seen = [];
const result = detectClaudeAuthPresence(
{ CLAUDE_CONFIG_DIR: "/custom/dir" },
(p) => { seen.push(p); return p === path.join("/custom/dir", ".credentials.json"); },
);
assert.equal(result, "credentials-file");
assert.ok(seen.includes(path.join("/custom/dir", ".credentials.json")));
});
test("detectClaudeAuthPresence: nothing => 'none'", () => {
assert.equal(detectClaudeAuthPresence({}, () => false), "none");
});

View File

@@ -0,0 +1,477 @@
/**
* Codex-related helper functions and state.
*
* Manages Codex login sessions, auth validation cache, binary resolution,
* integration state normalization, and error / fingerprint utilities.
*/
"use strict";
const { createHash } = require("node:crypto");
const { existsSync, readFileSync } = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { StringDecoder } = require("node:string_decoder");
const { stripAnsi, extractFirstNonLocalhostUrl, toUnpackedAsarPath } = require("./shellUtils.cjs");
// ── Module-level state ──
const codexLoginSessions = new Map();
let codexValidationCache = null;
const MAX_CODEX_LOGIN_OUTPUT_BYTES = 64 * 1024;
const MAX_CODEX_LOGIN_OUTPUT_CHARS = MAX_CODEX_LOGIN_OUTPUT_BYTES;
const MAX_CODEX_LOGIN_TERMINAL_SESSIONS = 8;
const CODEX_LOGIN_KILL_GRACE_MS = 750;
const CODEX_AUTH_HINTS = [
"not logged in",
"authentication required",
"auth required",
"login required",
"missing credentials",
"no credentials",
"unauthorized",
"forbidden",
"codex login",
"401",
"403",
"invalid_grant",
"invalid_token",
"credentials",
];
// ── Login session helpers ──
function appendCodexLoginOutput(session, chunk) {
const cleanChunk = stripAnsi(chunk);
if (!cleanChunk) return;
const combined = `${session.output || ""}${cleanChunk}`;
if (!session.url) {
session.url = extractFirstNonLocalhostUrl(combined);
}
session.output = retainUtf8Tail(combined, MAX_CODEX_LOGIN_OUTPUT_BYTES);
}
function retainUtf8Tail(value, maxBytes) {
const buffer = Buffer.from(String(value || ""), "utf8");
if (buffer.length <= maxBytes) return String(value || "");
let start = buffer.length - maxBytes;
// Never begin inside a UTF-8 continuation sequence.
while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) start += 1;
return buffer.subarray(start).toString("utf8");
}
function createCodexLoginOutputDecoder(session) {
const decoder = new StringDecoder("utf8");
let ended = false;
return {
write(chunk) {
if (ended) return;
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
appendCodexLoginOutput(session, decoder.write(buffer));
},
end() {
if (ended) return;
ended = true;
appendCodexLoginOutput(session, decoder.end());
},
};
}
function pruneCodexLoginSessions() {
const terminalSessionIds = [];
for (const [sessionId, session] of codexLoginSessions) {
if (session?.state !== "running" && !session?.process) terminalSessionIds.push(sessionId);
}
const excess = terminalSessionIds.length - MAX_CODEX_LOGIN_TERMINAL_SESSIONS;
for (let index = 0; index < excess; index += 1) {
codexLoginSessions.delete(terminalSessionIds[index]);
}
}
function clearCodexLoginKillTimer(session, clearTimeoutFn = clearTimeout) {
if (!session?.killTimer) return;
clearTimeoutFn(session.killTimer);
session.killTimer = null;
}
function stopCodexLoginProcess(session, {
setTimeoutFn = setTimeout,
clearTimeoutFn = clearTimeout,
} = {}) {
const child = session?.process;
if (!child) return false;
clearCodexLoginKillTimer(session, clearTimeoutFn);
session.killTimer = setTimeoutFn(() => {
session.killTimer = null;
if (session.process !== child) return;
try { child.kill("SIGKILL"); } catch {}
}, CODEX_LOGIN_KILL_GRACE_MS);
session.killTimer?.unref?.();
try { child.kill("SIGTERM"); } catch {}
return true;
}
function recordCodexLoginSession(session) {
if (!session?.id) return;
codexLoginSessions.delete(session.id);
codexLoginSessions.set(session.id, session);
pruneCodexLoginSessions();
}
function toCodexLoginSessionResponse(session) {
return {
sessionId: session.id,
state: session.state,
url: session.url,
output: session.output,
error: session.error,
exitCode: session.exitCode,
codexPath: session.codexPath || null,
};
}
function getActiveCodexLoginSession() {
for (const session of codexLoginSessions.values()) {
if (session.state === "running" && session.process && !session.process.killed) {
return session;
}
}
return null;
}
// ── Codex config.toml probing ──
//
// Users who hand-configure `~/.codex/config.toml` with a custom
// `model_provider` + matching `[model_providers.<name>]` entry are fully
// functional from the Codex CLI, but `codex login status` doesn't see them
// because it only reports on `~/.codex/auth.json` (populated by `codex login`).
// We read and minimally parse the config file so we can surface this as a
// valid "ready" state and skip the ChatGPT login prompt in the UI.
/** Find `#` outside quoted regions. Tracks escape state via a flag rather
* than peeking at the previous character, so even runs of backslashes like
* `"C:\\path\\"` close the string correctly. Literal (single-quoted) TOML
* strings don't recognize `\` as an escape, so only honor escapes inside
* basic (double-quoted) strings. */
function findUnquotedHash(value) {
let inStr = false;
let quote = "";
let escaped = false;
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (inStr) {
if (escaped) {
escaped = false;
continue;
}
if (quote === '"' && ch === "\\") {
escaped = true;
continue;
}
if (ch === quote) {
inStr = false;
quote = "";
}
continue;
}
if (ch === '"' || ch === "'") {
inStr = true;
quote = ch;
continue;
}
if (ch === "#") return i;
}
return -1;
}
/**
* Parse the narrow subset of TOML we need from Codex's config.toml:
* - top-level string keys (e.g. `model_provider = "my_provider"`)
* - `[model_providers.<name>]` tables with string-valued keys
* Unsupported TOML features (arrays, inline tables, multi-line strings, etc.)
* are ignored — Codex's config.toml doesn't use them for provider definitions.
*/
function parseCodexConfigToml(text) {
const result = { model_providers: {} };
let currentProvider = null;
let atTopLevel = true;
// Strip UTF-8 BOM so the first key still matches the regex on Windows-edited files.
const normalized = String(text || "").replace(/^\uFEFF/, "");
const lines = normalized.split(/\r?\n/);
for (const rawLine of lines) {
let line = rawLine;
const hashIdx = findUnquotedHash(line);
if (hashIdx >= 0) line = line.slice(0, hashIdx);
line = line.trim();
if (!line) continue;
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
const section = sectionMatch[1].trim();
if (section.startsWith("model_providers.")) {
currentProvider = section.slice("model_providers.".length);
if (!result.model_providers[currentProvider]) {
result.model_providers[currentProvider] = {};
}
atTopLevel = false;
} else {
currentProvider = null;
atTopLevel = false;
}
continue;
}
const kvMatch = line.match(/^([A-Za-z_][\w.-]*)\s*=\s*(.+)$/);
if (!kvMatch) continue;
const key = kvMatch[1];
let raw = kvMatch[2].trim();
let value;
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
value = raw.slice(1, -1);
} else {
value = raw;
}
if (atTopLevel) {
result[key] = value;
} else if (currentProvider) {
result.model_providers[currentProvider][key] = value;
}
}
return result;
}
/**
* Inspect `~/.codex/config.toml` to determine whether the user has
* configured a custom `model_provider` that isn't the built-in OpenAI/ChatGPT
* path.
*
* Returns null when:
* - the config file doesn't exist or can't be read
* - no `model_provider` is set, or it points to the default `openai` preset
* - the referenced provider entry is missing (config is malformed)
*
* Returns a summary object otherwise — even if the env_key isn't currently
* exported in the shell environment. That case is surfaced via
* `envKeyPresent: false` so the UI can warn the user; we don't want the
* absence of an env var to silently fall back to the ChatGPT login flow,
* because the config.toml is a strong signal the user doesn't want that.
*/
function readCodexCustomProviderConfig(shellEnv) {
const home = shellEnv?.HOME || shellEnv?.USERPROFILE || os.homedir();
if (!home) return null;
const configPath = path.join(home, ".codex", "config.toml");
if (!existsSync(configPath)) return null;
let text;
try {
text = readFileSync(configPath, "utf8");
} catch {
return null;
}
let parsed;
try {
parsed = parseCodexConfigToml(text);
} catch {
return null;
}
const activeName = typeof parsed.model_provider === "string"
? parsed.model_provider.trim()
: "";
if (!activeName) return null;
// The built-in "openai" provider still goes through ChatGPT/API-key auth
// managed by `codex login`, so treating it as "custom" would be wrong.
if (activeName === "openai") return null;
const providerEntry = parsed.model_providers?.[activeName];
if (!providerEntry) return null;
const envKeyName = typeof providerEntry.env_key === "string" ? providerEntry.env_key.trim() : "";
const envKeyValue = envKeyName && shellEnv ? String(shellEnv[envKeyName] || "").trim() : "";
const hardcodedApiKey = typeof providerEntry.api_key === "string" ? providerEntry.api_key.trim() : "";
const activeModel = typeof parsed.model === "string" ? parsed.model.trim() : "";
// Hash the actual auth material (either the hardcoded api_key or the
// resolved env_key value) so the SDK backend fingerprint changes when
// the user rotates their key — without ever returning the raw value
// across the IPC boundary.
const authMaterial = hardcodedApiKey || envKeyValue;
const authHash = authMaterial
? createHash("sha256").update(authMaterial).digest("hex")
: null;
return {
providerName: activeName,
displayName: providerEntry.name || activeName,
baseUrl: providerEntry.base_url || null,
envKey: envKeyName || null,
envKeyPresent: Boolean(envKeyValue),
hasHardcodedApiKey: Boolean(hardcodedApiKey),
model: activeModel || null,
authHash,
};
}
/**
* Returns a user-facing error message when a Codex config.toml custom
* provider references an env_key that isn't exported in the shell env and
* doesn't have a hardcoded api_key either — otherwise returns null. Shared
* by every spawn path (stream handler, list-models handler) so users get
* the same actionable message regardless of which one hits first.
*/
function getCodexCustomConfigPreflightError(customConfig) {
if (!customConfig) return null;
if (!customConfig.envKey) return null;
if (customConfig.envKeyPresent || customConfig.hasHardcodedApiKey) return null;
return `Codex is configured to use the "${customConfig.displayName}" provider from ~/.codex/config.toml, but the environment variable ${customConfig.envKey} is not set. Export it in your shell (e.g. add to ~/.zshrc) and click "Refresh Status" in Settings.`;
}
// ── Integration state ──
function normalizeCodexIntegrationState(rawOutput) {
const normalizedOutput = String(rawOutput || "").toLowerCase();
if (normalizedOutput.includes("logged in using chatgpt")) {
return "connected_chatgpt";
}
if (
normalizedOutput.includes("logged in using an api key") ||
normalizedOutput.includes("logged in using api key")
) {
return "connected_api_key";
}
if (normalizedOutput.includes("not logged in")) {
return "not_logged_in";
}
return "unknown";
}
function appendCodexChatGptValidationFailure(rawOutput, validationError) {
return [
String(rawOutput || "").trim(),
"",
"ChatGPT auth validation failed:",
validationError || "Unknown validation error",
].join("\n").trim();
}
// ── Error helpers ──
function safeJsonStringify(value) {
const seen = new WeakSet();
try {
return JSON.stringify(value, (_key, nestedValue) => {
if (typeof nestedValue !== "object" || nestedValue === null) {
return nestedValue;
}
if (seen.has(nestedValue)) {
return "[Circular]";
}
seen.add(nestedValue);
return nestedValue;
});
} catch {
return null;
}
}
function stringifyErrorValue(value, seen = new WeakSet()) {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (value instanceof Error) return value.message || value.name || String(value);
if (typeof value !== "object") return String(value);
if (seen.has(value)) return "[Circular error]";
seen.add(value);
const candidates = [
value?.data?.message,
value?.data?.error,
value?.errorText,
value?.message,
value?.error,
value?.cause,
value?.data,
];
for (const candidate of candidates) {
const message = stringifyErrorValue(candidate, seen).trim();
if (message && message !== "{}") {
return message;
}
}
return safeJsonStringify(value) || String(value);
}
function extractCodexError(error) {
const message = stringifyErrorValue(error) || "Unknown Codex error";
const code = error?.data?.code || error?.code || error?.error?.code || error?.data?.error?.code;
return {
message,
code: typeof code === "string" ? code : undefined,
};
}
function isCodexAuthError(params) {
const searchableText = `${params?.code || ""} ${params?.message || ""} ${params?.error || ""}`.toLowerCase();
return CODEX_AUTH_HINTS.some((hint) => searchableText.includes(hint));
}
// ── Fingerprints ──
function getCodexAuthFingerprint(apiKey) {
const normalized = String(apiKey || "").trim();
if (!normalized) return null;
return createHash("sha256").update(normalized).digest("hex");
}
function getCodexMcpFingerprint(mcpServers) {
return createHash("sha256").update(JSON.stringify(mcpServers || [])).digest("hex");
}
// ── Validation cache ──
function invalidateCodexValidationCache() {
codexValidationCache = null;
}
function getCodexValidationCache() {
return codexValidationCache;
}
function setCodexValidationCache(value) {
codexValidationCache = value;
}
module.exports = {
MAX_CODEX_LOGIN_OUTPUT_BYTES,
MAX_CODEX_LOGIN_OUTPUT_CHARS,
MAX_CODEX_LOGIN_TERMINAL_SESSIONS,
CODEX_LOGIN_KILL_GRACE_MS,
codexLoginSessions,
appendCodexLoginOutput,
createCodexLoginOutputDecoder,
pruneCodexLoginSessions,
recordCodexLoginSession,
clearCodexLoginKillTimer,
stopCodexLoginProcess,
toCodexLoginSessionResponse,
getActiveCodexLoginSession,
normalizeCodexIntegrationState,
appendCodexChatGptValidationFailure,
readCodexCustomProviderConfig,
getCodexCustomConfigPreflightError,
extractCodexError,
isCodexAuthError,
getCodexAuthFingerprint,
getCodexMcpFingerprint,
invalidateCodexValidationCache,
getCodexValidationCache,
setCodexValidationCache,
};

View File

@@ -0,0 +1,179 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
MAX_CODEX_LOGIN_OUTPUT_CHARS,
MAX_CODEX_LOGIN_OUTPUT_BYTES,
MAX_CODEX_LOGIN_TERMINAL_SESSIONS,
CODEX_LOGIN_KILL_GRACE_MS,
appendCodexLoginOutput,
createCodexLoginOutputDecoder,
appendCodexChatGptValidationFailure,
codexLoginSessions,
extractCodexError,
isCodexAuthError,
normalizeCodexIntegrationState,
recordCodexLoginSession,
stopCodexLoginProcess,
} = require("./codexHelpers.cjs");
test("Codex login output keeps a bounded tail", () => {
const session = { output: "", url: null };
appendCodexLoginOutput(session, "a".repeat(MAX_CODEX_LOGIN_OUTPUT_CHARS));
appendCodexLoginOutput(session, "tail-marker");
assert.equal(session.output.length, MAX_CODEX_LOGIN_OUTPUT_CHARS);
assert.match(session.output, /tail-marker$/);
});
test("Codex login output limit is measured in UTF-8 bytes without cutting characters", () => {
const session = { output: "", url: null };
appendCodexLoginOutput(session, "中".repeat(MAX_CODEX_LOGIN_OUTPUT_BYTES));
assert.ok(Buffer.byteLength(session.output, "utf8") <= MAX_CODEX_LOGIN_OUTPUT_BYTES);
assert.doesNotMatch(session.output, /<2F>/u);
assert.match(session.output, /^中+$/u);
});
test("Codex login stdout and stderr decode split UTF-8 independently when interleaved", () => {
const session = { output: "", url: null };
const stdout = createCodexLoginOutputDecoder(session);
const stderr = createCodexLoginOutputDecoder(session);
const outBytes = Buffer.from("中文", "utf8");
const errBytes = Buffer.from("错误", "utf8");
stdout.write(outBytes.subarray(0, 2));
stderr.write(errBytes.subarray(0, 1));
stdout.write(outBytes.subarray(2));
stderr.write(errBytes.subarray(1));
stdout.end();
stderr.end();
assert.equal(session.output, "中文错误");
});
test("Codex login history retains only bounded terminal sessions", (t) => {
t.after(() => codexLoginSessions.clear());
codexLoginSessions.clear();
recordCodexLoginSession({ id: "running", state: "running", process: { killed: false } });
for (let index = 0; index < MAX_CODEX_LOGIN_TERMINAL_SESSIONS + 3; index += 1) {
recordCodexLoginSession({ id: `done-${index}`, state: "success", process: null });
}
assert.equal(codexLoginSessions.has("running"), true);
assert.equal(codexLoginSessions.has("done-0"), false);
assert.equal(codexLoginSessions.size, MAX_CODEX_LOGIN_TERMINAL_SESSIONS + 1);
});
test("a newly completed Codex login remains available while older records are pruned", (t) => {
t.after(() => codexLoginSessions.clear());
codexLoginSessions.clear();
for (let index = 0; index < MAX_CODEX_LOGIN_TERMINAL_SESSIONS; index += 1) {
recordCodexLoginSession({ id: `old-${index}`, state: "success", process: null });
}
const current = { id: "current", state: "running", process: { killed: false } };
recordCodexLoginSession(current);
current.state = "success";
current.process = null;
recordCodexLoginSession(current);
assert.equal(codexLoginSessions.size, MAX_CODEX_LOGIN_TERMINAL_SESSIONS);
assert.equal(codexLoginSessions.has("current"), true);
assert.equal(codexLoginSessions.has("old-0"), false);
});
test("live cancelled login processes stay tracked until they close", (t) => {
t.after(() => codexLoginSessions.clear());
codexLoginSessions.clear();
for (let index = 0; index < MAX_CODEX_LOGIN_TERMINAL_SESSIONS + 2; index += 1) {
recordCodexLoginSession({
id: `cancelled-${index}`,
state: "cancelled",
process: { kill() {} },
});
}
assert.equal(codexLoginSessions.size, MAX_CODEX_LOGIN_TERMINAL_SESSIONS + 2);
});
test("Codex login cancellation escalates from TERM to KILL", () => {
const signals = [];
const scheduled = [];
let escalate;
const session = {
process: { kill: (signal) => signals.push(signal) },
killTimer: null,
};
stopCodexLoginProcess(session, {
setTimeoutFn: (callback, delay) => {
scheduled.push(delay);
escalate = callback;
return { unref() {} };
},
});
escalate();
assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]);
assert.deepEqual(scheduled, [CODEX_LOGIN_KILL_GRACE_MS]);
});
test("normalizeCodexIntegrationState recognizes ChatGPT login status", () => {
assert.equal(
normalizeCodexIntegrationState("Logged in using ChatGPT"),
"connected_chatgpt",
);
});
test("appendCodexChatGptValidationFailure preserves the login status output", () => {
const output = appendCodexChatGptValidationFailure(
"Logged in using ChatGPT",
"SDK probe failed",
);
assert.match(output, /Logged in using ChatGPT/);
assert.match(output, /ChatGPT auth validation failed:/);
assert.match(output, /SDK probe failed/);
assert.equal(normalizeCodexIntegrationState(output), "connected_chatgpt");
});
test("isCodexAuthError recognizes auth failures stored in error text", () => {
assert.equal(
isCodexAuthError({ ok: false, error: "401 Unauthorized: authentication required" }),
true,
);
});
test("extractCodexError preserves nested error object messages", () => {
const normalized = extractCodexError({
error: {
code: "model_not_found",
message: "Model gpt-test is not available",
},
});
assert.deepEqual(normalized, {
message: "Model gpt-test is not available",
code: "model_not_found",
});
});
test("extractCodexError stringifies unknown object errors instead of [object Object]", () => {
const normalized = extractCodexError({
status: 400,
detail: "Bad request",
});
assert.equal(normalized.message, '{"status":400,"detail":"Bad request"}');
assert.equal(normalized.code, undefined);
});
test("extractCodexError handles circular structured errors", () => {
const error = { status: 500 };
error.self = error;
const normalized = extractCodexError(error);
assert.equal(normalized.message, '{"status":500,"self":"[Circular]"}');
});

View File

@@ -0,0 +1,171 @@
"use strict";
/**
* Shell-aware command blocklist helpers shared by the main-process AI exec
* paths (in-app bridge handlers, MCP TCP bridge handlers, terminal worker).
*
* The default table in lib/commandBlocklist.json is grouped into
* common / posix / powershell patterns. Callers pass the best shell kind they
* can resolve for the target session:
* - live session objects: resolveSessionBlocklistShellKind(session) mirrors
* the inputs the AI PTY wrapper uses (confirmed kind, live idle prompt,
* remote login-shell hint)
* - metadata-only paths: meta.shellType (often empty; callers that know a
* downstream authoritative check re-runs the defaults should fall back to
* checkBlocklistCommonOnly instead of the strict full table, so
* POSIX-only patterns never block PowerShell-native commands)
*
* User-added patterns (settings list entries that are not part of the default
* table) always apply, on every shell.
*/
const {
DEFAULT_COMMAND_BLOCKLIST,
COMMON_PATTERNS,
isDefaultBlocklistPattern,
selectDefaultBlocklistPatterns,
} = require("../../../lib/commandBlocklist.cjs");
const { resolveEffectiveShellKind } = require("./ptyExecHelpers.cjs");
const {
getFreshIdlePrompt,
isDefaultPowerShellPromptLine,
isDefaultCmdPromptLine,
isDefaultPosixPromptLine,
stripAnsi,
} = require("./shellUtils.cjs");
function compilePatterns(patterns) {
return patterns
.map((pattern) => {
try {
return { pattern, regex: new RegExp(pattern, "i") };
} catch {
return null;
}
})
.filter(Boolean);
}
const compiledDefaultCache = new Map();
function compiledDefaultPatternsFor(shellKind) {
const key = String(shellKind || "").toLowerCase();
let compiled = compiledDefaultCache.get(key);
if (!compiled) {
compiled = compilePatterns(selectDefaultBlocklistPatterns(key));
compiledDefaultCache.set(key, compiled);
}
return compiled;
}
const compiledCommonPatterns = compilePatterns(COMMON_PATTERNS);
// User blocklists are stable between settings updates; compile each list once.
const compiledUserCache = new WeakMap();
function compiledUserPatterns(blocklist) {
let compiled = compiledUserCache.get(blocklist);
if (!compiled) {
const userPatterns = blocklist.filter(
(pattern) => !isDefaultBlocklistPattern(pattern),
);
compiled = compilePatterns(userPatterns);
compiledUserCache.set(blocklist, compiled);
}
return compiled;
}
function firstMatch(command, compiled) {
for (const { pattern, regex } of compiled) {
if (regex.test(command)) {
return { blocked: true, matchedPattern: pattern };
}
}
return null;
}
function firstEnabledDefaultMatch(command, compiled, enabledPatterns) {
for (const { pattern, regex } of compiled) {
if (enabledPatterns.has(pattern) && regex.test(command)) {
return { blocked: true, matchedPattern: pattern };
}
}
return null;
}
function normalizeConfiguredBlocklist(blocklist) {
return Array.isArray(blocklist) ? blocklist : DEFAULT_COMMAND_BLOCKLIST;
}
/**
* User additions + default patterns selected for shellKind.
* Unknown / empty shell kinds keep the strict full default table.
*/
function checkBlocklistForShell(command, shellKind, configuredBlocklist = DEFAULT_COMMAND_BLOCKLIST) {
const blocklist = normalizeConfiguredBlocklist(configuredBlocklist);
const userMatch = firstMatch(command, compiledUserPatterns(blocklist));
if (userMatch) return userMatch;
return firstEnabledDefaultMatch(
command,
compiledDefaultPatternsFor(shellKind),
new Set(blocklist),
) || { blocked: false };
}
/**
* User additions + shell-independent (common) default patterns only.
* For metadata-only call sites that know a downstream authoritative check
* re-runs the full shell-selected defaults on the live session.
*/
function checkBlocklistCommonOnly(command, configuredBlocklist = DEFAULT_COMMAND_BLOCKLIST) {
const blocklist = normalizeConfiguredBlocklist(configuredBlocklist);
const userMatch = firstMatch(command, compiledUserPatterns(blocklist));
if (userMatch) return userMatch;
return firstEnabledDefaultMatch(command, compiledCommonPatterns, new Set(blocklist)) || { blocked: false };
}
/**
* Best-effort shell kind for a live session, mirroring the inputs the AI PTY
* wrapper uses (ptyExecHelpers.resolveEffectiveShellKind): confirmed shell
* kind, live idle prompt, and the remote login-shell probe hint.
*/
function resolveSessionBlocklistShellKind(session) {
if (!session || typeof session !== "object") return "";
let prompt = null;
try {
prompt = getFreshIdlePrompt(session);
} catch {
prompt = null;
}
try {
const baseKind = session.shellKind === "unknown" ? "" : (session.shellKind || "");
const loginShellHint = session._loginShellKind || "";
const resolved = resolveEffectiveShellKind(baseKind, prompt, { loginShellHint }) || "";
if (baseKind || loginShellHint) return resolved;
const lastPromptLine = stripAnsi(String(prompt || ""))
.replace(/\r/g, "\n")
.split("\n")
.pop()
.replace(/\s+$/, "");
if (
isDefaultPowerShellPromptLine(lastPromptLine)
|| isDefaultCmdPromptLine(lastPromptLine)
|| isDefaultPosixPromptLine(lastPromptLine)
) {
return resolved;
}
// Wrapper selection defaults an unclassified remote session to POSIX.
// Safety keeps it unknown so failed probes retain the strict all-groups
// fallback instead of silently omitting PowerShell rules.
return "";
} catch {
return session.shellKind || session._loginShellKind || "";
}
}
module.exports = {
checkBlocklistForShell,
checkBlocklistCommonOnly,
resolveSessionBlocklistShellKind,
};

View File

@@ -0,0 +1,81 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const {
checkBlocklistForShell,
checkBlocklistCommonOnly,
resolveSessionBlocklistShellKind,
} = require("./commandSafety.cjs");
test("checkBlocklistForShell selects default groups by shell kind", () => {
assert.equal(checkBlocklistForShell("echo $(whoami)", "").blocked, true);
assert.equal(checkBlocklistForShell("echo $(whoami)", "unknown").blocked, true);
assert.equal(checkBlocklistForShell("echo $(whoami)", "posix").blocked, true);
assert.equal(checkBlocklistForShell("echo $(whoami)", "fish").blocked, true);
assert.equal(checkBlocklistForShell('Write-Host "now: $(Get-Date)"', "powershell").blocked, false);
assert.equal(checkBlocklistForShell("Remove-Item -Recurse -Force C:\\x", "powershell").blocked, true);
assert.equal(checkBlocklistForShell("mkfs.ext4 /dev/sda", "powershell").blocked, true);
assert.equal(checkBlocklistForShell("dd if=/dev/zero of=/dev/sda", "powershell").blocked, true);
assert.equal(checkBlocklistForShell("chmod -R 777 /", "powershell").blocked, true);
assert.equal(checkBlocklistForShell("echo $(date)", "cmd").blocked, false);
assert.equal(checkBlocklistForShell("shutdown /r /t 0", "cmd").blocked, true);
assert.equal(checkBlocklistForShell("wsl dd if=/dev/zero of=/dev/sda", "cmd").blocked, true);
assert.equal(checkBlocklistForShell("wsl chmod -R 777 /", "cmd").blocked, true);
});
test("checkBlocklistCommonOnly never applies POSIX or PowerShell patterns", () => {
assert.equal(checkBlocklistCommonOnly("echo $(whoami)").blocked, false);
assert.equal(checkBlocklistCommonOnly("echo `whoami`").blocked, false);
assert.equal(checkBlocklistCommonOnly("Remove-Item -Recurse -Force C:\\x").blocked, false);
assert.equal(checkBlocklistCommonOnly("rm -rf /").blocked, true);
assert.equal(checkBlocklistCommonOnly("shutdown /r /t 0").blocked, true);
});
test("user-added settings patterns always apply regardless of shell kind", () => {
const settingsList = ["forbidden-thing"];
assert.equal(checkBlocklistForShell("forbidden-thing", "powershell", settingsList).blocked, true);
assert.equal(checkBlocklistCommonOnly("forbidden-thing", settingsList).blocked, true);
const withDefaults = ["\\$\\(", "forbidden-thing"];
assert.equal(checkBlocklistCommonOnly("echo $(date)", withDefaults).blocked, false);
assert.equal(checkBlocklistForShell("echo $(date)", "posix", withDefaults).blocked, true);
});
test("configured removal of defaults remains authoritative", () => {
const defaults = require("../../../lib/commandBlocklist.cjs");
const withoutRm = defaults.filter((pattern) => !pattern.startsWith("\\brm\\s+"));
assert.equal(checkBlocklistForShell("rm -rf /", "posix", withoutRm).blocked, false);
assert.equal(checkBlocklistForShell("rm -rf /", "posix", []).blocked, false);
assert.equal(checkBlocklistCommonOnly("rm -rf /", []).blocked, false);
});
test("resolveSessionBlocklistShellKind mirrors the PTY wrapper inputs", () => {
assert.equal(
resolveSessionBlocklistShellKind({ shellKind: "powershell" }),
"powershell",
);
assert.equal(
resolveSessionBlocklistShellKind({
shellKind: "",
lastIdlePrompt: "PS C:\\Users\\dev> ",
_promptTrackTail: "some output\r\nPS C:\\Users\\dev> ",
}),
"powershell",
);
assert.equal(
resolveSessionBlocklistShellKind({ shellKind: "", _loginShellKind: "powershell" }),
"powershell",
);
assert.equal(
resolveSessionBlocklistShellKind({
shellKind: "",
_loginShellKind: "cmd",
lastIdlePrompt: "user@host:~$ ",
_promptTrackTail: "\r\nuser@host:~$ ",
}),
"posix",
);
assert.equal(resolveSessionBlocklistShellKind({}), "");
assert.equal(resolveSessionBlocklistShellKind(null), "");
});

View File

@@ -0,0 +1,37 @@
"use strict";
const { buildBashHistoryCleanup, bashHistoryScratchNames } = require("./ptyExecHelpers.cjs");
// Both fish and POSIX shells accept this command. Inspect the parent of a
// short-lived sh in the interactive PTY, rather than the SSH login shell.
function buildLiveShellProbe(marker) {
const script = 'if test -r "/proc/$PPID/comm"; then IFS= read -r name < "/proc/$PPID/comm"; else name=$(ps -p "$PPID" -o comm= 2>/dev/null); fi; '
+ `printf "${marker}_P:%s\\n" "$name"`;
// command eval bypasses an eval customization; plain eval is the fallback
// when command itself is shadowed. Never invoke a shadowed builtin after the
// command path already succeeded. Both eval bodies remain Bash-guarded.
const cleanup = buildBashHistoryCleanup(marker, true);
const { dispatcher } = bashHistoryScratchNames(marker);
const clear = `[ -z "\${${dispatcher}-}" ]||$${dispatcher} unset ${dispatcher}`;
const fallback = `[ "\${${dispatcher}-}" = command ]||{ ${cleanup}; };${clear}`;
// Continuation lines stay within canonical input limits. Each echo carries
// the marker so the renderer also hides continuation prompts.
return ` true ${marker}; command sh -c '${script}' 2>/dev/null; \\\n: '${marker}'; \\command eval '${cleanup}' 2>/dev/null || true; \\\n: '${marker}'; \\eval '${fallback}' 2>/dev/null || true; \\\n: '${marker}'; \\command eval '${clear}' 2>/dev/null || true; printf '%s' '${marker}_Q'\n`;
}
function parseLiveShellProbe(output, marker) {
const lines = String(output).replace(/\r/g, "\n").split("\n");
if (!lines.some((line) => line.startsWith(`${marker}_Q`))) return null;
for (const line of lines) {
if (!line.startsWith(`${marker}_P:`)) continue;
const name = line.slice(marker.length + 3).trim().split("/").pop().replace(/^-/, "");
return {
kind: name === "fish" ? "fish"
: /^(?:ba|da|z|k|a)?sh$/.test(name) ? "posix" : null,
};
}
return { kind: null };
}
module.exports = { buildLiveShellProbe, parseLiveShellProbe };

View File

@@ -0,0 +1,419 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { buildLiveShellProbe, parseLiveShellProbe } = require('./liveShellProbe.cjs');
const { startPtyJob } = require('./ptyExec.cjs');
test('live shell response excludes echoed commands, stale markers and partial lines', () => {
const marker = '__NCMCP_probe__';
assert.equal(parseLiveShellProbe(buildLiveShellProbe(marker), marker), null);
assert.equal(parseLiveShellProbe(`${marker}_P:fi`, marker), null);
assert.equal(parseLiveShellProbe('__NCMCP_old___P:fish\n', marker), null);
assert.deepEqual(parseLiveShellProbe(`\r${marker}_P:/usr/bin/fish\r\n${marker}_Q`, marker), { kind: 'fish' });
assert.deepEqual(parseLiveShellProbe(`${marker}_P:-zsh\n${marker}_Q`, marker), { kind: 'posix' });
assert.deepEqual(parseLiveShellProbe(`${marker}_P:\n${marker}_Q`, marker), { kind: null });
});
test('probe waits for complete reply before choosing the first wrapper', async () => {
const pty = new EventEmitter();
const writes = [];
pty.write = (data) => writes.push(data);
const job = startPtyJob(pty, 'printf success', { shellKind: 'posix', probeLiveShell: true, timeoutMs: 1000 });
assert.equal(writes.length, 1);
assert.ok(!writes[0].includes('printf success'));
pty.emit('data', `${job.marker}_P:fi`);
assert.equal(writes.length, 1);
pty.emit('data', `sh\r\n${job.marker}_Q`);
assert.equal(writes.length, 2);
assert.ok(writes[1].includes('function __ncmcp_int'));
pty.emit('data', `${job.marker}_S\r\nsuccess\r\n${job.marker}_E:0\r\n`);
assert.equal((await job.resultPromise).exitCode, 0);
});
test('probe wrapper keeps the start marker separate when terminal echo is disabled', async () => {
const { spawnSync } = require('node:child_process');
const pty = new EventEmitter();
let job;
let pendingInput = '';
pty.write = (data) => {
if (String(data).includes('command sh -c')) return;
if (data === '\x03') return;
pendingInput += String(data);
if (!pendingInput.endsWith('\n')) return;
const script = pendingInput.replace(/^\x0b\x15/, '');
pendingInput = '';
const result = spawnSync('/bin/sh', ['-c', script], { encoding: 'utf8' });
queueMicrotask(() => pty.emit('data', `${job.marker}_QPROMPT> ${result.stdout}`));
};
job = startPtyJob(pty, 'printf no-newline-output', { probeLiveShell: true, timeoutMs: 1000 });
pty.emit('data', `${job.marker}_P:sh\n${job.marker}_Q`);
const result = await job.resultPromise;
assert.equal(result.exitCode, 0, JSON.stringify(result));
assert.match(result.stdout, /no-newline-output/);
});
test('cancelled probe never injects user command after a late reply', async () => {
const pty = new EventEmitter();
const writes = [];
pty.write = (data) => writes.push(data);
const job = startPtyJob(pty, 'touch should-not-run', { probeLiveShell: true, timeoutMs: 1000 });
job.cancel();
pty.emit('data', `${job.marker}_P:fish\n${job.marker}_Q`);
pty.emit('close');
await job.resultPromise;
assert.ok(writes.every((data) => !data.includes('touch should-not-run')));
});
test('cancelling a probe completes when the idle prompt returns', async () => {
const pty = new EventEmitter();
pty.write = () => {};
const job = startPtyJob(pty, 'should-not-run', {
probeLiveShell: true, expectedPrompt: 'user@host:~$ ', timeoutMs: 1000,
});
job.cancel();
pty.emit('data', 'user@host:~$ ');
const result = await job.resultPromise;
assert.equal(result.error, 'Cancelled');
});
test('real PTY: first execution in startup fish and return to parent shells', {
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 30000,
}, async () => {
const nodePty = require('node-pty');
const { execViaPty } = require('./ptyExec.cjs');
for (const [parent, startup] of [['/bin/bash', false], ['/bin/zsh', false], ['/bin/bash', true]]) {
const fishCommand = "fish --no-config -C 'function fish_prompt; printf FISH_READY\\>\\ ; end'";
const terminal = nodePty.spawn(parent, startup ? ['-c', `exec ${fishCommand}`] : parent.endsWith('bash') ? ['--noprofile', '--norc'] : ['-f'], {
name: 'dumb', cols: 240, rows: 24,
env: { ...process.env, TERM: 'dumb', PS1: 'PARENT_READY> ', BASH_SILENCE_DEPRECATION_WARNING: '1' },
});
let output = '';
terminal.onData((data) => { output += data; });
const waitFor = async (text) => {
const deadline = Date.now() + 5000;
while (!output.includes(text)) {
if (Date.now() > deadline) throw new Error(`Missing ${text}: ${output.slice(-1500)}`);
await new Promise((resolve) => setTimeout(resolve, 20));
}
output = '';
};
try {
if (!startup) {
await waitFor('PARENT_READY>');
terminal.write('echo earlier-command\r');
await waitFor('PARENT_READY>');
terminal.write(`${fishCommand}\r`);
}
await waitFor('FISH_READY>');
const result = await execViaPty(terminal, 'printf first-command-success', {
loginShellHint: 'posix', probeLiveShell: true, stripMarkers: true, timeoutMs: 3000, enforceWallTimeout: true,
});
assert.equal(result.exitCode, 0, JSON.stringify(result));
assert.match(result.stdout, /first-command-success/);
if (startup) {
await waitFor('FISH_READY>');
terminal.write('set -gx PATH /nonexistent\r');
await waitFor('FISH_READY>');
const fallback = await execViaPty(terminal, 'printf fallback-success', {
shellKind: 'fish', probeLiveShell: true, timeoutMs: 3000,
});
assert.equal(fallback.exitCode, 0, JSON.stringify(fallback));
assert.match(fallback.stdout, /fallback-success/);
continue;
}
await waitFor('FISH_READY>');
terminal.write('exit\r');
await waitFor('PARENT_READY>');
const returned = await execViaPty(terminal, 'printf parent-command-success', {
loginShellHint: 'fish', probeLiveShell: true, stripMarkers: true, timeoutMs: 3000,
});
assert.equal(returned.exitCode, 0, JSON.stringify(returned));
assert.match(returned.stdout, /parent-command-success/);
} finally {
terminal.kill();
}
}
});
test('real PTY: echo-disabled bash completes output without a trailing newline', {
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 10000,
}, async () => {
const pty = require('node-pty').spawn('/bin/bash', ['--noprofile', '--norc', '--noediting'], {
name: 'dumb', cols: 240, rows: 24,
env: { ...process.env, TERM: 'dumb', PS1: 'NOECHO_READY> ', BASH_SILENCE_DEPRECATION_WARNING: '1' },
});
let output = '';
pty.onData((data) => { output += data; });
const ready = async () => {
const deadline = Date.now() + 3000;
while (!output.includes('NOECHO_READY>')) {
if (Date.now() > deadline) throw new Error('No echo-disabled shell prompt');
await new Promise((resolve) => setTimeout(resolve, 10));
}
output = '';
};
try {
await ready();
pty.write('stty -echo\n');
await ready();
const result = await require('./ptyExec.cjs').execViaPty(pty, 'printf noecho-success', {
shellKind: 'posix', probeLiveShell: true, timeoutMs: 2000,
});
assert.equal(result.exitCode, 0, JSON.stringify(result));
assert.match(result.stdout, /noecho-success/);
assert.ok(!output.includes('command sh -c'), 'the terminal must actually suppress input echo');
} finally {
pty.kill();
}
});
for (const editing of [true, false]) {
test(`real PTY: pending input clearing leaves no control command (editing=${editing})`, {
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 5000,
}, async () => {
const pty = require('node-pty').spawn('/bin/bash', ['--noprofile', '--norc', ...(editing ? [] : ['--noediting'])], {
name: 'dumb', cols: 240, rows: 24,
env: { ...process.env, TERM: 'dumb', PS1: 'CLEAR_READY> ', HISTFILE: '/dev/null', BASH_SILENCE_DEPRECATION_WARNING: '1' },
});
let output = '';
pty.onData(data => { output += data; });
try {
const waitForPrompt = async () => {
const deadline = Date.now() + 3000;
while (!output.includes('CLEAR_READY>')) {
if (Date.now() > deadline) throw new Error(`Missing prompt: ${output}`);
await new Promise(resolve => setTimeout(resolve, 10));
}
};
await waitForPrompt();
output = '';
// Leave text on both sides of the readline cursor. Without editing, all
// bytes remain pending canonical input and must still be discarded.
pty.write('left-right\x1b[5D');
await new Promise(resolve => setTimeout(resolve, 50));
const { buildPendingInputClearPrefix } = require('./ptyExecHelpers.cjs');
pty.write(buildPendingInputClearPrefix('posix') + "printf 'CLEAR_SUCCESS\\n'\n");
await waitForPrompt();
assert.match(output, /CLEAR_SUCCESS\r\n/);
assert.doesNotMatch(output, /command not found|syntax error/);
} finally {
pty.kill();
}
});
}
test('real PTY: canonical bash executes a long literal command without truncation', {
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 15000,
}, async () => {
const pty = require('node-pty').spawn('/bin/bash', ['--noprofile', '--norc', '--noediting'], {
name: 'dumb', cols: 240, rows: 24,
env: { ...process.env, TERM: 'dumb', PS1: 'CANONICAL_READY> ', HISTFILE: '/dev/null', BASH_SILENCE_DEPRECATION_WARNING: '1' },
});
let output = '';
pty.onData((data) => { output += data; });
try {
const deadline = Date.now() + 3000;
while (!output.includes('CANONICAL_READY>')) {
if (Date.now() > deadline) throw new Error('Missing canonical shell prompt');
await new Promise((resolve) => setTimeout(resolve, 10));
}
const literal = 'x'.repeat(12000);
const result = await require('./ptyExec.cjs').execViaPty(pty, `printf '%s' '${literal}'`, {
shellKind: 'posix', probeLiveShell: true, timeoutMs: 1500,
});
assert.equal(result.exitCode, 0, JSON.stringify({ ...result, stdout: result.stdout?.slice(-200) }));
assert.equal(result.stdout.trim(), literal);
} finally {
pty.kill();
}
});
// The second element is a history-listing invocation that still reaches the
// real history builtin under that customization.
for (const [customization, listHistory] of [
[':', 'command builtin history'],
['HISTCONTROL=ignorespace', 'command builtin history'],
["alias history='history 10'", 'command builtin history'],
['history() { :; }', 'command builtin history'],
['history() { builtin history "$@" | cat; }', 'command builtin history'],
["alias eval=':'", 'command builtin history'],
['eval() { :; }', 'command builtin history'],
['PATH=/nonexistent', 'command builtin history'],
["alias builtin=':'", 'command builtin history'],
['builtin() { :; }', 'command builtin history'],
["alias command=':'", '\\builtin history'],
['command() { :; }', '\\builtin history'],
]) {
for (const executeCommand of [false, true]) {
test(`bash probe keeps user history clean (${customization}, wrapper=${executeCommand})`, () => {
const { spawnSync } = require('node:child_process');
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
const marker = '__NCMCP_HISTORY_PROBE__';
const input = `HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=\n${customization}\n${listHistory} -c\necho user_one\necho user_two\n`
+ buildLiveShellProbe(marker)
+ (executeCommand ? buildWrappedCommand('echo command_ok', 'posix', marker, true) : '')
+ '\nprintf \"\\n\"\n' + listHistory + '\nexit\n';
const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-i'], {
input, encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
});
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes(`${marker}_Q`), result.stdout);
const entries = result.stdout.split('\n').filter(line => /^\s*\d+\s/.test(line));
assert.ok(entries.some(line => line.includes('echo user_one')), entries.join('\n'));
assert.ok(entries.some(line => line.includes('echo user_two')), entries.join('\n'));
assert.ok(entries.every(line => !line.includes(marker)), entries.join('\n'));
});
}
}
test('real PTY: probe and execution leave only user commands for arrow recall', {
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 10000,
}, async () => {
const terminal = require('node-pty').spawn('/bin/bash', ['--noprofile', '--norc'], {
name: 'dumb', cols: 240, rows: 24,
env: { ...process.env, TERM: 'dumb', HISTFILE: '/dev/null', HISTCONTROL: '',
PS1: 'HISTORY_READY> ', BASH_SILENCE_DEPRECATION_WARNING: '1' },
});
let output = '';
terminal.onData(data => { output += data; });
const waitFor = async text => {
const deadline = Date.now() + 3000;
while (!output.includes(text)) {
if (Date.now() > deadline) throw new Error(`Missing ${text}: ${output.slice(-1500)}`);
await new Promise(resolve => setTimeout(resolve, 10));
}
const result = output;
output = '';
return result;
};
try {
await waitFor('HISTORY_READY>');
terminal.write('builtin history -c\r');
await waitFor('HISTORY_READY>');
terminal.write('echo user_history_one\r');
await waitFor('HISTORY_READY>');
terminal.write('echo user_history_two\r');
await waitFor('HISTORY_READY>');
const result = await require('./ptyExec.cjs').execViaPty(terminal, 'printf agent_success', {
shellKind: 'posix', probeLiveShell: true, timeoutMs: 2000,
});
assert.equal(result.exitCode, 0, JSON.stringify(result));
assert.match(result.stdout, /agent_success/);
await waitFor('HISTORY_READY>');
terminal.write('\x1b[A\r');
const recalled = await waitFor('HISTORY_READY>');
assert.match(recalled, /user_history_two/);
assert.doesNotMatch(recalled, /__NCMCP_/);
terminal.write('builtin history\r');
const history = await waitFor('HISTORY_READY>');
assert.match(history, /echo user_history_one/);
assert.match(history, /echo user_history_two/);
assert.doesNotMatch(history, /__NCMCP_/);
} finally {
terminal.kill();
}
});
for (const invocationName of ['sh', 'renamed-bash']) {
test(`Bash invoked as ${invocationName} cleans probe history`, () => {
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const directory = require('../tempDirBridge.cjs').getTempFilePath('probe-bash');
fs.mkdirSync(directory, { mode: 0o700 });
try {
const shell = path.join(directory, invocationName);
fs.symlinkSync('/bin/bash', shell);
const marker = '__NCMCP_RENAMED_BASH__';
const result = spawnSync(shell, ['--noprofile', '--norc', '-i'], {
input: 'HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=\nbuiltin history -c\necho preserve_user_history\n'
+ buildLiveShellProbe(marker) + '\nprintf "\\n"\ncommand builtin history\nexit\n',
encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
});
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes(`${marker}_Q`), result.stdout);
const entries = result.stdout.split('\n').filter(line => /^\s*\d+\s/.test(line));
assert.ok(entries.some(line => line.includes('echo preserve_user_history')), result.stdout);
assert.ok(entries.every(line => !line.includes(marker)), result.stdout);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
}
for (const [shell, args] of [
['/bin/dash', ['-i']],
['/bin/zsh', ['-f', '-i']],
['/bin/bash', ['--noprofile', '--norc', '-i']],
]) {
test(`probe preserves an interactive errexit session in ${shell}`, (t) => {
const { spawnSync } = require('node:child_process');
const marker = '__NCMCP_ERREXIT_PROBE__';
const result = spawnSync(shell, args, {
input: 'set -e\n' + buildLiveShellProbe(marker) + '\necho shell_survived\nexit\n',
encoding: 'utf8', env: { ...process.env, TERM: 'dumb', HISTFILE: '/dev/null' }, timeout: 5000,
});
if (result.error?.code === 'ENOENT') return t.skip(`${shell} is unavailable`);
assert.ifError(result.error);
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes(`${marker}_Q`), result.stdout);
assert.ok(result.stdout.includes('shell_survived'), result.stdout);
});
}
for (const shell of ['/bin/dash', '/bin/zsh']) {
test(`history cleanup keeps the execution wrapper portable in ${shell}`, { skip: !require('node:fs').existsSync(shell) }, () => {
const { spawnSync } = require('node:child_process');
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
const marker = '__NCMCP_PORTABLE_HISTORY__';
const result = spawnSync(shell, ['-c', buildWrappedCommand('echo portable-history-ok', 'posix', marker, true)], { encoding: 'utf8', timeout: 5000 });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /portable-history-ok/);
assert.ok(result.stdout.includes(`${marker}_E:0`));
});
}
for (const historyControl of ['', 'ignorespace']) {
test(`successful cleanup does not invoke a shadowed builtin (${historyControl || 'default'})`, () => {
const { spawnSync } = require('node:child_process');
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
const marker = '__NCMCP_FALLBACK_GUARD__';
const input = `HISTFILE=/dev/null; HISTCONTROL=${historyControl}; PS1=; PS2=\nbuiltin() { printf UNEXPECTED_FALLBACK; }\ncommand history -c\necho user_one\n`
+ buildLiveShellProbe(marker)
+ buildWrappedCommand('echo command_ok', 'posix', marker, true)
+ '\ncommand history\nexit\n';
const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-i'], {
input, encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
});
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes(`${marker}_Q`), result.stdout);
assert.match(result.stdout, /command_ok/);
assert.doesNotMatch(result.stdout, /UNEXPECTED_FALLBACK/);
const entries = result.stdout.split('\n').filter(line => /^\s*\d+\s/.test(line));
assert.ok(entries.some(line => line.includes('echo user_one')), entries.join('\n'));
assert.ok(entries.every(line => !line.includes(marker)), entries.join('\n'));
});
}
for (const stop of ['cancel', 'timeout']) {
test(`paced probe stops writing after ${stop}`, async () => {
const pty = new EventEmitter();
const writes = [];
pty.write = data => writes.push(data);
const job = startPtyJob(pty, 'echo must_not_run', {
shellKind: 'posix', probeLiveShell: true, timeoutMs: stop === 'timeout' ? 70 : 1000,
enforceWallTimeout: stop === 'timeout',
});
await new Promise(resolve => setTimeout(resolve, 45));
assert.ok(writes.length >= 2, 'probe must have started a later chunk');
if (stop === 'cancel') {
job.cancel();
pty.emit('close');
}
await job.resultPromise;
const count = writes.length;
await new Promise(resolve => setTimeout(resolve, 100));
assert.equal(writes.length, count);
assert.ok(writes.every(data => !data.includes('must_not_run')));
});
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,510 @@
"use strict";
const { StringDecoder } = require("node:string_decoder");
const iconv = require("iconv-lite");
const {
stripAnsi,
isDefaultPowerShellPromptLine,
isDefaultCmdPromptLine,
isDefaultPosixPromptLine,
} = require("./shellUtils.cjs");
const { classifyLocalShellType } = require("../../../lib/localShell.cjs");
// Build a stateful decoder for a full exec call. Serial data events can
// split multi-byte characters across chunks (very common on GBK/GB18030
// consoles), and a stateless iconv.decode per chunk would emit
// replacement bytes for the leading half. StringDecoder and
// iconv.getDecoder both preserve partial-byte state across write() calls
// and flush any trailing bytes on end(), which is what we need.
function createStatefulDecoder(encoding) {
const enc = encoding || "utf8";
if (Buffer.isEncoding(enc)) {
return new StringDecoder(enc);
}
try {
return iconv.getDecoder(enc);
} catch {
return new StringDecoder("utf8");
}
}
function detectShellKind(shellPath, platform = process.platform) {
return classifyLocalShellType(shellPath, platform);
}
function subscribeToPtyData(ptyStream, onData) {
if (typeof ptyStream?.onData === "function") {
const disposable = ptyStream.onData((data) => onData(data));
return () => {
try {
disposable?.dispose?.();
} catch {
// Ignore cleanup failures
}
};
}
if (typeof ptyStream?.on === "function" && typeof ptyStream?.removeListener === "function") {
ptyStream.on("data", onData);
return () => {
try {
ptyStream.removeListener("data", onData);
} catch {
// Ignore cleanup failures
}
};
}
throw new Error("PTY stream does not support data subscriptions");
}
function hasExpectedPromptSuffix(text, expectedPrompt) {
if (!expectedPrompt) return false;
const normalizedText = stripAnsi(String(text || "")).replace(/\r/g, "");
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
return !!normalizedPrompt && normalizedText.endsWith(normalizedPrompt);
}
function escapePosixSingleQuoted(text) {
return String(text || "").replace(/'/g, "'\\''");
}
function escapePowerShellSingleQuoted(text) {
return String(text || "").replace(/'/g, "''");
}
function escapeFishSingleQuoted(text) {
return String(text || "").replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}
function escapeCmdForNestedShell(text) {
return String(text || "").replace(/"/g, '""').replace(/%/g, "%%");
}
// Matches PowerShell's default prompt only (e.g. `PS C:\Users\alice>`,
// `PS>`). Custom prompt functions (oh-my-posh, starship, PSReadLine themes
// that emit ``/`λ`/etc.) intentionally fall through — we'd rather miss
// the override than wrap a fish/zsh prompt as PowerShell. Pattern lives
// in shellUtils.cjs so prompt extraction and wrapper selection share one
// source of truth.
function isPowerShellPrompt(prompt) {
// Treat `\r` as a line break too so a PSReadLine/ConPTY redraw like
// `PS C:\old>\rPS C:\new>` is matched against the redrawn last line,
// not the doubled string.
const lastLine = stripAnsi(String(prompt || ""))
.replace(/\r/g, "\n")
.split("\n")
.pop()
.replace(/\s+$/, "");
return isDefaultPowerShellPromptLine(lastLine);
}
function isCmdPrompt(prompt) {
const lastLine = stripAnsi(String(prompt || ""))
.replace(/\r/g, "\n")
.split("\n")
.pop()
.replace(/\s+$/, "");
return isDefaultCmdPromptLine(lastLine);
}
function isPosixPrompt(prompt) {
const lastLine = stripAnsi(String(prompt || ""))
.replace(/\r/g, "\n")
.split("\n")
.pop()
.replace(/\s+$/, "");
return isDefaultPosixPromptLine(lastLine);
}
// Prompt-driven override is intentionally narrow: only flip to PowerShell
// when the session has no confirmed shell type. This keeps the issue #841
// fix working for remote Windows shells that never set shellKind at connect
// time, while preventing a malicious remote process from spoofing a
// `PS ...>` line on a real bash/zsh/fish/cmd session to coerce a single
// mis-wrapped command.
//
// Remote login-shell probing stores a *soft* hint (`loginShellHint` /
// session._loginShellKind) without pinning session.shellKind:
// - hint "fish" → fish wrapper (issue #1854) without permanent pin
// - hint "posix" → native posix wrapper evaluated by interactive bash/zsh
// (NOT sh -c / dash — Codex P2 on #2061)
// - hint "powershell" / "cmd" → Windows DefaultShell (issue #2959) without
// permanent pin, so a live opposing PS/cmd prompt can still win, and a
// live `user@host:...$` POSIX prompt (e.g. WSL nesting) can override too
// - live PS ...> still overrides when base kind is open
// - live C:\...> selects cmd when base kind is open (Windows OpenSSH default)
//
// Universe of shellKind values (see lib/localShell.cjs:23-33 and
// terminalBridge.cjs:368, :932, :1074):
// "posix" | "powershell" | "cmd" | "fish" | "unknown" | "raw" | "" | undefined
// Excluded on purpose from prompt override:
// - "posix" / "fish" / "cmd" / "powershell": confirmed local/spawn kinds —
// never override (anti-spoof for #841).
// - "raw": serial / network device — execViaRawPty bypasses buildWrappedCommand.
const SHELL_KINDS_OPEN_TO_PROMPT_OVERRIDE = new Set([
"",
"unknown",
]);
const LOGIN_SHELL_HINTS = new Set(["posix", "fish", "powershell", "cmd"]);
function resolveEffectiveShellKind(shellKind, expectedPrompt, options = {}) {
const baseKind = shellKind || "";
const hint = options.loginShellHint || "";
if (SHELL_KINDS_OPEN_TO_PROMPT_OVERRIDE.has(baseKind)) {
if (isPowerShellPrompt(expectedPrompt)) {
return "powershell";
}
if (isCmdPrompt(expectedPrompt)) {
return "cmd";
}
// Windows OpenSSH DefaultShell soft hint + nested WSL/bash: live
// `user@host:...$` must win so AI does not type a PS/cmd wrapper into
// a POSIX shell and hang on markers. Fish/posix soft hints stay put —
// those login shells already share this prompt family.
if (
isPosixPrompt(expectedPrompt)
&& (hint === "powershell" || hint === "cmd")
) {
return "posix";
}
}
if (baseKind) return baseKind;
// Soft login-shell hint from remote probe (not a permanent pin).
if (LOGIN_SHELL_HINTS.has(hint)) return hint;
return "posix";
}
// Discard unfinished prompt-line input before the agent wrapper so typed-but-
// not-entered text is not concatenated onto the injected command (#2962).
// Raw/serial devices have no portable line-kill binding; leave them alone.
function buildPendingInputClearPrefix(shellKind) {
switch (shellKind) {
case "raw":
return "";
case "cmd":
return "\x1b";
case "powershell":
// Vi gg plus a counted dd removes the whole multiline buffer, including
// in PSReadLine 2.0 where dG is unavailable. Escape+r is Emacs
// RevertLine. Repeated Escape clears Windows mode, and the final
// i+Backspace leaves every mode on an empty editable line.
return "\x1bggd2147483647d\x1br\x1b\x1bi\x08";
default:
// Kill the suffix before the prefix. Canonical/no-editing terminals do
// not bind Ctrl+K; the trailing Ctrl+U must erase that literal byte too.
return "\x0b\x15";
}
}
function bashHistoryScratchNames(marker) {
const suffix = String(marker || "").toLowerCase().replace(/[^a-z0-9]/g, "").slice(-12) || "dflt";
return { entry: `__nc_h_${suffix}`, dispatcher: `__nc_d_${suffix}` };
}
function buildBashHistoryCleanup(marker, keepDispatcher = false) {
// Expanded dispatcher names bypass aliases. Verify that a dispatcher really
// executes builtins before trusting an empty history read from a no-op function.
// After deletion, verify the entry is gone: a shadowed history function may
// delete only in a subshell. Stop as soon as the real dispatcher succeeds.
// Invocation-specific scratch names avoid readonly user variables. Clear the
// history-bearing scratch through the verified dispatcher, never plain unset.
const { entry, dispatcher } = bashHistoryScratchNames(marker);
const unsetNames = keepDispatcher ? entry : `${entry} ${dispatcher}`;
return `[ "\${BASH_VERSION-}" ]&&{ for ${dispatcher} in command builtin;do ${entry}=$($${dispatcher} printf x);[ "$${entry}" = x ]||continue;${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*) ${entry}=\${${entry}#"\${${entry}%%[^[:space:]]*}"};$${dispatcher} history -d "\${${entry}%%[[:space:]]*}";${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*) continue;;esac;;esac;$${dispatcher} unset ${unsetNames};break;done; } 2>/dev/null`;
}
function buildPosixWrapperBody(command, marker, startFormat) {
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
const commandLines = String(command || "").replace(/\r\n?/g, "\n").split("\n");
let cmdAssign = commandLines.length > 1
? `${marker}_cmd=$(printf '%s\\n' ${commandLines.map((line) => `'${escapePosixSingleQuoted(line)}'`).join(" ")})`
: `${marker}_cmd='${escapePosixSingleQuoted(command)}'`;
if (Buffer.byteLength(cmdAssign, 'utf8') > 650) {
// Canonical PTYs limit bytes per physical input line, regardless of write
// pacing. Emit bounded quoted pieces inside one command substitution; each
// continuation keeps the marker visible to the terminal echo filter.
const writes = [];
for (const [index, line] of commandLines.entries()) {
let chunk = '';
let bytes = 0;
for (const character of line) {
const quoted = escapePosixSingleQuoted(character);
const size = Buffer.byteLength(quoted, 'utf8');
if (bytes + size > 512) {
writes.push(`printf '%s' '${chunk}'`);
chunk = '';
bytes = 0;
}
chunk += quoted;
bytes += size;
}
writes.push(`printf '${index < commandLines.length - 1 ? '%s\\n' : '%s'}' '${chunk}'`);
}
cmdAssign = `${marker}_cmd=$(${writes.join(`; \\\n: '${marker}'; `)})`;
}
const historyCleanup = buildBashHistoryCleanup(marker);
const prefix = `${marker}=0; ${cmdAssign}; { printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"`;
const suffix = `${historyCleanup}; (exit $__NCMCP_rc); }`;
const separator = prefix.length + suffix.length + 2 > 1000
? `; \\\n: '${marker}'; ` : "; ";
return `${prefix}${separator}${suffix}`;
}
function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false) {
// A live probe leaves its completion marker unterminated to hide the next
// prompt. With terminal echo disabled, only the wrapper can end that line.
const startFormat = separateStartMarker ? "\\n%s\\n" : "%s\\n";
switch (shellKind) {
case "powershell": {
const psPager = "$env:PAGER='cat'; $env:SYSTEMD_PAGER=''; $env:GIT_PAGER='cat'; $env:LESS=''; ";
const psEscaped = escapePowerShellSingleQuoted(command);
return (
`$${marker}=0; $${marker}_cmd='${psEscaped}'; & { Write-Output '${marker}_S'; ${psPager}$LASTEXITCODE=$null; try { Invoke-Expression $${marker}_cmd; $${marker}_rc = if ($LASTEXITCODE -ne $null) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 } } catch { $${marker}_rc = 1 }; Write-Output "${marker}_E:$${marker}_rc" }\r\n`
);
}
case "cmd": {
const cmdEscaped = escapeCmdForNestedShell(command);
return (
`set "${marker}=0" & set "${marker}_CMD=${cmdEscaped}" & (echo ${marker}_S & set "PAGER=cat" & set "SYSTEMD_PAGER=" & set "GIT_PAGER=cat" & set "LESS=" & call cmd /d /s /c "%${marker}_CMD%" & call echo ${marker}_E:^%errorlevel^%)\r\n`
);
}
case "fish":
// Leading space: see the comment in the POSIX branch below. Fish
// does not skip leading-space commands by default, but users can
// define a `fish_should_add_to_history` function that filters them
// — this prefix is what lets that opt-in actually take effect.
return (
` set ${marker} 0; function __ncmcp_int --on-signal INT; printf '%s\\n' '${marker}_E:130'; functions -e __ncmcp_int; end; ` +
`set -l ${marker}_cmd '${escapeFishSingleQuoted(command)}'; ` +
`begin; set -gx PAGER cat; set -gx SYSTEMD_PAGER ''; set -gx GIT_PAGER cat; set -gx LESS ''; ` +
`printf '${startFormat}' '${marker}_S'; eval \$${marker}_cmd; set __NCMCP_rc $status; ` +
`functions -e __ncmcp_int; printf '%s\\n' '${marker}_E:'\$__NCMCP_rc; end\n`
);
case "posix":
default: {
// Compound command with an early marker on each physical line.
//
// Layout: __NCMCP_xxx=0; { ... MARKER_S; eval command; MARKER_E; }
//
// Key design decisions:
//
// 1) __NCMCP_xxx=0 at the VERY START ensures the PTY echo line
// contains __NCMCP_ in its first few bytes. This is critical:
// preload.cjs filters chunks by buffering incomplete lines that
// contain __NCMCP_. Without this prefix, the first chunk of a
// long echo line might not contain the marker and would leak
// through to the terminal as garbage.
//
// 2) The user command is executed via eval on a quoted string. This
// keeps shell syntax errors inside the eval call so the wrapper
// can still emit the end marker and return a non-zero exit code.
//
// 3) The complete { ... } group is parsed before execution, so SIGINT
// cannot cause bash to flush the end marker from the input buffer.
// trap ':' INT lets child processes receive SIGINT normally while
// preventing the shell from aborting the compound command.
//
// 4) The eval runs inside a subshell ( ... ) so shell-terminating
// constructs in the generated command — set -e / set -o errexit
// followed by a failure, exit, shell option changes, traps,
// function/alias definitions — end or mutate only the subshell,
// never the user's active login shell (issue #1850). set -e still
// behaves normally *inside* the command, and the subshell shares
// the PTY so the user sees all output live. The intentional
// trade-off is that cd/export no longer persist into the user's
// shell or across agent commands; the terminal.execute tool
// description tells the model to combine cd with its command.
// Earlier attempts (PRs #1852/#1882) that instead tried to detect
// dangerous commands grew into shell parsing and were abandoned —
// do not reintroduce detection here.
//
// Leading single space: lets bash/zsh skip recording this command
// in history when the user already has HISTCONTROL=ignorespace
// (bash) or HIST_IGNORE_SPACE (zsh) configured — Debian/Ubuntu and
// most Oh-My-Zsh setups have this on by default; CentOS/RHEL users
// can opt in by adding `HISTCONTROL=ignoreboth` to ~/.bashrc.
// Without that config the prefix is harmless; it just doesn't
// suppress history recording.
return ` ${buildPosixWrapperBody(command, marker, startFormat)}\n`;
}
}
}
function findEndMarker(outputText, marker, { allowInline = false } = {}) {
const endPattern = marker + "_E:";
let searchFrom = 0;
while (searchFrom < outputText.length) {
const endIdx = outputText.indexOf(endPattern, searchFrom);
if (endIdx === -1) return null;
// Before the start marker is confirmed, require a line boundary so the
// echoed wrapper command cannot be mistaken for real completion. Once the
// command has started, the random marker can safely follow output that did
// not end with a newline.
if (allowInline || endIdx === 0 || outputText[endIdx - 1] === "\n" || outputText[endIdx - 1] === "\r") {
const afterEnd = outputText.slice(endIdx + endPattern.length);
const codeMatch = afterEnd.match(/^(\d+)/);
const exitCode = codeMatch ? parseInt(codeMatch[1], 10) : null;
if (exitCode !== null) {
return { endIdx, exitCode };
}
}
searchFrom = endIdx + 1;
}
return null;
}
function normalizePtyOutput(stdout, {
stripMarkers = false,
expectedPrompt = "",
trimOutput = true,
stripPrompt = true,
markerToStrip = null,
} = {}) {
let cleaned = stripAnsi(stdout || "").replace(/\r/g, "");
if (stripMarkers) {
// Prefer the job-specific marker so user output that contains "__NCMCP_"
// (e.g. printf '__NCMCP_demo\n') is preserved.
const pattern = markerToStrip
? new RegExp(`^[^\r\n]*${markerToStrip}[^\r\n]*[\r\n]*`, "gm")
: /^[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/gm;
cleaned = cleaned.replace(pattern, "");
}
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
if (stripPrompt && normalizedPrompt && cleaned.endsWith(normalizedPrompt)) {
cleaned = cleaned.slice(0, cleaned.length - normalizedPrompt.length);
}
return trimOutput ? cleaned.trim() : cleaned;
}
function appendBoundedOutput(current, chunk, maxBufferedChars) {
const limit = Number.isFinite(maxBufferedChars) ? Math.max(0, Math.floor(maxBufferedChars)) : 0;
const currentText = String(current || "");
const chunkText = String(chunk || "");
if (limit > 0 && chunkText.length >= limit) {
return {
text: chunkText.slice(-limit),
dropped: currentText.length + chunkText.length - limit,
};
}
const combined = `${currentText}${chunkText}`;
if (limit <= 0 || combined.length <= limit) {
return { text: combined, dropped: 0 };
}
const dropped = combined.length - limit;
return {
text: combined.slice(dropped),
dropped,
};
}
function consumeVisibleText(carry, chunk) {
const input = `${carry || ""}${chunk || ""}`;
if (!input) {
return { visibleText: "", carry: "" };
}
let visibleText = "";
let index = 0;
while (index < input.length) {
const ch = input[index];
if (ch === "\r") {
// Preserve \r so consumers / serializers can collapse progress-bar
// redraws to the latest frame. \r\n becomes a single \n.
if (input[index + 1] === "\n") {
visibleText += "\n";
index += 2;
continue;
}
visibleText += "\r";
index += 1;
continue;
}
if (ch !== "\u001b") {
visibleText += ch;
index += 1;
continue;
}
if (index + 1 >= input.length) {
break;
}
const next = input[index + 1];
if (next === "[") {
let cursor = index + 2;
let complete = false;
while (cursor < input.length) {
const code = input.charCodeAt(cursor);
if (code >= 0x40 && code <= 0x7e) {
index = cursor + 1;
complete = true;
break;
}
cursor += 1;
}
if (!complete) break;
continue;
}
if (next === "]") {
let cursor = index + 2;
let complete = false;
while (cursor < input.length) {
const oscChar = input[cursor];
if (oscChar === "\u0007") {
index = cursor + 1;
complete = true;
break;
}
if (oscChar === "\u001b") {
if (cursor + 1 >= input.length) break;
if (input[cursor + 1] === "\\") {
index = cursor + 2;
complete = true;
break;
}
}
cursor += 1;
}
if (!complete) break;
continue;
}
visibleText += ch;
index += 1;
}
return {
visibleText,
carry: input.slice(index),
};
}
module.exports = {
createStatefulDecoder,
detectShellKind,
subscribeToPtyData,
hasExpectedPromptSuffix,
resolveEffectiveShellKind,
buildPendingInputClearPrefix,
buildWrappedCommand,
buildBashHistoryCleanup,
bashHistoryScratchNames,
findEndMarker,
normalizePtyOutput,
appendBoundedOutput,
consumeVisibleText,
stripAnsi,
};

View File

@@ -0,0 +1,62 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { startPtyJob } = require('./ptyExec.cjs');
const { buildLiveShellProbe } = require('./liveShellProbe.cjs');
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
for (const background of [true, false]) {
test(`paced ${background ? 'background' : 'silent foreground'} delivery does not consume startup time`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const pty = new EventEmitter();
const writes = [];
pty.write = (data) => writes.push(String(data));
const command = `echo ${'x'.repeat(background ? 150000 : 12000)}`;
const job = startPtyJob(pty, command, {
shellKind: 'posix', probeLiveShell: true,
timeoutMs: background ? 3600000 : 500,
maxBufferedChars: background ? 1024 : 0,
});
const advance = (ms) => {
for (let elapsed = 0; elapsed < ms; elapsed += 30) t.mock.timers.tick(30);
};
try {
const probeLength = 2 + buildLiveShellProbe(job.marker).length;
while (writes.join('').length < probeLength && !writes.includes('\x03')) advance(30);
assert.ok(!writes.includes('\x03'), 'probe delivery was interrupted');
pty.emit('data', `${job.marker}_P:sh\n${job.marker}_Q`);
// The background case crosses the old probe timer; the foreground
// case has no echo to refresh its inactivity timer during delivery.
advance(background ? 32010 : 1200);
assert.ok(!writes.includes('\x03'), 'wrapper interrupted before delivery completed');
const totalLength = probeLength + 2 + buildWrappedCommand(command, 'posix', job.marker, true).length;
while (writes.join('').length < totalLength && !writes.includes('\x03')) advance(30);
assert.ok(!writes.includes('\x03'), 'delivery did not finish');
pty.emit('data', `${job.marker}_S\nOK\n${job.marker}_E:0\n`);
assert.equal((await job.resultPromise).exitCode, 0);
} finally {
pty.emit('close');
t.mock.timers.reset();
}
});
}
test('completed delivery still has a bounded wait for a missing probe reply', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const pty = new EventEmitter();
const writes = [];
pty.write = (data) => writes.push(String(data));
const job = startPtyJob(pty, 'echo never', {
shellKind: 'posix', probeLiveShell: true, timeoutMs: 500,
});
const length = 2 + buildLiveShellProbe(job.marker).length;
while (writes.join('').length < length) t.mock.timers.tick(30);
// Unrelated output must not keep a never-started command alive forever.
for (let i = 0; i < 6; i++) {
pty.emit('data', 'unrelated output\n');
t.mock.timers.tick(100);
}
const result = await job.resultPromise;
assert.match(result.error, /Command startup timed out/);
assert.ok(writes.includes('\x03'));
});

View File

@@ -0,0 +1,460 @@
/**
* Resolve and cache the interactive shell kind used by AI PTY exec wrappers.
*
* Local terminals set shellKind from the executable path at spawn time. SSH /
* Telnet (and similar remote) sessions historically left shellKind unset, so
* resolveEffectiveShellKind fell through to "posix" and typed a bash-style
* wrapper into fish login shells (issue #1854).
*
* Before AI exec we probe the remote login shell once via a separate SSH exec
* channel (silent — does not touch the interactive PTY). All login-shell probe
* results (fish/posix/powershell/cmd) are stored as session._loginShellKind
* (soft hint) so resolveEffectiveShellKind can pick the matching wrapper
* without permanently assuming login shell === active interactive shell, and
* without routing bash sessions through /bin/sh (dash). Live PS/cmd prompts
* can still override a Windows DefaultShell hint when the user nested the
* opposite shell.
*
* Windows OpenSSH (issue #2959) has no POSIX `getent`/`sh` login-shell probe:
* we read HKLM\SOFTWARE\OpenSSH DefaultShell via `reg query` instead. Without
* that, AI typed a bash wrapper into PowerShell/cmd, hung waiting for markers,
* and Stop/Ctrl+C tore down the SSH tab.
*/
"use strict";
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
const crypto = require("node:crypto");
const { classifyLocalShellType } = require("../../../lib/localShell.cjs");
// Kinds that buildWrappedCommand / resolveEffectiveShellKind already trust.
// "unknown" is intentionally excluded: local unknown shells are unsupported
// for AI exec, and we do not invent a remote kind without a successful probe.
const CONFIRMED_SHELL_KINDS = new Set([
"posix",
"fish",
"powershell",
"cmd",
"raw",
]);
const DEFAULT_PROBE_TIMEOUT_MS = 3000;
const PROBE_OUTPUT_MARKER = "__NETCATTY_SHELL_KIND__:";
// Locale-independent: reg.exe missing-value stderr is translated on non-English
// Windows, so the probe echoes this marker via ERRORLEVEL instead.
const WINDOWS_NO_DEFAULT_SHELL_MARKER = "__NETCATTY_NO_DEFAULT_SHELL__";
function isConfirmedShellKind(shellKind) {
return CONFIRMED_SHELL_KINDS.has(shellKind);
}
function quoteShellArg(value) {
return `'${String(value ?? "").replace(/'/g, "'\\''")}'`;
}
/**
* True when the SSH identification software string is Win32-OpenSSH.
* `session.remoteSshVersion` is the software token from `SSH-2.0-<software>`.
*/
function isWindowsOpenSshRemote(remoteSshVersion) {
return /openssh_for_windows/i.test(String(remoteSshVersion || ""));
}
/**
* Map a remote shell path / basename to a wrapper kind.
* Returns null when we cannot classify (leave session.shellKind unset).
* Empty / missing paths return null (classifyLocalShellType would default to
* platform shell — that is wrong for a failed remote probe).
*/
function classifyShellKindFromRemotePath(shellPath) {
const trimmed = String(shellPath || "").trim();
if (!trimmed) return null;
const kind = classifyLocalShellType(trimmed, "linux");
if (!kind || kind === "unknown") return null;
return kind;
}
/**
* Silent remote probe: force POSIX sh so fish/zsh login shells can still run it
* when sshd invokes the command through the user's login shell (`$SHELL -c`).
* Prints a single line: absolute login-shell path (or empty).
*/
function buildRemoteLoginShellProbeCommand() {
const script = [
'SH="$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7)"',
'[ -n "$SH" ] || SH="${SHELL:-}"',
`printf "${PROBE_OUTPUT_MARKER}%s\\n" "$SH"`,
].join("; ");
return `exec sh -c ${quoteShellArg(script)}`;
}
function parseRemoteLoginShellProbeOutput(stdout) {
const lines = String(stdout || "")
.replace(/\r/g, "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
if (!line.startsWith(PROBE_OUTPUT_MARKER)) continue;
const kind = classifyShellKindFromRemotePath(line.slice(PROBE_OUTPUT_MARKER.length));
if (kind) return kind;
}
return null;
}
/**
* Silent Windows OpenSSH probe. Force `cmd.exe` so ERRORLEVEL works under both
* DefaultShell=cmd and DefaultShell=powershell (sshd still invokes console PE
* binaries). Do not match localized reg.exe diagnostics.
*/
function buildRemoteWindowsLoginShellProbeCommand() {
// Merge stderr for REG_SZ success lines that some hosts split across streams.
// Echo the missing-value marker only when the OpenSSH key is readable but
// DefaultShell is absent. Do not treat a failed OpenSSH child query under a
// readable HKLM\SOFTWARE parent as "key missing": registry ACLs are per-key,
// so the account may read SOFTWARE yet be denied OpenSSH while DefaultShell
// is PowerShell (Codex P2). A bare `if errorlevel 1` on the value query
// alone would also fire on access denied / policy blocks and permanently
// pin cmd on PowerShell hosts. When the OpenSSH key itself is unreadable
// (absent or denied), emit nothing and leave the kind unclassified; English
// "unable to find..." remains a parser fallback only.
//
// `if errorlevel 1` means exit code >= 1; `if not errorlevel 1` means 0.
return (
'cmd.exe /d /s /c "reg query HKLM\\SOFTWARE\\OpenSSH /v DefaultShell 2>&1'
+ " & if errorlevel 1 ("
+ "reg query HKLM\\SOFTWARE\\OpenSSH >nul 2>&1"
+ ` & if not errorlevel 1 echo ${WINDOWS_NO_DEFAULT_SHELL_MARKER}`
+ ')"'
);
}
/**
* Parse `reg query` DefaultShell output.
* Missing DefaultShell value (OpenSSH key readable) → Microsoft's documented
* default (cmd). Unreadable OpenSSH key stays unclassified unless the English
* missing-key diagnostic is present.
*/
function parseRemoteWindowsLoginShellProbeOutput(stdout) {
const text = String(stdout || "").replace(/\r/g, "");
const sz = text.match(/DefaultShell\s+REG_SZ\s+([^\n]+)/i);
if (sz) {
const rawPath = sz[1].trim().replace(/^"+|"+$/g, "");
const kind = classifyShellKindFromRemotePath(rawPath);
if (kind) return kind;
}
if (
text.includes(WINDOWS_NO_DEFAULT_SHELL_MARKER)
|| /unable to find the specified registry key or value/i.test(text)
) {
return "cmd";
}
return null;
}
/**
* Build an execProbe(command, timeoutMs) => Promise<string|null> from an
* ssh2-like connection (conn.exec(command, cb)).
*/
function createSshConnExecProbe(conn) {
if (!conn || typeof conn.exec !== "function") return null;
return async function execProbe(command, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
try {
const result = await executeBoundedSshCommand(conn, command, {
openingTimeoutMs: timeoutMs,
runTimeoutMs: timeoutMs,
maxOutputBytes: 64 * 1024,
});
// Include stderr so Windows `reg query` missing-value diagnostics
// (and any probe that only prints errors) still reach the parser.
return `${result.stdout || ""}${result.stderr || ""}`;
} catch {
return null;
}
};
}
/**
* Prefer the live SSH connection, then any companion stats connection
* (mosh/et) that still speaks ssh2 exec.
*/
function createSessionExecProbe(session) {
if (!session || typeof session !== "object") return null;
if (typeof session._shellKindExecProbe === "function") {
return (command, timeoutMs) => session._shellKindExecProbe(command, timeoutMs);
}
return (
createSshConnExecProbe(session.conn)
|| createSshConnExecProbe(session.sshClient)
|| createSshConnExecProbe(session.moshStatsConn)
|| createSshConnExecProbe(session.etStatsConn)
|| null
);
}
function withProbeTimeout(promise, timeoutMs) {
const ms = Number.isFinite(timeoutMs) && timeoutMs > 0
? timeoutMs
: DEFAULT_PROBE_TIMEOUT_MS;
let timer = null;
return Promise.race([
Promise.resolve(promise),
new Promise((resolve) => {
timer = setTimeout(() => resolve(null), ms);
}),
]).finally(() => {
if (timer) clearTimeout(timer);
});
}
/**
* Apply a successful remote probe result onto the session.
*
* Login-shell probe is a soft hint, not a permanent active-shell pin.
* Store on session._loginShellKind only and leave session.shellKind unset so
* resolveEffectiveShellKind can:
* - use the hint for the wrapper (native posix for bash/zsh, fish for fish,
* powershell/cmd for Windows DefaultShell — issue #1854 / #2959)
* - still honor a live opposing Windows prompt when the user nested cmd from
* a PowerShell login or PowerShell from a cmd login (Codex P2 on #2960)
* - still honor a live `user@host:...$` POSIX prompt over a Windows soft hint
* (e.g. WSL nested from PowerShell/cmd OpenSSH login)
* - still honor a live PowerShell prompt over a Unix login hint (#841)
*
* Always mark the probe settled so we do not re-probe every AI exec.
*/
function applyProbedShellKind(session, kind) {
if (!kind) return session.shellKind;
session._shellKindProbeSettled = true;
session._loginShellKind = kind;
// Soft hint only; never pin session.shellKind from a remote login probe.
return session.shellKind;
}
function markShellKindProbeSettled(session) {
if (!session || typeof session !== "object") return;
session._shellKindProbeSettled = true;
}
function isShellKindProbeSettled(session) {
return Boolean(session?._shellKindProbeSettled)
|| isConfirmedShellKind(session?.shellKind);
}
/**
* Probe once for the remote login shell kind.
*
* Prefer the Windows OpenSSH DefaultShell registry probe when the banner says
* Win32-OpenSSH (POSIX getent/sh never works there). Otherwise try the Unix
* marker probe, then fall back to the Windows reg probe for hosts whose banner
* was not recorded on the session.
*
* @returns {Promise<{ kind: string|null, settleWithoutKind?: boolean }>}
*/
async function probeRemoteLoginShellKind(execProbe, timeoutMs, session) {
const preferWindows = isWindowsOpenSshRemote(session?.remoteSshVersion);
if (preferWindows) {
const winStdout = await withProbeTimeout(
execProbe(buildRemoteWindowsLoginShellProbeCommand(), timeoutMs),
timeoutMs,
);
// Timed out / SSH exec failed — leave unsettled for a later retry
// (same as the Unix probe branch below). Settling here would permanently
// fall back to the POSIX wrapper on Windows sessions until reconnect.
if (winStdout == null) {
return { kind: null };
}
const winKind = parseRemoteWindowsLoginShellProbeOutput(winStdout);
if (winKind) return { kind: winKind };
// Completed probe but nothing classifiable. Settle without pinning so we
// stop re-probing; live PS/cmd prompt override can still select the
// wrapper when lastIdlePrompt is available.
return { kind: null, settleWithoutKind: true };
}
const stdout = await withProbeTimeout(
execProbe(buildRemoteLoginShellProbeCommand(), timeoutMs),
timeoutMs,
);
const kind = parseRemoteLoginShellProbeOutput(stdout);
if (kind) return { kind };
// Timed out / probe returned null — leave unsettled for a later retry.
// Do not stack a second full-timeout Windows probe in the same attempt.
if (stdout == null) {
return { kind: null };
}
// Got bytes but no classifiable Unix marker. Skip Windows reg when the
// Unix probe already printed our marker with an unclassifiable path
// (exotic login shells); otherwise try DefaultShell for Windows OpenSSH
// hosts whose banner was not recorded on the session.
if (String(stdout).includes(PROBE_OUTPUT_MARKER)) {
return { kind: null };
}
const winStdout = await withProbeTimeout(
execProbe(buildRemoteWindowsLoginShellProbeCommand(), timeoutMs),
timeoutMs,
);
// Timed out / SSH exec failed — leave unsettled for a later retry.
if (winStdout == null) {
return { kind: null };
}
const winKind = parseRemoteWindowsLoginShellProbeOutput(winStdout);
if (winKind) return { kind: winKind };
// Completed Windows fallback but nothing classifiable (access denied, empty,
// garbage). Settle without pinning so we do not re-run both probes on every
// AI exec for the life of the session (Codex P2 on #2960).
return { kind: null, settleWithoutKind: true };
}
/**
* Ensure session.shellKind is set when we can detect it. Safe to call on every
* AI exec — confirmed kinds short-circuit; concurrent callers share one probe.
*
* @param {object} session
* @param {{ execProbe?: (command: string, timeoutMs?: number) => Promise<string|null>, timeoutMs?: number }} [options]
* @returns {Promise<string|undefined>}
*/
async function ensureSessionShellKind(session, options = {}) {
if (!session || typeof session !== "object") return undefined;
if (isConfirmedShellKind(session.shellKind)) {
return session.shellKind;
}
// Probe already decided "generic posix login shell" (or pinned a kind).
// Do not re-hit the network; leave shellKind unset for the posix case so
// resolveEffectiveShellKind can still honor a live PowerShell prompt.
if (session._shellKindProbeSettled) {
return session.shellKind;
}
// Local shells with an unrecognised executable stay "unknown"; do not probe.
if (
(session.protocol === "local" || session.type === "local")
&& session.shellKind === "unknown"
) {
return session.shellKind;
}
if (session._shellKindProbePromise) {
return session._shellKindProbePromise;
}
const execProbe =
typeof options.execProbe === "function"
? options.execProbe
: createSessionExecProbe(session);
if (typeof execProbe !== "function") {
return session.shellKind;
}
const timeoutMs = Number.isFinite(options.timeoutMs)
? options.timeoutMs
: DEFAULT_PROBE_TIMEOUT_MS;
session._shellKindProbePromise = (async () => {
try {
const probed = await probeRemoteLoginShellKind(execProbe, timeoutMs, session);
if (probed.kind) {
return applyProbedShellKind(session, probed.kind);
}
if (probed.settleWithoutKind) {
markShellKindProbeSettled(session);
}
return session.shellKind;
} catch {
return session.shellKind;
} finally {
// Retry only when the probe failed to classify anything.
if (!isShellKindProbeSettled(session)) {
session._shellKindProbePromise = null;
}
}
})();
return session._shellKindProbePromise;
}
/**
* Probe shell kind while remaining cancellable via activePtyExecs.
*
* The first AI exec on a remote session may await ensureSessionShellKind for up
* to the probe timeout before execViaPty registers a real marker. Stop during
* that window would otherwise find nothing in activePtyExecs and the command
* would still be typed after the probe resolves (Codex P2 on PR #2061).
*
* Mirrors the pending-marker pattern used by execViaChannel: register a
* cancel latch synchronously, await the probe, then short-circuit if Stop
* fired before we write to the PTY.
*
* @returns {Promise<{ ok: true, shellKind: string|undefined } | { ok: false, cancelled: true, error: string, exitCode: number, stdout: string, stderr: string }>}
*/
async function ensureSessionShellKindForExec(session, options = {}) {
const {
trackForCancellation = null,
chatSessionId = null,
execProbe,
timeoutMs,
} = options;
let cancelled = false;
const pendingMarker = trackForCancellation
? `__NCMCP_SK_PENDING_${Date.now().toString(36)}_${crypto.randomBytes(8).toString("hex")}__`
: null;
if (pendingMarker) {
trackForCancellation.set(pendingMarker, {
chatSessionId: chatSessionId || null,
cancel: () => {
cancelled = true;
},
cleanup: () => {
// Nothing to tear down before the real PTY job starts.
},
});
}
try {
await ensureSessionShellKind(session, { execProbe, timeoutMs });
if (cancelled) {
return {
ok: false,
cancelled: true,
stdout: "",
stderr: "",
exitCode: 130,
error: "Cancelled",
};
}
return { ok: true, shellKind: session.shellKind };
} finally {
if (pendingMarker && trackForCancellation) {
trackForCancellation.delete(pendingMarker);
}
}
}
module.exports = {
CONFIRMED_SHELL_KINDS,
DEFAULT_PROBE_TIMEOUT_MS,
PROBE_OUTPUT_MARKER,
WINDOWS_NO_DEFAULT_SHELL_MARKER,
isConfirmedShellKind,
isWindowsOpenSshRemote,
classifyShellKindFromRemotePath,
buildRemoteLoginShellProbeCommand,
buildRemoteWindowsLoginShellProbeCommand,
parseRemoteLoginShellProbeOutput,
parseRemoteWindowsLoginShellProbeOutput,
createSshConnExecProbe,
createSessionExecProbe,
applyProbedShellKind,
ensureSessionShellKind,
ensureSessionShellKindForExec,
};

View File

@@ -0,0 +1,875 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { spawnSync } = require("node:child_process");
const { existsSync } = require("node:fs");
const {
isConfirmedShellKind,
PROBE_OUTPUT_MARKER,
WINDOWS_NO_DEFAULT_SHELL_MARKER,
classifyShellKindFromRemotePath,
buildRemoteLoginShellProbeCommand,
buildRemoteWindowsLoginShellProbeCommand,
parseRemoteLoginShellProbeOutput,
parseRemoteWindowsLoginShellProbeOutput,
isWindowsOpenSshRemote,
createSshConnExecProbe,
createSessionExecProbe,
ensureSessionShellKind,
ensureSessionShellKindForExec,
} = require("./sessionShellKind.cjs");
const {
buildWrappedCommand,
resolveEffectiveShellKind,
} = require("./ptyExecHelpers.cjs");
test("classifies remote login shell paths", () => {
assert.equal(classifyShellKindFromRemotePath("/usr/bin/fish"), "fish");
assert.equal(classifyShellKindFromRemotePath("/usr/local/bin/fish"), "fish");
assert.equal(classifyShellKindFromRemotePath("fish"), "fish");
assert.equal(classifyShellKindFromRemotePath("/bin/bash"), "posix");
assert.equal(classifyShellKindFromRemotePath("/bin/zsh"), "posix");
assert.equal(classifyShellKindFromRemotePath("/usr/bin/pwsh"), "powershell");
assert.equal(classifyShellKindFromRemotePath("/bin/cmd.exe"), "cmd");
assert.equal(
classifyShellKindFromRemotePath(
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
),
"powershell",
);
assert.equal(
classifyShellKindFromRemotePath("C:\\Windows\\System32\\cmd.exe"),
"cmd",
);
assert.equal(classifyShellKindFromRemotePath("/usr/bin/nu"), null);
assert.equal(classifyShellKindFromRemotePath(""), null);
});
test("isWindowsOpenSshRemote matches OpenSSH_for_Windows banners", () => {
assert.equal(isWindowsOpenSshRemote("OpenSSH_for_Windows_9.5"), true);
assert.equal(isWindowsOpenSshRemote("SSH-2.0-OpenSSH_for_Windows_8.1"), true);
assert.equal(isWindowsOpenSshRemote("OpenSSH_9.6"), false);
assert.equal(isWindowsOpenSshRemote(""), false);
assert.equal(isWindowsOpenSshRemote(undefined), false);
});
test("Windows login-shell probe uses reg query for DefaultShell", () => {
const command = buildRemoteWindowsLoginShellProbeCommand();
assert.match(command, /reg query/i);
assert.match(command, /HKLM\\SOFTWARE\\OpenSSH/i);
assert.match(command, /DefaultShell/);
// Force cmd.exe so ERRORLEVEL works under powershell DefaultShell too.
assert.match(command, /cmd\.exe/i);
// Missing-value marker only after confirming the OpenSSH key is readable
// (`if not errorlevel 1`), not on every reg failure (access denied / missing
// key under a readable parent). Parent SOFTWARE readability must not imply
// OpenSSH absence — ACL is per-key.
assert.match(command, /if errorlevel 1/i);
assert.match(command, /if not errorlevel 1/i);
assert.doesNotMatch(command, /HKLM\\SOFTWARE(?!\\OpenSSH)/);
assert.match(command, new RegExp(WINDOWS_NO_DEFAULT_SHELL_MARKER));
// Missing DefaultShell diagnostics may still land on stderr; redirect keeps
// REG_SZ success lines visible when hosts split streams.
assert.match(command, /2>&1/);
});
test("parseRemoteWindowsLoginShellProbeOutput reads DefaultShell and missing-key default", () => {
assert.equal(
parseRemoteWindowsLoginShellProbeOutput(
"\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\OpenSSH\r\n DefaultShell REG_SZ C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\r\n",
),
"powershell",
);
assert.equal(
parseRemoteWindowsLoginShellProbeOutput(
"\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\OpenSSH\r\n DefaultShell REG_SZ C:\\Windows\\System32\\cmd.exe\r\n",
),
"cmd",
);
// Locale-independent marker from ERRORLEVEL (preferred path): OpenSSH key
// readable, DefaultShell value absent.
assert.equal(
parseRemoteWindowsLoginShellProbeOutput(
`错误: 系统找不到指定的注册表项或值。\r\n${WINDOWS_NO_DEFAULT_SHELL_MARKER}\r\n`,
),
"cmd",
);
assert.equal(
parseRemoteWindowsLoginShellProbeOutput(
`${WINDOWS_NO_DEFAULT_SHELL_MARKER}\r\n`,
),
"cmd",
);
// English diagnostic kept as fallback for older fixtures / probe output.
assert.equal(
parseRemoteWindowsLoginShellProbeOutput(
"ERROR: The system was unable to find the specified registry key or value.\r\n",
),
"cmd",
);
// Localized text alone must not classify — that was the P2 hang risk.
assert.equal(
parseRemoteWindowsLoginShellProbeOutput("错误: 系统找不到指定的注册表项或值。\r\n"),
null,
);
// Access denied / policy blocks must stay unclassified (no missing-value
// marker). Treating them as cmd permanently pins the wrong wrapper on
// PowerShell DefaultShell hosts.
assert.equal(
parseRemoteWindowsLoginShellProbeOutput("ERROR: Access is denied.\r\n"),
null,
);
assert.equal(
parseRemoteWindowsLoginShellProbeOutput("错误: 拒绝访问。\r\n"),
null,
);
assert.equal(parseRemoteWindowsLoginShellProbeOutput(""), null);
assert.equal(parseRemoteWindowsLoginShellProbeOutput("reg: command not found\n"), null);
});
test("parseRemoteLoginShellProbeOutput reads classifiable probe output lines", () => {
assert.equal(
parseRemoteLoginShellProbeOutput(`\n${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`),
"fish",
);
assert.equal(
parseRemoteLoginShellProbeOutput(` ${PROBE_OUTPUT_MARKER}/bin/bash\r\n`),
"posix",
);
assert.equal(
parseRemoteLoginShellProbeOutput(`SHELL=/bin/bash\n${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`),
"fish",
);
assert.equal(parseRemoteLoginShellProbeOutput("SHELL=/bin/bash\n"), null);
assert.equal(parseRemoteLoginShellProbeOutput(" \n"), null);
});
test("probe command is fish-parseable and forces POSIX sh", () => {
const command = buildRemoteLoginShellProbeCommand();
// Outer form: fish and bash both accept `exec sh -c '...'` when sshd
// routes the remote command through the login shell.
assert.match(command, /^exec sh -c '/);
assert.match(command, /getent passwd/);
assert.match(command, new RegExp(PROBE_OUTPUT_MARKER));
// ${SHELL:-} lives inside the single-quoted sh script body, not as an
// outer-shell expansion — fish must not see it unquoted.
assert.match(command, /\$\{SHELL:-\}/);
assert.equal(command.startsWith("exec sh -c '"), true);
assert.equal(command.endsWith("'"), true);
});
test("isConfirmedShellKind covers wrapper kinds only", () => {
assert.equal(isConfirmedShellKind("fish"), true);
assert.equal(isConfirmedShellKind("posix"), true);
assert.equal(isConfirmedShellKind("unknown"), false);
assert.equal(isConfirmedShellKind(undefined), false);
assert.equal(isConfirmedShellKind(""), false);
});
test("ensureSessionShellKind short-circuits confirmed kinds without probing", async () => {
let probes = 0;
const session = { shellKind: "posix", protocol: "ssh" };
const kind = await ensureSessionShellKind(session, {
execProbe: async () => {
probes += 1;
return `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`;
},
});
assert.equal(kind, "posix");
assert.equal(probes, 0);
});
test("ensureSessionShellKind does not probe local unknown shells", async () => {
let probes = 0;
const session = { shellKind: "unknown", protocol: "local", type: "local" };
const kind = await ensureSessionShellKind(session, {
execProbe: async () => {
probes += 1;
return `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`;
},
});
assert.equal(kind, "unknown");
assert.equal(probes, 0);
});
test("ensureSessionShellKind probes fish once but does not pin it as active shell", async () => {
// Login shell = fish must not permanently set session.shellKind (Codex P2).
// Soft hint still selects the fish wrapper for the common fish-login case.
let probes = 0;
const session = { protocol: "ssh" };
const probe = async () => {
probes += 1;
return `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`;
};
const first = await ensureSessionShellKind(session, { execProbe: probe });
const second = await ensureSessionShellKind(session, { execProbe: probe });
assert.equal(first, undefined);
assert.equal(second, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, "fish");
assert.equal(session._shellKindProbeSettled, true);
assert.equal(probes, 1);
assert.equal(
resolveEffectiveShellKind(session.shellKind, "", { loginShellHint: session._loginShellKind }),
"fish",
);
});
test("ensureSessionShellKind shares one in-flight probe across concurrent callers", async () => {
let probes = 0;
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
const session = { protocol: "ssh" };
const probe = async () => {
probes += 1;
await gate;
return `${PROBE_OUTPUT_MARKER}/bin/zsh\n`;
};
const p1 = ensureSessionShellKind(session, { execProbe: probe });
const p2 = ensureSessionShellKind(session, { execProbe: probe });
release();
const [a, b] = await Promise.all([p1, p2]);
// Posix login shells are not pinned on session.shellKind (see below).
assert.equal(a, undefined);
assert.equal(b, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._shellKindProbeSettled, true);
assert.equal(probes, 1);
});
test("probed posix login shell does not block live PowerShell prompt override (Codex P2)", async () => {
// Login shell is bash/zsh, but the user may have entered pwsh interactively
// (or startup files exec'd it). Previously unset shellKind let
// resolveEffectiveShellKind honor PS ...> prompts (#841). Pinning posix
// permanently would type the bash wrapper into PowerShell.
let probes = 0;
const session = { protocol: "ssh" };
const probe = async () => {
probes += 1;
return `${PROBE_OUTPUT_MARKER}/bin/bash\n`;
};
await ensureSessionShellKind(session, { execProbe: probe });
await ensureSessionShellKind(session, { execProbe: probe });
assert.equal(probes, 1, "posix probe should settle without re-probing");
assert.equal(session.shellKind, undefined);
assert.equal(session._shellKindProbeSettled, true);
// Live PowerShell prompt still wins when shellKind is unset.
assert.equal(
resolveEffectiveShellKind(session.shellKind, "PS C:\\Users\\alice>", {
loginShellHint: session._loginShellKind,
}),
"powershell",
);
// Soft posix hint → native posix wrapper (evaluated by interactive bash/zsh,
// NOT routed through /bin/sh / dash).
assert.equal(
resolveEffectiveShellKind(session.shellKind, "alice@host:~$", {
loginShellHint: session._loginShellKind,
}),
"posix",
);
const marker = "__NCMCP_POSIX_NATIVE__";
const wrapped = buildWrappedCommand("echo native-posix", "posix", marker);
assert.doesNotMatch(wrapped, /\bsh\s+-c\b/);
assert.doesNotMatch(wrapped, /posix_sh/);
assert.match(wrapped, new RegExp(`${marker}=0;`));
assert.match(wrapped, new RegExp(`${marker}_cmd=`));
});
test("probed fish login shell is a soft hint, not a permanent pin (Codex P2)", async () => {
const session = { protocol: "ssh" };
await ensureSessionShellKind(session, {
execProbe: async () => `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`,
});
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, "fish");
// Soft hint selects fish wrapper for the common case.
assert.equal(
resolveEffectiveShellKind(session.shellKind, "root@host ~# ", {
loginShellHint: session._loginShellKind,
}),
"fish",
);
// PS prompt still overrides the fish login hint.
assert.equal(
resolveEffectiveShellKind(session.shellKind, "PS C:\\Users\\alice>", {
loginShellHint: session._loginShellKind,
}),
"powershell",
);
});
test("ensureSessionShellKind allows retry after a failed probe", async () => {
let probes = 0;
const session = { protocol: "ssh" };
const failThenSucceed = async () => {
probes += 1;
if (probes === 1) return null;
return `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`;
};
const first = await ensureSessionShellKind(session, {
execProbe: failThenSucceed,
});
assert.equal(first, undefined);
assert.equal(session.shellKind, undefined);
const second = await ensureSessionShellKind(session, {
execProbe: failThenSucceed,
});
assert.equal(second, undefined);
assert.equal(session._loginShellKind, "fish");
assert.equal(session._shellKindProbeSettled, true);
assert.equal(probes, 2);
});
test("ensureSessionShellKind uses a session-level exec probe when provided", async () => {
let probes = 0;
const session = {
protocol: "mosh",
_shellKindExecProbe: async () => {
probes += 1;
return `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`;
},
};
const kind = await ensureSessionShellKind(session);
assert.equal(kind, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, "fish");
assert.equal(probes, 1);
});
test("ensureSessionShellKind soft-hints powershell login shells without pinning", async () => {
const session = { protocol: "ssh" };
await ensureSessionShellKind(session, {
execProbe: async () => `${PROBE_OUTPUT_MARKER}/usr/bin/pwsh\n`,
});
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, "powershell");
assert.equal(session._shellKindProbeSettled, true);
assert.equal(
resolveEffectiveShellKind(session.shellKind, "", {
loginShellHint: session._loginShellKind,
}),
"powershell",
);
// Live cmd prompt overrides a PowerShell DefaultShell soft hint.
assert.equal(
resolveEffectiveShellKind(session.shellKind, "C:\\Users\\alice>", {
loginShellHint: session._loginShellKind,
}),
"cmd",
);
// Live POSIX prompt (WSL) overrides a PowerShell soft hint.
assert.equal(
resolveEffectiveShellKind(session.shellKind, "user@host:~$", {
loginShellHint: session._loginShellKind,
}),
"posix",
);
});
test("ensureSessionShellKind uses Windows DefaultShell probe for OpenSSH_for_Windows", async () => {
// Issue #2959: Unix `exec sh -c` probes never classify Windows OpenSSH, so AI
// fell through to a posix wrapper, hung, and Stop/Ctrl+C tore down the tab.
const probed = [];
const session = {
protocol: "ssh",
remoteSshVersion: "OpenSSH_for_Windows_9.5",
};
const kind = await ensureSessionShellKind(session, {
execProbe: async (command) => {
probed.push(command);
return (
"\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\OpenSSH\r\n" +
" DefaultShell REG_SZ C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\r\n"
);
},
});
assert.equal(kind, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, "powershell");
assert.equal(session._shellKindProbeSettled, true);
assert.equal(probed.length, 1);
assert.match(probed[0], /reg query/i);
assert.doesNotMatch(probed[0], /getent passwd/);
assert.equal(
resolveEffectiveShellKind(session.shellKind, "", {
loginShellHint: session._loginShellKind,
}),
"powershell",
);
});
test("ensureSessionShellKind soft-hints cmd when Windows OpenSSH has no DefaultShell value", async () => {
const session = {
protocol: "ssh",
remoteSshVersion: "OpenSSH_for_Windows_8.1",
};
const kind = await ensureSessionShellKind(session, {
execProbe: async () =>
`错误: 系统找不到指定的注册表项或值。\r\n${WINDOWS_NO_DEFAULT_SHELL_MARKER}\r\n`,
});
assert.equal(kind, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, "cmd");
assert.equal(session._shellKindProbeSettled, true);
assert.equal(
resolveEffectiveShellKind(session.shellKind, "", {
loginShellHint: session._loginShellKind,
}),
"cmd",
);
// Live PowerShell prompt overrides a cmd DefaultShell soft hint.
assert.equal(
resolveEffectiveShellKind(session.shellKind, "PS C:\\Users\\alice>", {
loginShellHint: session._loginShellKind,
}),
"powershell",
);
});
test("ensureSessionShellKind does not pin cmd when Windows reg probe is access-denied", async () => {
// Codex P2: access denied must not share the missing-value → cmd path.
let probes = 0;
const session = {
protocol: "ssh",
remoteSshVersion: "OpenSSH_for_Windows_9.5",
};
const kind = await ensureSessionShellKind(session, {
execProbe: async () => {
probes += 1;
// Live probe no longer echoes WINDOWS_NO_DEFAULT_SHELL_MARKER here.
return "ERROR: Access is denied.\r\n";
},
});
assert.equal(kind, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._shellKindProbeSettled, true);
assert.equal(probes, 1);
});
test("ensureSessionShellKind settles Windows OpenSSH without pinning when reg probe is empty", async () => {
let probes = 0;
const session = {
protocol: "ssh",
remoteSshVersion: "OpenSSH_for_Windows_9.5",
};
const kind = await ensureSessionShellKind(session, {
execProbe: async () => {
probes += 1;
return "";
},
});
assert.equal(kind, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._shellKindProbeSettled, true);
assert.equal(probes, 1);
// Settled: do not re-probe on the next AI exec.
await ensureSessionShellKind(session, {
execProbe: async () => {
probes += 1;
return "";
},
});
assert.equal(probes, 1);
});
test("ensureSessionShellKind retries Windows OpenSSH probe after null/timeout", async () => {
// Codex P1: timeout/channel failure must not settleWithoutKind — otherwise
// later AI execs permanently use the POSIX wrapper on Windows.
let probes = 0;
const session = {
protocol: "ssh",
remoteSshVersion: "OpenSSH_for_Windows_9.5",
};
const failThenSucceed = async () => {
probes += 1;
if (probes === 1) return null;
return (
"\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\OpenSSH\r\n" +
" DefaultShell REG_SZ C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\r\n"
);
};
const first = await ensureSessionShellKind(session, {
execProbe: failThenSucceed,
});
assert.equal(first, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._shellKindProbeSettled, undefined);
assert.equal(session._shellKindProbePromise, null);
const second = await ensureSessionShellKind(session, {
execProbe: failThenSucceed,
});
assert.equal(second, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, "powershell");
assert.equal(session._shellKindProbeSettled, true);
assert.equal(probes, 2);
});
test("ensureSessionShellKind falls back to Windows reg probe when Unix probe yields nothing", async () => {
const probed = [];
const session = { protocol: "ssh" };
const kind = await ensureSessionShellKind(session, {
execProbe: async (command) => {
probed.push(command);
if (/reg query/i.test(command)) {
return (
"HKEY_LOCAL_MACHINE\\SOFTWARE\\OpenSSH\n" +
" DefaultShell REG_SZ C:\\Windows\\System32\\cmd.exe\n"
);
}
return "no marker here\n";
},
});
assert.equal(kind, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, "cmd");
assert.equal(session._shellKindProbeSettled, true);
assert.equal(probed.length, 2);
assert.match(probed[0], /getent passwd|exec sh -c/);
assert.match(probed[1], /reg query/i);
});
test("ensureSessionShellKind settles completed unclassifiable Windows fallback without re-probing", async () => {
// Codex P2: when remoteSshVersion is missing, Unix probe returns non-marker
// bytes, and Windows reg returns access-denied, settle so later AI execs do
// not re-run both probes forever. Null/timeout still retries.
let probes = 0;
const session = { protocol: "ssh" };
const kind = await ensureSessionShellKind(session, {
execProbe: async (command) => {
probes += 1;
if (/reg query/i.test(command)) {
return "ERROR: Access is denied.\r\n";
}
return "no marker here\n";
},
});
assert.equal(kind, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, undefined);
assert.equal(session._shellKindProbeSettled, true);
assert.equal(probes, 2);
await ensureSessionShellKind(session, {
execProbe: async () => {
probes += 1;
return "should not run\n";
},
});
assert.equal(probes, 2);
});
test("ensureSessionShellKind retries Windows fallback after null/timeout when banner missing", async () => {
let probes = 0;
const session = { protocol: "ssh" };
const first = await ensureSessionShellKind(session, {
execProbe: async (command) => {
probes += 1;
if (/reg query/i.test(command)) return null;
return "no marker here\n";
},
});
assert.equal(first, undefined);
assert.equal(session._shellKindProbeSettled, undefined);
assert.equal(session._shellKindProbePromise, null);
assert.equal(probes, 2);
const second = await ensureSessionShellKind(session, {
execProbe: async (command) => {
probes += 1;
if (/reg query/i.test(command)) {
return (
"HKEY_LOCAL_MACHINE\\SOFTWARE\\OpenSSH\n" +
" DefaultShell REG_SZ C:\\Windows\\System32\\cmd.exe\n"
);
}
return "no marker here\n";
},
});
assert.equal(second, undefined);
assert.equal(session._loginShellKind, "cmd");
assert.equal(session._shellKindProbeSettled, true);
assert.equal(probes, 4);
});
test("ensureSessionShellKindForExec cancels when Stop fires during the probe", async () => {
// Codex P2 on #2061: probe can take up to the timeout before execViaPty
// registers a real marker. Pending marker must latch cancel so the command
// is not typed after the probe resolves.
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
const session = { protocol: "ssh" };
const activePtyExecs = new Map();
const probe = async () => {
await gate;
return `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`;
};
const pending = ensureSessionShellKindForExec(session, {
execProbe: probe,
trackForCancellation: activePtyExecs,
chatSessionId: "chat-cancel-probe",
});
// Wait until the pending marker is registered.
for (let i = 0; i < 20 && activePtyExecs.size === 0; i += 1) {
await new Promise((r) => setTimeout(r, 0));
}
assert.equal(activePtyExecs.size, 1);
const [marker, entry] = [...activePtyExecs.entries()][0];
assert.match(marker, /^__NCMCP_SK_PENDING_/);
assert.equal(entry.chatSessionId, "chat-cancel-probe");
// Simulate cancelPtyExecsForSession during the probe window.
entry.cancel();
release();
const result = await pending;
assert.equal(result.ok, false);
assert.equal(result.cancelled, true);
assert.equal(result.error, "Cancelled");
assert.equal(result.exitCode, 130);
assert.equal(activePtyExecs.size, 0, "pending marker cleaned up after probe");
// Login fish is recorded but not pinned as active shellKind.
assert.equal(session._loginShellKind, "fish");
assert.equal(session.shellKind, undefined);
});
test("ensureSessionShellKindForExec proceeds when not cancelled", async () => {
const session = { protocol: "ssh" };
const activePtyExecs = new Map();
const result = await ensureSessionShellKindForExec(session, {
execProbe: async () => `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`,
trackForCancellation: activePtyExecs,
chatSessionId: "chat-ok",
});
assert.equal(result.ok, true);
assert.equal(result.shellKind, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, "fish");
assert.equal(activePtyExecs.size, 0);
});
test("ensureSessionShellKind times out a hanging session-level exec probe", async () => {
let probes = 0;
const session = {
protocol: "mosh",
_shellKindExecProbe: async () => {
probes += 1;
return new Promise(() => {});
},
};
const kind = await ensureSessionShellKind(session, { timeoutMs: 1 });
assert.equal(kind, undefined);
assert.equal(session.shellKind, undefined);
assert.equal(session._shellKindProbePromise, null);
assert.equal(probes, 1);
});
test("createSshConnExecProbe returns stdout from conn.exec", async () => {
let seenCommand = "";
const conn = {
exec(command, cb) {
seenCommand = command;
const listeners = new Map();
const stream = {
on(event, fn) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event).push(fn);
return stream;
},
stderr: { on() { return this; } },
close() {},
};
// Deliver data after the probe has subscribed (next tick).
queueMicrotask(() => {
for (const fn of listeners.get("data") || []) {
fn(Buffer.from("/usr/bin/fish\n"));
}
for (const fn of listeners.get("close") || []) {
fn(0);
}
});
cb(null, stream);
},
};
const probe = createSshConnExecProbe(conn);
const command = buildRemoteLoginShellProbeCommand();
assert.equal(await probe(command, 1000), "/usr/bin/fish\n");
assert.equal(seenCommand, command);
});
test("createSshConnExecProbe includes stderr so missing DefaultShell is classifiable", async () => {
// Codex P1: reg.exe writes the missing-value error only on stderr. Dropping
// it made Windows OpenSSH probes settle without a kind and hang on POSIX
// wrappers when the interactive prompt was unrecognized.
// Codex P2: the live probe also echoes WINDOWS_NO_DEFAULT_SHELL_MARKER via
// ERRORLEVEL so non-English hosts do not depend on localized stderr text.
const { EventEmitter } = require("node:events");
const conn = {
exec(_command, cb) {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
queueMicrotask(() => {
stream.stderr.emit(
"data",
Buffer.from("错误: 系统找不到指定的注册表项或值。\r\n"),
);
stream.emit("data", Buffer.from(`${WINDOWS_NO_DEFAULT_SHELL_MARKER}\r\n`));
stream.emit("close", 1);
});
cb(null, stream);
},
};
const probe = createSshConnExecProbe(conn);
const output = await probe(buildRemoteWindowsLoginShellProbeCommand(), 1000);
assert.match(output, new RegExp(WINDOWS_NO_DEFAULT_SHELL_MARKER));
assert.equal(parseRemoteWindowsLoginShellProbeOutput(output), "cmd");
});
test("createSshConnExecProbe closes a channel that arrives after timeout", async () => {
let execCallback;
let closed = false;
const conn = {
exec(_command, cb) {
execCallback = cb;
},
};
const probe = createSshConnExecProbe(conn);
const result = await probe(buildRemoteLoginShellProbeCommand(), 1);
assert.equal(result, null);
const stream = {
on() { return stream; },
stderr: { on() { return this; } },
close() {
closed = true;
},
};
execCallback(null, stream);
assert.equal(closed, true);
});
test("createSessionExecProbe prefers session.conn over companions", () => {
const session = {
conn: { exec() {} },
moshStatsConn: { exec() {} },
};
const probe = createSessionExecProbe(session);
assert.equal(typeof probe, "function");
// Prefer primary conn: a probe built only from moshStatsConn is a different
// function identity; we just need a usable probe here.
assert.equal(createSessionExecProbe({}), null);
});
// --- Real fish binary: wrapper must produce markers (issue #1854) -----------
function resolveFishBinary() {
const candidates = [
process.env.FISH_PATH,
"/opt/homebrew/bin/fish",
"/usr/local/bin/fish",
"/usr/bin/fish",
].filter(Boolean);
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
const which = spawnSync("which", ["fish"], { encoding: "utf8" });
if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
return null;
}
const fishBinary = resolveFishBinary();
test(
"fish wrapper runs under real fish and emits start/end markers",
{ skip: !fishBinary ? "fish binary not available" : false },
() => {
const marker = "__NCMCP_FISHTEST__";
const wrapped = buildWrappedCommand("echo hello-fish-wrapper", "fish", marker);
// fish -c runs the wrapper as a script body (same grammar as interactive
// command line for this single-line form).
const result = spawnSync(
fishBinary,
["--no-config", "-c", wrapped.trim()],
{ encoding: "utf8", timeout: 10000 },
);
assert.equal(result.error, undefined, result.stderr || result.error);
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, new RegExp(`${marker}_S`));
assert.match(result.stdout, /hello-fish-wrapper/);
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
},
);
test(
"posix wrapper fails under real fish (regression guard for #1854)",
{ skip: !fishBinary ? "fish binary not available" : false },
() => {
const marker = "__NCMCP_FISHTEST__";
const wrapped = buildWrappedCommand("echo should-not-run", "posix", marker);
const result = spawnSync(
fishBinary,
["--no-config", "-c", wrapped.trim()],
{ encoding: "utf8", timeout: 10000 },
);
// fish rejects `VAR=0` assignment syntax.
assert.notEqual(result.status, 0);
assert.match(
`${result.stdout}\n${result.stderr}`,
/Unsupported use of '='|Unknown command/,
);
},
);
test(
"after ensureSessionShellKind(fish login), fish wrapper succeeds under real fish",
{ skip: !fishBinary ? "fish binary not available" : false },
async () => {
// Soft login hint selects fish wrapper without pinning session.shellKind.
const session = { protocol: "ssh" };
await ensureSessionShellKind(session, {
execProbe: async () => `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`,
});
assert.equal(session.shellKind, undefined);
assert.equal(session._loginShellKind, "fish");
const marker = "__NCMCP_FISHTEST__";
const effective = resolveEffectiveShellKind(session.shellKind, "root at host # ", {
loginShellHint: session._loginShellKind,
});
assert.equal(effective, "fish");
const wrapped = buildWrappedCommand("printf 'ok\\n'", effective, marker);
const result = spawnSync(
fishBinary,
["--no-config", "-c", wrapped.trim()],
{ encoding: "utf8", timeout: 10000 },
);
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /ok/);
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
},
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { mergeLoginShellPath } = require("./shellUtils.cjs");
test("mergeLoginShellPath unions login-shell PATH ahead of base, dedup", () => {
const merged = mergeLoginShellPath({
basePath: "/usr/bin:/bin",
runLoginShellPath: () => "/opt/homebrew/bin:/usr/bin:/Users/me/.local/bin",
platform: "darwin",
delimiter: ":",
});
const parts = merged.split(":");
assert.ok(parts.includes("/opt/homebrew/bin"));
assert.ok(parts.includes("/Users/me/.local/bin"));
assert.ok(parts.includes("/bin"));
// no duplicate /usr/bin
assert.equal(parts.filter((p) => p === "/usr/bin").length, 1);
});
test("mergeLoginShellPath returns basePath untouched on win32", () => {
const merged = mergeLoginShellPath({
basePath: "C:\\Windows", runLoginShellPath: () => "X", platform: "win32", delimiter: ";",
});
assert.equal(merged, "C:\\Windows");
});
test("mergeLoginShellPath tolerates login-shell failure", () => {
const merged = mergeLoginShellPath({
basePath: "/usr/bin", runLoginShellPath: () => { throw new Error("no shell"); },
platform: "darwin", delimiter: ":",
});
assert.equal(merged, "/usr/bin");
});

View File

@@ -0,0 +1,747 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
addCodexExecutableEnvForSdk,
buildWindowsShellCommandLine,
extractTrailingIdlePrompt,
formatSyntheticEcho,
getFreshIdlePrompt,
isDefaultPowerShellPromptLine,
isDefaultCmdPromptLine,
isDefaultPosixPromptLine,
isPlausibleCliVersionOutput,
looksLikeIdleAutoLogout,
prepareCommandForSpawn,
resolveWindowsShimToNativeExe,
resolveClaudeCodeExecutableForSdk,
resolveCodexExecutableForSdk,
resolveCodebuddyExecutableForSdk,
parseRegQueryPath,
expandWindowsEnvRefs,
mergeWindowsPath,
readWindowsRegistryPath,
trackSessionIdlePrompt,
} = require("./shellUtils.cjs");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
test("formatSyntheticEcho normalizes multi-line commands to CRLF so xterm doesn't staircase", () => {
assert.equal(
formatSyntheticEcho("set -e\ncd /tmp\necho done"),
"set -e\r\ncd /tmp\r\necho done\r\n",
);
// Already-CRLF input is not doubled.
assert.equal(formatSyntheticEcho("a\r\nb"), "a\r\nb\r\n");
// Single-line commands keep the original shape.
assert.equal(formatSyntheticEcho("npm test"), "npm test\r\n");
});
test("extracts a trailing PowerShell idle prompt", () => {
assert.equal(
extractTrailingIdlePrompt("Microsoft Windows...\r\nPS C:\\Users\\alice>"),
"PS C:\\Users\\alice>",
);
});
test("preserves trailing whitespace on a captured PowerShell prompt", () => {
// The wrapper-selection logic trims this, but the suffix-match logic in
// hasExpectedPromptSuffix() compares against raw PTY bytes, so the trailing
// space PowerShell emits after `>` must round-trip unchanged.
assert.equal(
extractTrailingIdlePrompt("Microsoft Windows...\r\nPS C:\\Users\\alice> "),
"PS C:\\Users\\alice> ",
);
});
test("extracts a bare PowerShell prompt with no working directory", () => {
assert.equal(extractTrailingIdlePrompt("welcome\r\nPS>"), "PS>");
});
test("does not extract content that merely looks PowerShell-ish", () => {
// Any non-prompt output ending in `PSO>` or `ZIPS>` would have produced a
// trailing newline before the next prompt; this guards against the regex
// accidentally matching command output that just happens to contain "PS".
assert.equal(extractTrailingIdlePrompt("nope\r\nPSO>"), "");
assert.equal(extractTrailingIdlePrompt("nope\r\nZIPS>"), "");
});
test("rejects `PS >` (literal `PS` + space + `>`) so spoofed scripts can't masquerade as a default prompt", () => {
// Default PowerShell never emits this shape; rejecting it makes the
// override harder to coerce via printed output.
assert.equal(extractTrailingIdlePrompt("welcome\r\nPS >"), "");
});
test("treats CR repaints as line breaks so only the redrawn line is captured", () => {
// PSReadLine / ConPTY emit bare `\r` to repaint the current line. The
// captured prompt must equal the visible last line, not the
// concatenation of every overwritten frame, so hasExpectedPromptSuffix
// can still match the live PTY tail later.
assert.equal(
extractTrailingIdlePrompt("PS C:\\old>\rPS C:\\new>"),
"PS C:\\new>",
);
});
test("isDefaultPowerShellPromptLine matches default shapes and rejects look-alikes", () => {
assert.equal(isDefaultPowerShellPromptLine("PS C:\\Users\\alice>"), true);
assert.equal(isDefaultPowerShellPromptLine("PS /home/alice>"), true);
assert.equal(isDefaultPowerShellPromptLine("PS>"), true);
assert.equal(isDefaultPowerShellPromptLine("PS >"), false);
assert.equal(isDefaultPowerShellPromptLine("PSO>"), false);
assert.equal(isDefaultPowerShellPromptLine("ZIPS>"), false);
assert.equal(isDefaultPowerShellPromptLine(""), false);
assert.equal(isDefaultPowerShellPromptLine(null), false);
});
test("extracts a trailing cmd.exe idle prompt", () => {
// Windows OpenSSH default shell is cmd.exe; without capturing `C:\...>`
// AI exec cannot select the cmd wrapper when shellKind is still unset.
assert.equal(
extractTrailingIdlePrompt("Microsoft Windows...\r\nC:\\Users\\alice>"),
"C:\\Users\\alice>",
);
assert.equal(extractTrailingIdlePrompt("welcome\r\nC:\\>"), "C:\\>");
assert.equal(extractTrailingIdlePrompt("welcome\r\nD:\\data\\proj>"), "D:\\data\\proj>");
});
test("isDefaultCmdPromptLine matches drive-letter cmd prompts only", () => {
assert.equal(isDefaultCmdPromptLine("C:\\Users\\alice>"), true);
assert.equal(isDefaultCmdPromptLine("C:\\>"), true);
assert.equal(isDefaultCmdPromptLine("C:>"), true);
assert.equal(isDefaultCmdPromptLine("PS C:\\Users\\alice>"), false);
assert.equal(isDefaultCmdPromptLine("alice@host:~$"), false);
assert.equal(isDefaultCmdPromptLine("C: >"), false);
assert.equal(isDefaultCmdPromptLine(""), false);
});
test("isDefaultPosixPromptLine matches classic user@host prompts", () => {
assert.equal(isDefaultPosixPromptLine("alice@host:~$"), true);
assert.equal(isDefaultPosixPromptLine("alice@wsl:/mnt/c$"), true);
assert.equal(isDefaultPosixPromptLine("root@box:/#"), true);
assert.equal(isDefaultPosixPromptLine("root@host ~#"), false);
assert.equal(isDefaultPosixPromptLine("PS C:\\Users\\alice>"), false);
assert.equal(isDefaultPosixPromptLine("C:\\Users\\alice>"), false);
assert.equal(isDefaultPosixPromptLine(""), false);
});
test("isPlausibleCliVersionOutput rejects stack traces and file URLs", () => {
assert.equal(isPlausibleCliVersionOutput("2.1.123 (Claude Code)"), true);
assert.equal(isPlausibleCliVersionOutput("codex-cli 0.125.0"), true);
assert.equal(isPlausibleCliVersionOutput("file:///opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js:95"), false);
assert.equal(isPlausibleCliVersionOutput("TypeError: Cannot read properties of undefined"), false);
assert.equal(isPlausibleCliVersionOutput(" at runCli (cli.js:10:1)"), false);
assert.equal(isPlausibleCliVersionOutput("permission denied"), false);
assert.equal(isPlausibleCliVersionOutput("Usage: claude [options]"), false);
});
test("buildWindowsShellCommandLine quotes command paths and args with spaces", () => {
assert.equal(
buildWindowsShellCommandLine("C:\\Program Files\\Codex\\codex.cmd", ["login", "status"]),
"\"C:\\Program Files\\Codex\\codex.cmd\" \"login\" \"status\"",
);
});
test("prepareCommandForSpawn wraps Windows cmd shims as a single shell command", () => {
const result = prepareCommandForSpawn("C:\\Program Files\\Codex\\codex.cmd", ["--version"]);
if (process.platform === "win32") {
assert.deepEqual(result, {
command: "\"C:\\Program Files\\Codex\\codex.cmd\" \"--version\"",
args: [],
shell: true,
});
} else {
assert.deepEqual(result, {
command: "C:\\Program Files\\Codex\\codex.cmd",
args: ["--version"],
shell: false,
});
}
});
test("resolveClaudeCodeExecutableForSdk maps Windows npm cmd shim to Claude Code cli.js", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-shim-"));
try {
const shimPath = path.join(tmp, "claude.cmd");
const scriptPath = path.join(tmp, "node_modules", "@anthropic-ai", "claude-code", "cli.js");
fs.mkdirSync(path.dirname(scriptPath), { recursive: true });
fs.writeFileSync(scriptPath, "", "utf8");
fs.writeFileSync(
shimPath,
'@ECHO off\r\nnode "%basedir%\\node_modules\\@anthropic-ai\\claude-code\\cli.js" %*\r\n',
"utf8",
);
assert.equal(resolveClaudeCodeExecutableForSdk(shimPath, "win32"), scriptPath);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveClaudeCodeExecutableForSdk leaves non-Windows Claude paths unchanged", () => {
assert.equal(
resolveClaudeCodeExecutableForSdk("/usr/local/bin/claude", "darwin"),
"/usr/local/bin/claude",
);
});
test("resolveClaudeCodeExecutableForSdk keeps Windows cmd shim when Claude Code cli.js is missing", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-missing-cli-"));
try {
const shimPath = path.join(tmp, "claude.cmd");
fs.writeFileSync(
shimPath,
'@ECHO off\r\nnode "%basedir%\\node_modules\\@anthropic-ai\\claude-code\\cli.js" %*\r\n',
"utf8",
);
assert.equal(resolveClaudeCodeExecutableForSdk(shimPath, "win32"), shimPath);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveClaudeCodeExecutableForSdk maps Windows npm cmd shim to native claude.exe when cli.js is absent", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-native-"));
try {
const shimPath = path.join(tmp, "claude.cmd");
const nativeExe = path.join(tmp, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe");
fs.mkdirSync(path.dirname(nativeExe), { recursive: true });
fs.writeFileSync(nativeExe, "", "utf8");
fs.writeFileSync(
shimPath,
'@ECHO off\r\n"%~dp0\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*\r\n',
"utf8",
);
assert.equal(resolveClaudeCodeExecutableForSdk(shimPath, "win32"), nativeExe);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveWindowsShimToNativeExe resolves npm .cmd shim to native exe", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-shim-native-"));
try {
const shimPath = path.join(tmp, "claude.cmd");
const nativeExe = path.join(tmp, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe");
fs.mkdirSync(path.dirname(nativeExe), { recursive: true });
fs.writeFileSync(nativeExe, "", "utf8");
// Single backslashes in the .cmd content (%~dp0 expands to the shim dir)
fs.writeFileSync(
shimPath,
'@ECHO off\r\n"%~dp0\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*\r\n',
"utf8",
);
const resolved = resolveWindowsShimToNativeExe(shimPath, "win32");
assert.equal(resolved, nativeExe);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("prepareCommandForSpawn can skip native exe unwrap for node+script shims", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-spawn-no-unwrap-"));
try {
const shimPath = path.join(tmp, "cursor-agent.cmd");
const nodeExe = path.join(tmp, "versions", "2026.06.01-abc", "node.exe");
fs.mkdirSync(path.dirname(nodeExe), { recursive: true });
fs.writeFileSync(nodeExe, "", "utf8");
fs.writeFileSync(
shimPath,
'@ECHO off\r\n"%~dp0\\versions\\2026.06.01-abc\\node.exe" "%~dp0\\versions\\2026.06.01-abc\\index.js" %*\r\n',
"utf8",
);
assert.equal(resolveWindowsShimToNativeExe(shimPath, "win32"), nodeExe);
const unwrapped = prepareCommandForSpawn(shimPath, ["status", "--format", "json"]);
const wrapped = prepareCommandForSpawn(shimPath, ["status", "--format", "json"], {
unwrapNativeExe: false,
});
if (process.platform === "win32") {
assert.deepEqual(unwrapped, {
command: nodeExe,
args: ["status", "--format", "json"],
shell: false,
});
assert.deepEqual(wrapped, {
command: buildWindowsShellCommandLine(shimPath, ["status", "--format", "json"]),
args: [],
shell: true,
});
} else {
assert.equal(unwrapped.shell, false);
assert.equal(wrapped.shell, false);
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("prepareCommandForSpawn resolves Windows cmd shim to native exe with shell:false", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-spawn-native-"));
try {
const shimPath = path.join(tmp, "claude.cmd");
const nativeExe = path.join(tmp, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe");
fs.mkdirSync(path.dirname(nativeExe), { recursive: true });
fs.writeFileSync(nativeExe, "", "utf8");
fs.writeFileSync(
shimPath,
'@ECHO off\r\n"%~dp0\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*\r\n',
"utf8",
);
const result = prepareCommandForSpawn(shimPath, ["--version"]);
if (process.platform === "win32") {
assert.deepEqual(result, {
command: nativeExe,
args: ["--version"],
shell: false,
});
} else {
// On non-Windows, resolveWindowsShimToNativeExe is skipped; verify win32 behavior explicitly.
assert.equal(resolveWindowsShimToNativeExe(shimPath, "win32"), nativeExe);
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
function writeCodexWin32NativeLayout(globalPrefix, arch = process.arch === "arm64" ? "arm64" : "x64") {
const triple = arch === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc";
const platformPackage = arch === "arm64" ? "@openai/codex-win32-arm64" : "@openai/codex-win32-x64";
const nativeExe = path.join(
globalPrefix,
"node_modules",
platformPackage,
"vendor",
triple,
"bin",
"codex.exe",
);
fs.mkdirSync(path.dirname(nativeExe), { recursive: true });
fs.writeFileSync(nativeExe, "", "utf8");
return nativeExe;
}
test("resolveCodexExecutableForSdk maps Windows npm cmd shim to native codex.exe", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-shim-"));
try {
const shimPath = path.join(tmp, "codex.cmd");
const nativeExe = writeCodexWin32NativeLayout(tmp);
fs.writeFileSync(
shimPath,
'@ECHO off\r\nnode "%~dp0\\node_modules\\@openai\\codex\\bin\\codex.js" %*\r\n',
"utf8",
);
assert.equal(resolveCodexExecutableForSdk(shimPath, "win32"), nativeExe);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCodexExecutableForSdk maps Windows local npm bin shim to native codex.exe", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-local-shim-"));
try {
const shimPath = path.join(tmp, "node_modules", ".bin", "codex.cmd");
const nativeExe = writeCodexWin32NativeLayout(tmp);
fs.mkdirSync(path.dirname(shimPath), { recursive: true });
fs.writeFileSync(
shimPath,
'@ECHO off\r\nnode "%~dp0\\..\\@openai\\codex\\bin\\codex.js" %*\r\n',
"utf8",
);
assert.equal(resolveCodexExecutableForSdk(shimPath, "win32"), nativeExe);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCodexExecutableForSdk leaves non-Windows Codex paths unchanged", () => {
assert.equal(
resolveCodexExecutableForSdk("/usr/local/bin/codex", "darwin"),
"/usr/local/bin/codex",
);
});
test("resolveCodexExecutableForSdk returns null for Windows cmd shim when native codex.exe is missing", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-missing-native-"));
try {
const shimPath = path.join(tmp, "codex.cmd");
fs.writeFileSync(
shimPath,
'@ECHO off\r\nnode "%~dp0\\node_modules\\@openai\\codex\\bin\\codex.js" %*\r\n',
"utf8",
);
assert.equal(resolveCodexExecutableForSdk(shimPath, "win32"), null);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCodexExecutableForSdk maps Windows nvmd bin shim to native codex.exe", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-nvmd-shim-"));
try {
const nvmdHome = path.join(tmp, ".nvmd");
const binDir = path.join(nvmdHome, "bin");
const versionRoot = path.join(nvmdHome, "versions", "22.14.0");
fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(path.join(nvmdHome, "default"), "22.14.0\n", "utf8");
fs.writeFileSync(
path.join(nvmdHome, "packages.json"),
JSON.stringify({ codex: ["22.14.0"] }),
"utf8",
);
// nvmd Windows package shims are copies of npm.cmd / nvmd.exe, not npm's
// @openai/codex launcher. The real install lives under versions/<ver>/.
const shimPath = path.join(binDir, "codex.cmd");
fs.writeFileSync(shimPath, '@echo off\r\n"%~dpn0.exe" %*\r\n', "utf8");
fs.writeFileSync(path.join(binDir, "codex.exe"), "", "utf8");
fs.writeFileSync(path.join(binDir, "nvmd.exe"), "", "utf8");
const nativeExe = writeCodexWin32NativeLayout(versionRoot);
assert.equal(resolveCodexExecutableForSdk(shimPath, "win32"), nativeExe);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCodexExecutableForSdk maps Windows nvmd.exe package shim to native codex.exe", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-nvmd-exe-"));
try {
const nvmdHome = path.join(tmp, ".nvmd");
const binDir = path.join(nvmdHome, "bin");
const versionRoot = path.join(nvmdHome, "versions", "20.18.0");
fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(path.join(nvmdHome, "default"), "20.18.0\n", "utf8");
const shimPath = path.join(binDir, "codex.exe");
fs.writeFileSync(shimPath, "", "utf8");
fs.writeFileSync(path.join(binDir, "nvmd.exe"), "", "utf8");
const nativeExe = writeCodexWin32NativeLayout(versionRoot);
assert.equal(resolveCodexExecutableForSdk(shimPath, "win32"), nativeExe);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCodexExecutableForSdk maps Windows PowerShell shim to native codex.exe", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-ps1-shim-"));
try {
const shimPath = path.join(tmp, "codex.ps1");
const nativeExe = writeCodexWin32NativeLayout(tmp);
fs.writeFileSync(
shimPath,
'& "$basedir/node_modules/@openai/codex/bin/codex.js" $args\r\n',
"utf8",
);
assert.equal(resolveCodexExecutableForSdk(shimPath, "win32"), nativeExe);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCodexExecutableForSdk maps codex.js entry to native codex.exe", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-js-entry-"));
try {
const codexJs = path.join(tmp, "node_modules", "@openai", "codex", "bin", "codex.js");
const nativeExe = writeCodexWin32NativeLayout(tmp);
fs.mkdirSync(path.dirname(codexJs), { recursive: true });
fs.writeFileSync(codexJs, "", "utf8");
assert.equal(resolveCodexExecutableForSdk(codexJs, "win32"), nativeExe);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("addCodexExecutableEnvForSdk prepends bundled Codex path dir on Windows", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-env-path-"));
try {
const nativeExe = writeCodexWin32NativeLayout(tmp);
const pathDir = path.join(path.dirname(path.dirname(nativeExe)), "codex-path");
fs.mkdirSync(pathDir, { recursive: true });
const env = addCodexExecutableEnvForSdk({ Path: "C:\\Windows\\System32" }, nativeExe, "win32");
assert.equal(env.Path, `${pathDir};C:\\Windows\\System32`);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
function writeCodebuddyWin32BinLayout(dir) {
const binJs = path.join(dir, "node_modules", "@tencent-ai", "codebuddy-code", "bin", "codebuddy");
fs.mkdirSync(path.dirname(binJs), { recursive: true });
fs.writeFileSync(binJs, "#!/usr/bin/env node\n", "utf8");
return binJs;
}
test("resolveCodebuddyExecutableForSdk leaves non-Windows CodeBuddy paths unchanged", () => {
assert.equal(
resolveCodebuddyExecutableForSdk("/usr/local/bin/codebuddy", "darwin"),
"/usr/local/bin/codebuddy",
);
});
test("resolveCodebuddyExecutableForSdk maps Windows npm cmd shim to package bin/codebuddy", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codebuddy-shim-"));
try {
const shimPath = path.join(tmp, "codebuddy.cmd");
const binJs = writeCodebuddyWin32BinLayout(tmp);
fs.writeFileSync(
shimPath,
'@ECHO off\r\nnode "%~dp0\\node_modules\\@tencent-ai\\codebuddy-code\\bin\\codebuddy" %*\r\n',
"utf8",
);
assert.equal(resolveCodebuddyExecutableForSdk(shimPath, "win32"), binJs);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCodebuddyExecutableForSdk maps extensionless Windows shim to package bin/codebuddy", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codebuddy-noext-"));
try {
const shimPath = path.join(tmp, "codebuddy");
const binJs = writeCodebuddyWin32BinLayout(tmp);
fs.writeFileSync(shimPath, "#!/bin/sh\n", "utf8");
assert.equal(resolveCodebuddyExecutableForSdk(shimPath, "win32"), binJs);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCodebuddyExecutableForSdk returns null for Windows cmd shim when package JS is missing", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codebuddy-missing-"));
try {
const shimPath = path.join(tmp, "codebuddy.cmd");
fs.writeFileSync(shimPath, "@ECHO off\r\nnode foo %*\r\n", "utf8");
assert.equal(resolveCodebuddyExecutableForSdk(shimPath, "win32"), null);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCodebuddyExecutableForSdk passes through a native exe path", () => {
assert.equal(
resolveCodebuddyExecutableForSdk("C:\\tools\\codebuddy.exe", "win32"),
"C:\\tools\\codebuddy.exe",
);
});
test("parseRegQueryPath extracts the Path value from reg query output", () => {
const out = parseRegQueryPath(
"\r\nHKEY_CURRENT_USER\\Environment\r\n Path REG_EXPAND_SZ C:\\Users\\me\\AppData\\Roaming\\npm;C:\\tools\r\n",
);
assert.equal(out, "C:\\Users\\me\\AppData\\Roaming\\npm;C:\\tools");
});
test("parseRegQueryPath handles REG_SZ and missing value", () => {
assert.equal(parseRegQueryPath(" Path REG_SZ C:\\bin"), "C:\\bin");
assert.equal(parseRegQueryPath("HKEY_CURRENT_USER\\Environment\r\n Temp REG_SZ C:\\Temp"), "");
});
test("expandWindowsEnvRefs expands %VAR% case-insensitively", () => {
assert.equal(
expandWindowsEnvRefs("%AppData%\\npm;%Other%", { APPDATA: "C:\\Users\\me\\AppData\\Roaming" }),
"C:\\Users\\me\\AppData\\Roaming\\npm;%Other%",
);
});
test("mergeWindowsPath dedupes case-insensitively and trims trailing slashes", () => {
const out = mergeWindowsPath(
"C:\\Windows\\System32;C:\\tools\\",
"c:\\windows\\system32;C:\\tools;C:\\new",
);
assert.equal(out, "C:\\Windows\\System32;C:\\tools\\;C:\\new");
});
test("mergeWindowsPath keeps refreshed Windows PATH entries ahead of stale process entries", () => {
const out = mergeWindowsPath(
"C:\\new-codebuddy;C:\\Windows\\System32",
"C:\\Users\\me\\AppData\\Roaming\\npm",
"C:\\old-codebuddy;C:\\Windows\\System32",
);
assert.equal(out, "C:\\new-codebuddy;C:\\Windows\\System32;C:\\Users\\me\\AppData\\Roaming\\npm;C:\\old-codebuddy");
});
test("readWindowsRegistryPath merges HKCU and HKLM and expands refs", async () => {
const exec = async (cmd, args) => {
assert.equal(cmd, "reg");
const hive = args[1];
if (hive === "HKCU\\Environment") {
return { stdout: " Path REG_EXPAND_SZ %APPDATA%\\npm\r\n" };
}
return { stdout: " Path REG_EXPAND_SZ C:\\Windows\\System32\r\n" };
};
const out = await readWindowsRegistryPath({ exec, env: { APPDATA: "C:\\Roaming" } });
assert.equal(out, "C:\\Roaming\\npm;C:\\Windows\\System32");
});
test("readWindowsRegistryPath tolerates a failing hive query", async () => {
const exec = async (cmd, args) => {
if (args[1] === "HKCU\\Environment") throw new Error("ERROR: cannot read");
return { stdout: " Path REG_SZ C:\\tools\r\n" };
};
const out = await readWindowsRegistryPath({ exec, env: {} });
assert.equal(out, "C:\\tools");
});
test("tracks PowerShell idle prompt after SSH output", () => {
const session = {};
const prompt = trackSessionIdlePrompt(session, "Last login...\r\nPS C:\\Windows\\System32>");
assert.equal(prompt, "PS C:\\Windows\\System32>");
assert.equal(session.lastIdlePrompt, "PS C:\\Windows\\System32>");
assert.equal(typeof session.lastIdlePromptAt, "number");
});
test("getFreshIdlePrompt returns the cached prompt when the live tail still ends with it", () => {
const session = {
lastIdlePrompt: "PS C:\\Users\\alice>",
_promptTrackTail: "Microsoft Windows...\r\nPS C:\\Users\\alice>",
};
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice>");
});
test("getFreshIdlePrompt drops a stale prompt when the live tail has moved on (e.g. exited PowerShell)", () => {
// Simulates: SSH session entered PowerShell, captured `PS C:\>`, then
// user `exit`-ed back into a shell with a custom prompt the regex
// doesn't recognize. lastIdlePrompt is still the old PS line, but the
// visible tail now shows the new prompt — we must NOT keep handing
// the stale value to resolveEffectiveShellKind.
const session = {
lastIdlePrompt: "PS C:\\Users\\alice>",
_promptTrackTail: "PS C:\\Users\\alice>\r\nexit\r\nlogout\r\n ",
};
assert.equal(getFreshIdlePrompt(session), "");
});
test("getFreshIdlePrompt drops a stale prompt when the live tail switched to cmd.exe", () => {
const session = {
lastIdlePrompt: "PS C:\\Users\\alice>",
_promptTrackTail: "PS C:\\Users\\alice>\r\ncmd\r\nMicrosoft Windows...\r\nC:\\Users\\alice>",
};
assert.equal(getFreshIdlePrompt(session), "");
});
test("getFreshIdlePrompt tolerates ANSI colour codes that wrap the prompt in either side", () => {
const session = {
lastIdlePrompt: "PS C:\\Users\\alice>",
_promptTrackTail: "stuff\r\nPS C:\\Users\\alice>",
};
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice>");
});
test("getFreshIdlePrompt returns empty string when the session has no cached prompt or tail", () => {
assert.equal(getFreshIdlePrompt(null), "");
assert.equal(getFreshIdlePrompt(undefined), "");
assert.equal(getFreshIdlePrompt({}), "");
assert.equal(getFreshIdlePrompt({ lastIdlePrompt: "PS C:\\>" }), "");
assert.equal(
getFreshIdlePrompt({ lastIdlePrompt: "", _promptTrackTail: "anything" }),
"",
);
});
test("getFreshIdlePrompt and trackSessionIdlePrompt round-trip through a real PTY-like flow", () => {
// (1) Remote PowerShell prompt arrives — lastIdlePrompt is captured.
const session = {};
trackSessionIdlePrompt(session, "Microsoft Windows...\r\nPS C:\\Users\\alice>");
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice>");
// (2) User runs `exit` and the shell now shows an unrecognized prompt.
// trackSessionIdlePrompt does not update lastIdlePrompt (the new shape
// doesn't match POSIX or PowerShell regexes), so the cache is stale.
trackSessionIdlePrompt(session, "\r\nexit\r\nlogout\r\n ");
assert.equal(session.lastIdlePrompt, "PS C:\\Users\\alice>"); // unchanged
// The freshness check rescues us: the visible tail no longer ends
// with the cached PS line, so downstream wrapper selection sees "".
assert.equal(getFreshIdlePrompt(session), "");
});
test("looksLikeIdleAutoLogout detects the bash TMOUT banner at the tail", () => {
// bash prints this immediately before a TMOUT auto-logout exit. The exit
// itself is a clean shell exit (code 0, no signal), so the banner is the
// only reliable discriminator from a user-typed `exit` (#1062 / #977).
assert.equal(
looksLikeIdleAutoLogout("user@host:~$ \x07timed out waiting for input: auto-logout\r\n"),
true,
);
});
test("looksLikeIdleAutoLogout detects the csh/tcsh auto-logout banner", () => {
assert.equal(looksLikeIdleAutoLogout("\r\nauto-logout\r\n"), true);
});
test("looksLikeIdleAutoLogout sees through ANSI escapes around the banner", () => {
assert.equal(
looksLikeIdleAutoLogout("\x1b[0m\x1b[33mtimed out waiting for input: auto-logout\x1b[0m\r\n"),
true,
);
});
test("looksLikeIdleAutoLogout ignores a plain (non-timeout) logout", () => {
// A normal login-shell exit prints "logout" — without the "auto-" prefix —
// and must still auto-close the tab.
assert.equal(looksLikeIdleAutoLogout("user@host:~$ logout\r\n"), false);
});
test("looksLikeIdleAutoLogout ignores the banner when it is not at the tail", () => {
// "auto-logout" scrolled past long ago; the user then ran more commands and
// exited normally. Only the tail end is inspected, so this is not a timeout.
const tail = "auto-logout\n" + "x".repeat(400) + "\nuser@host:~$ logout\r\n";
assert.equal(looksLikeIdleAutoLogout(tail), false);
});
test("looksLikeIdleAutoLogout ignores auto-logout in command output before an intentional exit", () => {
// Investigating TMOUT: the user greps the profile (output mentions
// "auto-logout"), reads it, then exits on purpose. The banner is not the
// final line, so the tab must still auto-close. Guards against matching an
// unanchored substring anywhere in the recent output.
const tail =
"root@h:~# grep -i auto-logout /etc/profile\r\n" +
"# bash TMOUT auto-logout setting\r\nTMOUT=300\r\n" +
"root@h:~# exit\r\nlogout\r\n";
assert.equal(looksLikeIdleAutoLogout(tail), false);
});
test("looksLikeIdleAutoLogout matches the real-server banner shape (prompt + banner on one line)", () => {
// The banner can share a line with the trailing prompt after ANSI/control
// bytes are stripped (observed over real SSH); anchoring on the line end
// must still match.
const tail =
"\x1b]0;root@VM:~\x07root@VM:~# \x1b[?2004l\x07timed out waiting for input: auto-logout\n";
assert.equal(looksLikeIdleAutoLogout(tail), true);
});
test("looksLikeIdleAutoLogout returns false for empty / non-string input", () => {
assert.equal(looksLikeIdleAutoLogout(""), false);
assert.equal(looksLikeIdleAutoLogout(undefined), false);
assert.equal(looksLikeIdleAutoLogout(null), false);
});
function withExecPath(fakePath, fn) {
const original = process.execPath;
Object.defineProperty(process, "execPath", { value: fakePath, configurable: true, writable: true });
try {
return fn();
} finally {
Object.defineProperty(process, "execPath", { value: original, configurable: true, writable: true });
}
}

View File

@@ -0,0 +1,19 @@
/**
* Extract the payload of an SSE `data:` field.
*
* The WHATWG EventSource spec treats the space after the colon as optional
* and strips at most one leading U+0020. Older AxonHub (0.9) and some
* intranet OpenAI-compat proxies emit `data:{json}` with no space, which
* a `data: ` prefix check silently drops (issue #3020).
*
* @param {unknown} line
* @returns {string | null}
*/
function extractSseDataPayload(line) {
const trimmed = typeof line === "string" ? line.trim() : "";
if (!trimmed.startsWith("data:")) return null;
const payload = trimmed.slice("data:".length);
return payload.startsWith(" ") ? payload.slice(1) : payload;
}
module.exports = { extractSseDataPayload };

View File

@@ -0,0 +1,26 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { extractSseDataPayload } = require("./sseDataLine.cjs");
test("extractSseDataPayload accepts data: with or without the optional space", () => {
assert.equal(extractSseDataPayload('data: {"a":1}'), '{"a":1}');
assert.equal(extractSseDataPayload('data:{"a":1}'), '{"a":1}');
assert.equal(extractSseDataPayload(' data:{"a":1} \r'), '{"a":1}');
assert.equal(extractSseDataPayload("data: [DONE]"), "[DONE]");
assert.equal(extractSseDataPayload("data:[DONE]"), "[DONE]");
});
test("extractSseDataPayload strips only one leading space after the colon", () => {
assert.equal(extractSseDataPayload("data: keep"), " keep");
assert.equal(extractSseDataPayload("data:"), "");
assert.equal(extractSseDataPayload("data: "), "");
});
test("extractSseDataPayload ignores non-data SSE lines", () => {
assert.equal(extractSseDataPayload(""), null);
assert.equal(extractSseDataPayload(": comment"), null);
assert.equal(extractSseDataPayload("event: message"), null);
assert.equal(extractSseDataPayload("DATA: foo"), null);
assert.equal(extractSseDataPayload(null), null);
assert.equal(extractSseDataPayload(undefined), null);
});

View File

@@ -0,0 +1,530 @@
const fsPromises = require("node:fs/promises");
const path = require("node:path");
const USER_SKILLS_DIR_NAME = "Skills";
const USER_SKILLS_README_NAME = "README.txt";
const MAX_SKILL_BYTES = 24 * 1024;
const MAX_DESCRIPTION_LENGTH = 500;
const MAX_INDEX_SKILLS = 8;
const MAX_INDEX_DESCRIPTION_CHARS = 160;
const MAX_INDEX_LINE_CHARS = 1400;
const MAX_EXPLICIT_SKILLS = 4;
const MAX_MATCHED_SKILLS = 2;
const MAX_MATCHED_SKILL_CHARS = 6000;
const MAX_TOTAL_INJECTED_SKILL_CHARS = 12000;
const USER_SKILLS_README_CONTENT = [
"Netcatty user skills",
"",
"Add one folder per skill inside this directory.",
"Each skill folder must contain a SKILL.md file.",
"",
"Example layout:",
" Skills/",
" My Skill/",
" SKILL.md",
"",
"Minimal SKILL.md:",
" ---",
" name: My Skill",
" description: Short summary of what this skill helps with.",
" ---",
"",
" Write the skill instructions here.",
"",
"After adding or editing a skill, reopen the AI settings page or start a new chat to refresh the list.",
"",
].join("\n");
const STOPWORDS = new Set([
"the", "and", "for", "with", "that", "this", "from", "into", "when", "then",
"only", "your", "will", "should", "have", "has", "had", "using", "use",
"agent", "skill", "skills", "task", "file", "files", "user", "into", "about",
]);
function stripQuotes(value) {
const trimmed = String(value || "").trim();
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function slugifySkill(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function tokenize(value) {
return String(value || "")
.toLowerCase()
.split(/[^a-z0-9]+/i)
.map((token) => token.trim())
.filter((token) => token.length >= 3 && !STOPWORDS.has(token));
}
function escapeRegExp(value) {
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function truncateInlineText(value, maxChars) {
const normalized = String(value || "").replace(/\s+/g, " ").trim();
if (normalized.length <= maxChars) return normalized;
return `${normalized.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
}
function formatSkillReadWarning(error) {
const code = typeof error?.code === "string" ? error.code : null;
const message = typeof error?.message === "string" ? error.message : String(error || "Unknown error");
return code
? `Failed to read SKILL.md (${code}: ${message}).`
: `Failed to read SKILL.md (${message}).`;
}
function containsPlaintextPhrase(prompt, phrase) {
const trimmedPhrase = String(phrase || "").trim();
if (!trimmedPhrase) return false;
const pattern = new RegExp(`(^|\\s)${escapeRegExp(trimmedPhrase)}(?=$|\\s|[.,!?;:])`, "i");
return pattern.test(String(prompt || ""));
}
function parseFrontmatter(content) {
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content);
if (!match) {
return { attributes: {}, body: content, hasFrontmatter: false };
}
const attributes = {};
for (const rawLine of match[1].split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const colonIndex = line.indexOf(":");
if (colonIndex <= 0) continue;
const key = line.slice(0, colonIndex).trim();
const value = stripQuotes(line.slice(colonIndex + 1).trim());
if (key) attributes[key] = value;
}
return {
attributes,
body: content.slice(match[0].length),
hasFrontmatter: true,
};
}
function summarizeSkillSlugs(skillsOrSlugs, maxItems = 4) {
const values = (Array.isArray(skillsOrSlugs) ? skillsOrSlugs : [])
.map((entry) => {
if (typeof entry === "string") return entry;
const slug = typeof entry?.slug === "string" ? entry.slug : "";
return slug;
})
.filter(Boolean)
.map((slug) => `/${slug}`);
if (values.length <= maxItems) {
return values.join(", ");
}
return `${values.slice(0, maxItems).join(", ")}, and ${values.length - maxItems} more`;
}
function getUserSkillsDir(electronApp) {
const userDataDir = electronApp?.getPath?.("userData");
if (!userDataDir) {
throw new Error("Electron app userData path is unavailable.");
}
return path.join(userDataDir, USER_SKILLS_DIR_NAME);
}
async function ensureUserSkillsDir(electronApp) {
const skillsDir = getUserSkillsDir(electronApp);
await fsPromises.mkdir(skillsDir, { recursive: true });
return skillsDir;
}
async function ensureUserSkillsReadme(electronApp) {
const skillsDir = await ensureUserSkillsDir(electronApp);
const dirEntries = await fsPromises.readdir(skillsDir);
if (dirEntries.length === 0) {
await fsPromises.writeFile(
path.join(skillsDir, USER_SKILLS_README_NAME),
USER_SKILLS_README_CONTENT,
"utf8",
);
}
return skillsDir;
}
async function scanUserSkills(electronApp) {
const skillsDir = await ensureUserSkillsReadme(electronApp);
const dirEntries = await fsPromises.readdir(skillsDir, { withFileTypes: true });
const skills = [];
const warnings = [];
for (const entry of dirEntries) {
// Only process actual directories, skipping symlinks for security
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
const dirName = entry.name;
// Basic path traversal protection: skip any directory name containing path separators
if (dirName.includes("/") || dirName.includes("\\") || dirName === ".." || dirName === ".") {
continue;
}
const skillDir = path.join(skillsDir, dirName);
const skillPath = path.join(skillDir, "SKILL.md");
const baseItem = {
id: dirName,
slug: slugifySkill(dirName),
directoryName: dirName,
directoryPath: skillDir,
skillPath,
name: dirName,
description: "",
status: "warning",
warnings: [],
};
try {
await fsPromises.access(skillPath);
} catch {
baseItem.warnings.push("Missing SKILL.md");
warnings.push(`${dirName}: Missing SKILL.md`);
skills.push(baseItem);
continue;
}
try {
const stat = await fsPromises.lstat(skillPath);
if (stat.isSymbolicLink()) {
baseItem.warnings.push("SKILL.md must not be a symbolic link.");
warnings.push(`${dirName}: SKILL.md must not be a symbolic link.`);
skills.push(baseItem);
continue;
}
if (!stat.isFile()) {
baseItem.warnings.push("SKILL.md must be a regular file.");
warnings.push(`${dirName}: SKILL.md must be a regular file.`);
skills.push(baseItem);
continue;
}
if (stat.size > MAX_SKILL_BYTES) {
baseItem.warnings.push(`SKILL.md is too large (${stat.size} bytes > ${MAX_SKILL_BYTES} bytes).`);
warnings.push(`${dirName}: SKILL.md is too large.`);
skills.push(baseItem);
continue;
}
const content = await fsPromises.readFile(skillPath, "utf8");
const { attributes, body, hasFrontmatter } = parseFrontmatter(content);
const name = stripQuotes(attributes.name || "").trim();
const description = stripQuotes(attributes.description || "").trim();
const usableSlug = slugifySkill(name || dirName);
if (!hasFrontmatter) {
baseItem.warnings.push("Missing YAML frontmatter.");
}
if (!name) {
baseItem.warnings.push("Missing frontmatter field: name.");
}
if (!description) {
baseItem.warnings.push("Missing frontmatter field: description.");
} else if (description.length > MAX_DESCRIPTION_LENGTH) {
baseItem.warnings.push(`Description is too long (${description.length} chars > ${MAX_DESCRIPTION_LENGTH}).`);
}
if (!usableSlug) {
baseItem.warnings.push("Skill name must include ASCII letters or digits to generate a usable slug.");
}
if (baseItem.warnings.length > 0) {
warnings.push(...baseItem.warnings.map((warning) => `${dirName}: ${warning}`));
skills.push({
...baseItem,
slug: usableSlug,
name: name || dirName,
description,
});
continue;
}
skills.push({
...baseItem,
slug: usableSlug,
name,
description,
status: "ready",
warnings: [],
body,
mtimeMs: stat.mtimeMs,
});
} catch (error) {
const warning = formatSkillReadWarning(error);
baseItem.warnings.push(warning);
warnings.push(`${dirName}: ${warning}`);
skills.push(baseItem);
}
}
const readySkillsBySlug = new Map();
for (const skill of skills) {
if (skill.status !== "ready" || !skill.slug) continue;
const matches = readySkillsBySlug.get(skill.slug);
if (matches) {
matches.push(skill);
} else {
readySkillsBySlug.set(skill.slug, [skill]);
}
}
for (const [slug, duplicateSkills] of readySkillsBySlug.entries()) {
if (duplicateSkills.length < 2) continue;
const duplicateWarning = `Duplicate skill slug "${slug}". Rename the skill or change its frontmatter name.`;
for (const skill of duplicateSkills) {
skill.status = "warning";
skill.warnings = [...skill.warnings, duplicateWarning];
warnings.push(`${skill.directoryName}: ${duplicateWarning}`);
}
}
const readyCount = skills.filter((skill) => skill.status === "ready").length;
const warningCount = skills.filter((skill) => skill.status === "warning").length;
return {
directoryPath: skillsDir,
readyCount,
warningCount,
skills: skills.map((skill) => ({
id: skill.id,
slug: skill.slug,
directoryName: skill.directoryName,
directoryPath: skill.directoryPath,
skillPath: skill.skillPath,
name: skill.name,
description: skill.description,
status: skill.status,
warnings: skill.warnings,
})),
warnings,
_readySkills: skills.filter((skill) => skill.status === "ready"),
};
}
/**
* Scores how well a skill matches a user prompt.
*
* Scored based on:
* - 50 points: Plain-text name/directory mention (e.g. prompt contains "my skill")
* - 1 point per keyword overlap (after tokenization/stopword filtering)
*
* @param {string} prompt - The user prompt
* @param {object} skill - The skill object from scanUserSkills
* @returns {number} The score (higher is better)
*/
function scoreSkillMatch(prompt, skill) {
const name = String(skill.name || "").trim();
const directoryName = String(skill.directoryName || "").trim();
// High weight for an exact plain-text mention of the skill name.
if (
(name && containsPlaintextPhrase(prompt, name)) ||
(directoryName && containsPlaintextPhrase(prompt, directoryName))
) {
return 50;
}
// Fallback to token keyword overlap
const promptTokens = new Set(tokenize(prompt));
const skillTokens = tokenize(`${skill.name} ${skill.description}`);
let overlap = 0;
for (const token of skillTokens) {
if (promptTokens.has(token)) overlap += 1;
}
return overlap;
}
/**
* Builds the contextual prompt part from matched user skills.
*
* @param {object} electronApp - The Electron app instance
* @param {string} prompt - The user's input prompt
* @param {string[]} selectedSkillSlugs - Explicitly requested skill slugs
* @returns {Promise<{context: string, status: object}>} The built prompt part and scan status
*/
async function buildUserSkillsContext(electronApp, prompt, selectedSkillSlugs = []) {
const status = await scanUserSkills(electronApp);
const readySkills = status._readySkills || [];
const trimmedPrompt = String(prompt || "").trim();
if (readySkills.length === 0) {
return { context: "", status };
}
const indexSkills = readySkills.slice(0, MAX_INDEX_SKILLS);
let remainingCount = Math.max(readySkills.length - indexSkills.length, 0);
const indexEntries = [];
let indexChars = 0;
for (const skill of indexSkills) {
const entry = `${skill.name}: ${truncateInlineText(skill.description, MAX_INDEX_DESCRIPTION_CHARS)}`;
const separatorChars = indexEntries.length > 0 ? 2 : 0;
if (indexChars + separatorChars + entry.length > MAX_INDEX_LINE_CHARS) {
remainingCount += indexSkills.length - indexEntries.length;
break;
}
indexEntries.push(entry);
indexChars += separatorChars + entry.length;
}
const indexLine = indexEntries.join("; ");
const orderedExplicitSlugs = [];
const seenExplicitSlugs = new Set();
for (const rawSlug of Array.isArray(selectedSkillSlugs) ? selectedSkillSlugs : []) {
const slug = slugifySkill(rawSlug);
if (!slug || seenExplicitSlugs.has(slug)) continue;
seenExplicitSlugs.add(slug);
orderedExplicitSlugs.push(slug);
}
const additionalExplicitCount = Math.max(orderedExplicitSlugs.length - MAX_EXPLICIT_SKILLS, 0);
const cappedExplicitSlugs = orderedExplicitSlugs.slice(0, MAX_EXPLICIT_SKILLS);
const explicitSlugSet = new Set(cappedExplicitSlugs);
const readySkillsBySlug = new Map(readySkills.map((skill) => [skill.slug, skill]));
const explicitSkills = [];
const unavailableExplicitSlugs = [];
for (const slug of cappedExplicitSlugs) {
const skill = readySkillsBySlug.get(slug);
if (skill) {
explicitSkills.push(skill);
} else {
unavailableExplicitSlugs.push(slug);
}
}
const matchedSkills = readySkills
.filter((skill) => !explicitSlugSet.has(skill.slug))
.map((skill) => ({ skill, score: scoreSkillMatch(trimmedPrompt, skill) }))
.filter((entry) => entry.score >= 2)
.sort((left, right) => right.score - left.score)
.slice(0, MAX_MATCHED_SKILLS)
.map((entry) => entry.skill);
const finalSkills = [...explicitSkills, ...matchedSkills];
const parts = [
"User-managed skills are installed in Netcatty.",
`Available user skills: ${indexLine}${remainingCount > 0 ? `; and ${remainingCount} more.` : "."}`,
"Use a user-managed skill only when it clearly matches the current request.",
];
if (additionalExplicitCount > 0) {
parts.push(
`The user selected ${additionalExplicitCount} additional Netcatty user skills that were omitted to stay within the prompt budget.`,
);
}
if (unavailableExplicitSlugs.length > 0) {
parts.push(
`The user explicitly selected these Netcatty user skills for this request, but their content is currently unavailable: ${summarizeSkillSlugs(unavailableExplicitSlugs)}.`,
);
}
if (finalSkills.length > 0) {
const includedSkillSections = [];
const omittedSkills = [];
const truncatedSkills = [];
let remainingSkillChars = MAX_TOTAL_INJECTED_SKILL_CHARS;
let budgetStopIndex = finalSkills.length;
for (let index = 0; index < finalSkills.length; index += 1) {
const skill = finalSkills[index];
const heading = `### ${skill.name}\n`;
const maxBodyChars = Math.min(
MAX_MATCHED_SKILL_CHARS,
Math.max(remainingSkillChars - heading.length, 0),
);
if (maxBodyChars <= 0) {
omittedSkills.push(skill);
continue;
}
const rawBody = String(skill.body || "").trim();
if (!rawBody) {
omittedSkills.push(skill);
continue;
}
if (rawBody.length > maxBodyChars && includedSkillSections.length > 0) {
omittedSkills.push(skill);
budgetStopIndex = index;
continue;
}
const body = rawBody.slice(0, maxBodyChars);
if (!body) {
omittedSkills.push(skill);
continue;
}
includedSkillSections.push(`${heading}${body}`);
remainingSkillChars -= heading.length + body.length;
if (body.length < rawBody.length) {
truncatedSkills.push(skill);
budgetStopIndex = index + 1;
break;
}
}
parts.push("Matched user-managed skills for this request:");
if (includedSkillSections.length > 0) {
parts.push(...includedSkillSections);
}
const omittedAfterIncluded = finalSkills.slice(budgetStopIndex);
for (const skill of omittedAfterIncluded) {
if (!omittedSkills.includes(skill) && !truncatedSkills.includes(skill)) {
omittedSkills.push(skill);
}
}
if (truncatedSkills.length > 0) {
parts.push(
`Some matched user-managed skill content was truncated to stay within the prompt budget: ${summarizeSkillSlugs(truncatedSkills)}.`,
);
}
if (omittedSkills.length > 0) {
parts.push(
`Additional matched user-managed skills were omitted to stay within the prompt budget: ${summarizeSkillSlugs(omittedSkills)}.`,
);
}
}
return {
context: parts.join("\n\n"),
status,
};
}
function toPublicUserSkillsStatus(status) {
if (!status || typeof status !== "object") {
return status;
}
const publicStatus = { ...status };
delete publicStatus._readySkills;
return publicStatus;
}
module.exports = {
USER_SKILLS_DIR_NAME,
getUserSkillsDir,
ensureUserSkillsDir,
ensureUserSkillsReadme,
scanUserSkills,
buildUserSkillsContext,
toPublicUserSkillsStatus,
};

View File

@@ -0,0 +1,403 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { buildUserSkillsContext, scanUserSkills } = require("./userSkills.cjs");
async function withUserSkills(skillDefinitions, run) {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-user-skills-"));
const userDataDir = path.join(rootDir, "userData");
const skillsDir = path.join(userDataDir, "Skills");
await fs.mkdir(skillsDir, { recursive: true });
for (const skill of skillDefinitions) {
const skillDir = path.join(skillsDir, skill.directoryName);
await fs.mkdir(skillDir, { recursive: true });
const content = [
"---",
`name: ${skill.name}`,
`description: ${skill.description}`,
"---",
"",
skill.body,
"",
].join("\n");
await fs.writeFile(path.join(skillDir, "SKILL.md"), content, "utf8");
}
const electronApp = {
getPath(key) {
return key === "userData" ? userDataDir : "";
},
};
try {
await run(electronApp);
} finally {
await fs.rm(rootDir, { recursive: true, force: true });
}
}
test("does not auto-match a user skill from an absolute path segment", async () => {
await withUserSkills(
[
{
directoryName: "Tmp Helper",
name: "tmp",
description: "Helper for scratch space workflows.",
body: "Body for tmp",
},
],
async (electronApp) => {
const result = await buildUserSkillsContext(
electronApp,
"please inspect /tmp/netcatty.log",
[],
);
assert.equal(result.context.includes("Matched user-managed skills for this request:"), false);
assert.equal(result.context.includes("Body for tmp"), false);
},
);
});
test("keeps every explicitly selected skill in the built context", async () => {
await withUserSkills(
[
{
directoryName: "Alpha One",
name: "Alpha One",
description: "Alpha helper.",
body: "Body for Alpha One",
},
{
directoryName: "Beta Two",
name: "Beta Two",
description: "Beta helper.",
body: "Body for Beta Two",
},
{
directoryName: "Gamma Three",
name: "Gamma Three",
description: "Gamma helper.",
body: "Body for Gamma Three",
},
],
async (electronApp) => {
const result = await buildUserSkillsContext(
electronApp,
"plain prompt",
["alpha-one", "beta-two", "gamma-three"],
);
assert.equal(result.context.includes("Body for Alpha One"), true);
assert.equal(result.context.includes("Body for Beta Two"), true);
assert.equal(result.context.includes("Body for Gamma Three"), true);
},
);
});
test("uses longer skill descriptions for routing matches without injecting the full index text", async () => {
const longDescription = [
"Use when the user needs a detailed workflow for operating Netcatty through SDK skills and CLI.",
"Includes platform launcher guidance, scoped command execution, recovery behavior, and constraints.",
"This intentionally exceeds the older short description budget so routing has enough signal.",
"It also names edge cases such as unavailable optional shells, strict chat-session scoping, and fallback-only history replay so the agent can choose the skill without reading the whole body first.",
].join(" ");
assert.ok(longDescription.length > 320);
await withUserSkills(
[
{
directoryName: "Detailed Router",
name: "Detailed Router",
description: longDescription,
body: "Detailed router body",
},
],
async (electronApp) => {
const status = await scanUserSkills(electronApp);
const result = await buildUserSkillsContext(
electronApp,
"Need fallback-only history replay guidance for SDK recovery.",
[],
);
assert.equal(status.readyCount, 1);
assert.equal(status.warningCount, 0);
assert.equal(result.context.includes("### Detailed Router"), true);
assert.equal(result.context.includes("Detailed router body"), true);
assert.equal(result.context.includes(longDescription), false);
},
);
});
test("caps the injected available-skills index when descriptions are very long", async () => {
const longDescription = "signal ".repeat(65);
await withUserSkills(
Array.from({ length: 8 }, (_, index) => ({
directoryName: `Skill ${index + 1}`,
name: `Skill ${index + 1}`,
description: `${longDescription}${index + 1}`,
body: `Body ${index + 1}`,
})),
async (electronApp) => {
const result = await buildUserSkillsContext(
electronApp,
"plain prompt",
[],
);
const availableLine = result.context
.split("\n")
.find((line) => line.startsWith("Available user skills: "));
assert.ok(availableLine, "expected available-skills index line");
assert.ok(availableLine.length < 1800, `expected capped index line, got ${availableLine.length}`);
},
);
});
test("preserves an unavailable explicit selection in the built context", async () => {
await withUserSkills(
[
{
directoryName: "Beta",
name: "Beta",
description: "Beta helper.",
body: "Body for Beta",
},
],
async (electronApp) => {
const result = await buildUserSkillsContext(
electronApp,
"plain prompt",
["missing-skill"],
);
assert.equal(result.context.includes("Available user skills: Beta: Beta helper."), true);
assert.equal(result.context.includes("/missing-skill"), true);
assert.match(result.context, /explicitly selected/i);
assert.match(result.context, /unavailable/i);
},
);
});
test("initializing an empty skills directory creates only an instructions file", async () => {
await withUserSkills([], async (electronApp) => {
const status = await scanUserSkills(electronApp);
const entries = await fs.readdir(status.directoryPath);
assert.deepEqual(status.skills, []);
assert.equal(status.readyCount, 0);
assert.equal(status.warningCount, 0);
assert.deepEqual(entries.sort(), ["README.txt"]);
});
});
test("unreadable SKILL.md becomes a warning instead of aborting the entire scan", {
skip: typeof process.getuid === "function" && process.getuid() === 0
? "chmod-based unreadable file checks are not enforceable when running as root"
: false,
}, async () => {
await withUserSkills(
[
{
directoryName: "Working Skill",
name: "Working Skill",
description: "A valid skill.",
body: "Working body",
},
{
directoryName: "Broken Skill",
name: "Broken Skill",
description: "This file will be unreadable.",
body: "Broken body",
},
],
async (electronApp) => {
const unreadablePath = path.join(
electronApp.getPath("userData"),
"Skills",
"Broken Skill",
"SKILL.md",
);
await fs.chmod(unreadablePath, 0o000);
try {
const status = await scanUserSkills(electronApp);
const workingSkill = status.skills.find((skill) => skill.name === "Working Skill");
const brokenSkill = status.skills.find((skill) => skill.directoryName === "Broken Skill");
assert.equal(status.readyCount, 1);
assert.equal(status.warningCount, 1);
assert.equal(workingSkill?.status, "ready");
assert.equal(brokenSkill?.status, "warning");
assert.match(brokenSkill?.warnings?.[0] || "", /Failed to read SKILL\.md/i);
} finally {
await fs.chmod(unreadablePath, 0o644);
}
},
);
});
test("symlinked SKILL.md is downgraded to a warning and never injected", async () => {
await withUserSkills(
[
{
directoryName: "Working Skill",
name: "Working Skill",
description: "A valid skill.",
body: "Working body",
},
],
async (electronApp) => {
const skillsDir = path.join(electronApp.getPath("userData"), "Skills");
const linkedDir = path.join(skillsDir, "Linked Skill");
const externalTarget = path.join(skillsDir, "..", "outside-secret.md");
await fs.mkdir(linkedDir, { recursive: true });
await fs.writeFile(
externalTarget,
[
"---",
"name: Linked Skill",
"description: Linked helper.",
"---",
"",
"TOPSECRET",
"",
].join("\n"),
"utf8",
);
await fs.symlink(externalTarget, path.join(linkedDir, "SKILL.md"));
const status = await scanUserSkills(electronApp);
const result = await buildUserSkillsContext(electronApp, "plain prompt", ["linked-skill"]);
const linkedSkill = status.skills.find((skill) => skill.directoryName === "Linked Skill");
assert.equal(status.readyCount, 1);
assert.equal(status.warningCount, 1);
assert.equal(linkedSkill?.status, "warning");
assert.match(linkedSkill?.warnings?.[0] || "", /symbolic link/i);
assert.equal(result.context.includes("TOPSECRET"), false);
assert.match(result.context, /linked-skill/i);
assert.match(result.context, /unavailable/i);
},
);
});
test("duplicate normalized slugs are downgraded to warnings and not injected explicitly", async () => {
await withUserSkills(
[
{
directoryName: "Foo Bar",
name: "Foo Bar",
description: "First skill.",
body: "Body for Foo Bar",
},
{
directoryName: "foo-bar",
name: "foo-bar",
description: "Second skill.",
body: "Body for foo-bar",
},
],
async (electronApp) => {
const status = await scanUserSkills(electronApp);
const result = await buildUserSkillsContext(electronApp, "plain prompt", ["foo-bar"]);
assert.equal(status.readyCount, 0);
assert.equal(status.warningCount, 2);
assert.equal(status.skills.every((skill) => skill.status === "warning"), true);
assert.equal(
status.skills.every((skill) =>
skill.warnings.some((warning) => warning.includes('Duplicate skill slug "foo-bar"')),
),
true,
);
assert.equal(result.context.includes("Body for Foo Bar"), false);
assert.equal(result.context.includes("Body for foo-bar"), false);
},
);
});
test("skills without a usable ASCII slug are downgraded to warnings", async () => {
await withUserSkills(
[
{
directoryName: "部署助手",
name: "部署助手",
description: "Deployment helper.",
body: "Body for 部署助手",
},
],
async (electronApp) => {
const status = await scanUserSkills(electronApp);
assert.equal(status.readyCount, 0);
assert.equal(status.warningCount, 1);
assert.equal(status.skills[0]?.status, "warning");
assert.equal(status.skills[0]?.slug, "");
assert.match(
status.skills[0]?.warnings?.[0] || "",
/usable slug/i,
);
},
);
});
test("explicit selections are capped to stay within the prompt budget", async () => {
await withUserSkills(
[
{
directoryName: "Skill One",
name: "Skill One",
description: "Helper one.",
body: "BODY_ONE_" + "a".repeat(3500),
},
{
directoryName: "Skill Two",
name: "Skill Two",
description: "Helper two.",
body: "BODY_TWO_" + "b".repeat(3500),
},
{
directoryName: "Skill Three",
name: "Skill Three",
description: "Helper three.",
body: "BODY_THREE_" + "c".repeat(3500),
},
{
directoryName: "Skill Four",
name: "Skill Four",
description: "Helper four.",
body: "BODY_FOUR_" + "d".repeat(3500),
},
{
directoryName: "Skill Five",
name: "Skill Five",
description: "Helper five.",
body: "BODY_FIVE_" + "e".repeat(3500),
},
],
async (electronApp) => {
const result = await buildUserSkillsContext(
electronApp,
"plain prompt",
["skill-one", "skill-two", "skill-three", "skill-four", "skill-five"],
);
assert.equal(result.context.includes("BODY_ONE_"), true);
assert.equal(result.context.includes("BODY_TWO_"), true);
assert.equal(result.context.includes("BODY_THREE_"), true);
assert.equal(result.context.includes("BODY_FOUR_"), false);
assert.equal(result.context.includes("BODY_FIVE_"), false);
assert.match(result.context, /prompt budget|additional selected/i);
},
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const electronPath = require.resolve("electron");
const previousElectron = require.cache[electronPath];
require.cache[electronPath] = {
id: electronPath,
filename: electronPath,
loaded: true,
exports: { dialog: {}, shell: {} },
};
const { buildExternalAgentSystemContext } = require("./aiBridge.cjs");
if (previousElectron) {
require.cache[electronPath] = previousElectron;
} else {
delete require.cache[electronPath];
}
test("buildExternalAgentSystemContext (MCP mode) includes vault host vs notes guidance", () => {
const context = buildExternalAgentSystemContext({
mode: "mcp",
chatSessionId: "chat-1",
});
assert.match(context, /vault_hosts_create/i);
assert.match(context, /NOT vault_notes_create/i);
assert.match(context, /do not silently create a Vault note/i);
});
test("buildExternalAgentSystemContext (skills mode) routes attachments through Netcatty CLI", () => {
const context = buildExternalAgentSystemContext({
mode: "skills",
chatSessionId: "chat-1",
});
assert.match(context, /attachment list --json --chat-session chat-1/i);
assert.match(context, /attachment read --filename <filename> --json --chat-session chat-1/i);
assert.match(context, /Use the local shell only to invoke Netcatty CLI commands/i);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,295 @@
"use strict";
/**
* Layer-3 (authentication) CLI probes for the managed backends.
* Each probe is dependency-injected (runners / fileExists) for unit testing;
* the discovery handler wires the real implementations.
*
* Returns: { authenticated: boolean, authSource: string|null }
*/
const { existsSync, readFileSync } = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { resolveCursorCliSpawnSpec } = require("./cursorCliSpawn.cjs");
function defaultFileExists(p) {
try { return existsSync(p); } catch { return false; }
}
function defaultReadFile(p) {
try { return readFileSync(p, "utf-8"); } catch { return null; }
}
// ── Claude ──
function defaultRunSecurity() {
// macOS keychain lookup for the Claude Code OAuth credentials entry.
try {
const stdout = execFileSync(
"security",
["find-generic-password", "-s", "Claude Code-credentials", "-w"],
{ encoding: "utf8", timeout: 4000, stdio: ["pipe", "pipe", "pipe"] },
);
return { exitCode: 0, stdout };
} catch (err) {
return { exitCode: err?.status ?? 1, stdout: "" };
}
}
function probeClaudeAuth({ env, platform, runSecurity, fileExists, homeDir } = {}) {
const e = env || process.env;
const plat = platform || process.platform;
const fx = fileExists || defaultFileExists;
const home = homeDir || os.homedir();
const apiKey = typeof e.ANTHROPIC_API_KEY === "string" ? e.ANTHROPIC_API_KEY.trim() : "";
const oauthToken = typeof e.CLAUDE_CODE_OAUTH_TOKEN === "string" ? e.CLAUDE_CODE_OAUTH_TOKEN.trim() : "";
const authToken = typeof e.ANTHROPIC_AUTH_TOKEN === "string" ? e.ANTHROPIC_AUTH_TOKEN.trim() : "";
if (apiKey || oauthToken || authToken) return { authenticated: true, authSource: "env" };
if (plat === "darwin") {
const sec = (runSecurity || defaultRunSecurity)();
if (sec && sec.exitCode === 0 && String(sec.stdout || "").trim()) {
return { authenticated: true, authSource: "keychain" };
}
}
const configDir = typeof e.CLAUDE_CONFIG_DIR === "string" && e.CLAUDE_CONFIG_DIR.trim()
? e.CLAUDE_CONFIG_DIR.trim()
: path.join(home, ".claude");
if (fx(path.join(configDir, ".credentials.json"))) {
return { authenticated: true, authSource: "credentials-file" };
}
return { authenticated: false, authSource: null };
}
// ── Copilot ──
function defaultRunGhAuthStatus() {
try {
const out = execFileSync("gh", ["auth", "status"], {
encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
});
return { exitCode: 0, stdout: out, stderr: "" };
} catch (err) {
return { exitCode: err?.status ?? 1, stdout: "", stderr: String(err?.stderr || err?.message || "") };
}
}
function probeCopilotAuth({ runGhAuthStatus } = {}) {
const res = (runGhAuthStatus || defaultRunGhAuthStatus)();
if (res && res.exitCode === 0) return { authenticated: true, authSource: "gh" };
return { authenticated: false, authSource: null };
}
// ── Codex ──
function probeCodexAuth({ runLoginStatus, fileExists, homeDir } = {}) {
const fx = fileExists || defaultFileExists;
const home = homeDir || os.homedir();
const res = runLoginStatus ? runLoginStatus() : { exitCode: 1, stdout: "" };
const out = String((res && (res.stdout || res.stderr)) || "").toLowerCase();
if (out.includes("logged in using chatgpt")) return { authenticated: true, authSource: "chatgpt" };
if (out.includes("logged in using an api key") || out.includes("logged in using api key")) {
return { authenticated: true, authSource: "api-key" };
}
if (fx(path.join(home, ".codex", "auth.json"))) {
return { authenticated: true, authSource: "auth-file" };
}
return { authenticated: false, authSource: null };
}
// ── CodeBuddy ──
// SDK supports CODEBUDDY_API_KEY, CODEBUDDY_AUTH_TOKEN (OAuth), and CLI login
// state (~/.codebuddy/settings.json with authToken/apiKeyHelper).
function probeCodebuddyAuth({ env, fileExists, readFile, homeDir } = {}) {
const e = env || process.env;
const fx = fileExists || defaultFileExists;
const rf = readFile || defaultReadFile;
const home = homeDir || os.homedir();
const apiKey = typeof e.CODEBUDDY_API_KEY === "string" ? e.CODEBUDDY_API_KEY.trim() : "";
const authToken = typeof e.CODEBUDDY_AUTH_TOKEN === "string" ? e.CODEBUDDY_AUTH_TOKEN.trim() : "";
if (apiKey) return { authenticated: true, authSource: "api-key" };
if (authToken) return { authenticated: true, authSource: "auth-token" };
// Check CLI login state in settings.json (authToken / apiKeyHelper fields).
const settingsPath = path.join(home, ".codebuddy", "settings.json");
const content = rf(settingsPath);
if (content !== null) {
try {
const parsed = JSON.parse(content);
if (parsed && typeof parsed === "object") {
if (typeof parsed.authToken === "string" && parsed.authToken.trim()) {
return { authenticated: true, authSource: "settings-file" };
}
if (typeof parsed.apiKeyHelper === "string" && parsed.apiKeyHelper.trim()) {
return { authenticated: true, authSource: "settings-file" };
}
}
} catch { /* Malformed JSON — treat as no auth */ }
}
return { authenticated: false, authSource: null };
}
// ── Cursor CLI login (cursor-agent only) ──
// Do not probe bare `agent` — it collides with other CLIs on PATH (e.g. Grok).
const CURSOR_CLI_BINARY_CANDIDATES = ["cursor-agent"];
function stripCursorApiKeyFromProbeEnv(env) {
const out = { ...(env || {}) };
delete out.CURSOR_API_KEY;
return out;
}
function defaultResolveCursorCliBinary(name, env) {
try {
const whichCmd = process.platform === "win32" ? "where" : "which";
const out = execFileSync(whichCmd, [name], {
encoding: "utf8",
timeout: 4000,
env: env || process.env,
stdio: ["pipe", "pipe", "pipe"],
});
const first = String(out || "").split(/\r?\n/).map((l) => l.trim()).find(Boolean);
return first || null;
} catch {
return null;
}
}
function defaultRunCursorStatus(binPath, env) {
const spec = resolveCursorCliSpawnSpec(binPath, ["status", "--format", "json"]);
try {
const stdout = execFileSync(spec.command, spec.args, {
encoding: "utf8",
timeout: 8000,
env: env || process.env,
stdio: ["pipe", "pipe", "pipe"],
shell: spec.shell,
windowsHide: true,
});
return { exitCode: 0, stdout: String(stdout || ""), stderr: "" };
} catch (err) {
return {
exitCode: err?.status ?? 1,
stdout: String(err?.stdout || ""),
stderr: String(err?.stderr || err?.message || ""),
};
}
}
function extractFirstJsonObject(text) {
const raw = String(text || "").replace(/^\uFEFF/, "").trim();
if (!raw) return null;
const candidates = [raw];
const start = raw.indexOf("{");
const end = raw.lastIndexOf("}");
if (start >= 0 && end > start) {
const sliced = raw.slice(start, end + 1);
if (sliced !== raw) candidates.push(sliced);
}
for (const candidate of candidates) {
try {
const parsed = JSON.parse(candidate);
if (parsed && typeof parsed === "object") return parsed;
} catch { /* try next */ }
}
return null;
}
function parseCursorStatusJson(stdout) {
const parsed = extractFirstJsonObject(stdout);
if (!parsed) return null;
// Real Cursor status always exposes isAuthenticated and/or status.
// Reject unrelated CLIs that accept unknown flags or emit other JSON.
if (typeof parsed.isAuthenticated !== "boolean" && typeof parsed.status !== "string") {
return null;
}
return parsed;
}
/**
* Probe local Cursor Agent CLI login (subscription session).
* Resolves only `cursor-agent` (not bare `agent`) to avoid PATH collisions.
* Strips CURSOR_API_KEY so "cli-login" is not proven by a metered API key alone.
*
* @returns {{ authenticated: boolean, authSource: string|null, email: string|null, binPath: string|null }}
*/
function probeCursorCliAuth({ env, resolveBinary, runStatus } = {}) {
const e = stripCursorApiKeyFromProbeEnv(env || process.env);
const resolve = resolveBinary || ((name) => defaultResolveCursorCliBinary(name, e));
const run = runStatus || ((bin) => defaultRunCursorStatus(bin, e));
// A resolved cursor-agent path is a user install even when status JSON is
// missing, unrecognized, or the status command throws.
let resolvedBinPath = null;
for (const name of CURSOR_CLI_BINARY_CANDIDATES) {
let binPath = null;
try {
binPath = resolve(name);
} catch {
continue;
}
if (!binPath) continue;
if (!resolvedBinPath) resolvedBinPath = binPath;
let res = null;
try {
res = run(binPath);
} catch {
continue;
}
if (!res) continue;
// Accept JSON even on non-zero exit if present (some CLIs exit 1 when logged out).
const parsed = parseCursorStatusJson(res.stdout);
if (!parsed) continue;
const authenticated = Boolean(
parsed.isAuthenticated === true || parsed.status === "authenticated",
);
if (!authenticated) continue;
const email = typeof parsed?.userInfo?.email === "string" ? parsed.userInfo.email : null;
return { authenticated: true, authSource: "cli-login", email, binPath };
}
return {
authenticated: false,
authSource: null,
email: null,
binPath: resolvedBinPath,
};
}
// ── Grok Build ──
function probeGrokAuth({ env, fileExists, homeDir } = {}) {
const e = env || process.env;
const fx = fileExists || defaultFileExists;
const home = homeDir || os.homedir();
const apiKey = typeof e.XAI_API_KEY === "string" ? e.XAI_API_KEY.trim() : "";
if (apiKey) return { authenticated: true, authSource: "env" };
// OAuth / account login persists under ~/.grok/auth.json (or GROK_CONFIG_DIR).
const configDir = typeof e.GROK_CONFIG_DIR === "string" && e.GROK_CONFIG_DIR.trim()
? e.GROK_CONFIG_DIR.trim()
: path.join(home, ".grok");
if (fx(path.join(configDir, "auth.json"))) {
return { authenticated: true, authSource: "auth-file" };
}
return { authenticated: false, authSource: null };
}
module.exports = {
probeClaudeAuth,
probeCopilotAuth,
probeCodexAuth,
probeCodebuddyAuth,
probeCursorCliAuth,
probeGrokAuth,
CURSOR_CLI_BINARY_CANDIDATES,
defaultRunSecurity,
defaultRunGhAuthStatus,
parseCursorStatusJson,
resolveCursorCliSpawnSpec,
};

View File

@@ -0,0 +1,334 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
probeClaudeAuth, probeCopilotAuth, probeCodexAuth, probeCodebuddyAuth, probeCursorCliAuth, probeGrokAuth,
parseCursorStatusJson, resolveCursorCliSpawnSpec,
} = require("./agentAuthProbes.cjs");
const { prepareCommandForSpawn } = require("../ai/shellUtils.cjs");
const { resolveCursorCliSpawnSpec: resolveSharedCursorCliSpawnSpec } = require("./cursorCliSpawn.cjs");
test("probeClaudeAuth: env ANTHROPIC_API_KEY -> authenticated env", () => {
const r = probeClaudeAuth({
env: { ANTHROPIC_API_KEY: "sk-x" },
platform: "darwin",
runSecurity: () => { throw new Error("should not be called"); },
fileExists: () => false,
});
assert.equal(r.authenticated, true);
assert.equal(r.authSource, "env");
});
test("probeClaudeAuth: macOS keychain hit -> authenticated keychain", () => {
const r = probeClaudeAuth({
env: {},
platform: "darwin",
runSecurity: () => ({ exitCode: 0, stdout: '{"claudeAiOauth":{}}' }),
fileExists: () => false,
});
assert.equal(r.authenticated, true);
assert.equal(r.authSource, "keychain");
});
test("probeClaudeAuth: linux credentials file -> authenticated credentials-file", () => {
const r = probeClaudeAuth({
env: {},
platform: "linux",
runSecurity: () => { throw new Error("no keychain on linux"); },
fileExists: (p) => p.endsWith(".credentials.json"),
});
assert.equal(r.authenticated, true);
assert.equal(r.authSource, "credentials-file");
});
test("probeClaudeAuth: nothing -> not authenticated", () => {
const r = probeClaudeAuth({
env: {}, platform: "darwin",
runSecurity: () => ({ exitCode: 44, stdout: "" }),
fileExists: () => false,
});
assert.equal(r.authenticated, false);
assert.equal(r.authSource, null);
});
test("probeCopilotAuth: gh auth status exit 0 -> authenticated gh", () => {
const r = probeCopilotAuth({ runGhAuthStatus: () => ({ exitCode: 0, stderr: "Logged in to github.com" }) });
assert.equal(r.authenticated, true);
assert.equal(r.authSource, "gh");
});
test("probeCopilotAuth: gh auth status non-zero -> not authenticated", () => {
const r = probeCopilotAuth({ runGhAuthStatus: () => ({ exitCode: 1, stderr: "not logged in" }) });
assert.equal(r.authenticated, false);
});
test("probeCodexAuth: 'Logged in using ChatGPT' -> authenticated chatgpt", () => {
const r = probeCodexAuth({
runLoginStatus: () => ({ exitCode: 0, stdout: "Logged in using ChatGPT" }),
fileExists: () => false,
});
assert.equal(r.authenticated, true);
assert.equal(r.authSource, "chatgpt");
});
test("probeCodexAuth: auth.json fallback -> authenticated auth-file", () => {
const r = probeCodexAuth({
runLoginStatus: () => ({ exitCode: 1, stdout: "not logged in" }),
fileExists: (p) => p.endsWith("auth.json"),
});
assert.equal(r.authenticated, true);
assert.equal(r.authSource, "auth-file");
});
// ── CodeBuddy ──
test("probeCodebuddyAuth: CODEBUDDY_API_KEY env -> authenticated api-key", () => {
const r = probeCodebuddyAuth({
env: { CODEBUDDY_API_KEY: "cb-key-123" },
readFile: () => null,
});
assert.equal(r.authenticated, true);
assert.equal(r.authSource, "api-key");
});
test("probeCodebuddyAuth: CODEBUDDY_AUTH_TOKEN env -> authenticated auth-token", () => {
const r = probeCodebuddyAuth({
env: { CODEBUDDY_AUTH_TOKEN: "oauth-token-xyz" },
readFile: () => null,
});
assert.equal(r.authenticated, true);
assert.equal(r.authSource, "auth-token");
});
test("probeCodebuddyAuth: settings.json with authToken -> authenticated settings-file", () => {
const r = probeCodebuddyAuth({
env: {},
readFile: () => '{"authToken":"real-token"}',
});
assert.equal(r.authenticated, true);
assert.equal(r.authSource, "settings-file");
});
test("probeCodebuddyAuth: settings.json with apiKeyHelper -> authenticated settings-file", () => {
const r = probeCodebuddyAuth({
env: {},
readFile: () => '{"apiKeyHelper":"/usr/local/bin/helper"}',
});
assert.equal(r.authenticated, true);
assert.equal(r.authSource, "settings-file");
});
test("probeCodebuddyAuth: empty settings.json -> not authenticated", () => {
const r = probeCodebuddyAuth({
env: {},
readFile: () => "",
});
assert.equal(r.authenticated, false);
assert.equal(r.authSource, null);
});
test("probeCodebuddyAuth: malformed JSON in settings.json -> not authenticated", () => {
const r = probeCodebuddyAuth({
env: {},
readFile: () => "{not valid json",
});
assert.equal(r.authenticated, false);
assert.equal(r.authSource, null);
});
test("probeCodebuddyAuth: settings.json without auth fields -> not authenticated", () => {
const r = probeCodebuddyAuth({
env: {},
readFile: () => '{"theme":"dark","language":"en"}',
});
assert.equal(r.authenticated, false);
assert.equal(r.authSource, null);
});
test("probeCodebuddyAuth: no env, no settings file -> not authenticated", () => {
const r = probeCodebuddyAuth({
env: {},
readFile: () => null,
});
assert.equal(r.authenticated, false);
assert.equal(r.authSource, null);
});
test("probeCodebuddyAuth: CODEBUDDY_API_KEY takes precedence over settings.json", () => {
const r = probeCodebuddyAuth({
env: { CODEBUDDY_API_KEY: "cb-key" },
readFile: () => '{"authToken":"token"}',
});
assert.equal(r.authenticated, true);
assert.equal(r.authSource, "api-key");
});
// ── Cursor CLI login ──
test("probeCursorCliAuth: prefers cursor-agent and parses authenticated JSON", () => {
const calls = [];
const r = probeCursorCliAuth({
env: { CURSOR_API_KEY: "should-be-stripped" },
resolveBinary: (name) => {
calls.push(name);
return name === "cursor-agent" ? "/bin/cursor-agent" : null;
},
runStatus: (bin) => {
assert.equal(bin, "/bin/cursor-agent");
return {
exitCode: 0,
stdout: JSON.stringify({
status: "authenticated",
isAuthenticated: true,
userInfo: { email: "user@example.com" },
}),
};
},
});
assert.deepEqual(calls, ["cursor-agent"]);
assert.equal(r.authenticated, true);
assert.equal(r.authSource, "cli-login");
assert.equal(r.email, "user@example.com");
assert.equal(r.binPath, "/bin/cursor-agent");
});
test("probeCursorCliAuth: does not fall back to bare agent binary", () => {
const statusCalls = [];
const resolveCalls = [];
const r = probeCursorCliAuth({
resolveBinary: (name) => {
resolveCalls.push(name);
return name === "agent" ? "/bin/agent" : null;
},
runStatus: (bin) => {
statusCalls.push(bin);
return {
exitCode: 0,
stdout: JSON.stringify({ isAuthenticated: true, userInfo: { email: "a@b.c" } }),
};
},
});
assert.deepEqual(resolveCalls, ["cursor-agent"]);
assert.deepEqual(statusCalls, []);
assert.equal(r.authenticated, false);
assert.equal(r.binPath, null);
});
// ── Grok Build ──
test("probeGrokAuth: env XAI_API_KEY -> authenticated env", () => {
const r = probeGrokAuth({
env: { XAI_API_KEY: "xai-test" },
fileExists: () => false,
homeDir: "/home/user",
});
assert.equal(r.authenticated, true);
assert.equal(r.authSource, "env");
});
test("probeGrokAuth: auth.json fallback -> authenticated auth-file", () => {
const path = require("node:path");
const homeDir = path.join("home", "user");
const authPath = path.join(homeDir, ".grok", "auth.json");
const r = probeGrokAuth({
env: {},
homeDir,
fileExists: (p) => p === authPath,
});
assert.equal(r.authenticated, true);
assert.equal(r.authSource, "auth-file");
});
test("probeGrokAuth: nothing -> not authenticated", () => {
const r = probeGrokAuth({
env: {},
homeDir: "/home/user",
fileExists: () => false,
});
assert.equal(r.authenticated, false);
assert.equal(r.authSource, null);
});
test("probeCursorCliAuth: unauthenticated JSON -> not authenticated but keeps binPath", () => {
const r = probeCursorCliAuth({
resolveBinary: (name) => (name === "cursor-agent" ? "/bin/cursor-agent" : null),
runStatus: () => ({
exitCode: 0,
stdout: JSON.stringify({ isAuthenticated: false }),
}),
});
assert.equal(r.authenticated, false);
assert.equal(r.authSource, null);
assert.equal(r.binPath, "/bin/cursor-agent");
});
test("probeCursorCliAuth: missing binary -> not authenticated", () => {
const r = probeCursorCliAuth({
resolveBinary: () => null,
runStatus: () => { throw new Error("should not run"); },
});
assert.equal(r.authenticated, false);
assert.equal(r.binPath, null);
});
test("probeCursorCliAuth: status command failure -> not authenticated", () => {
const r = probeCursorCliAuth({
resolveBinary: () => "/bin/cursor-agent",
runStatus: () => ({ exitCode: 1, stdout: "", stderr: "boom" }),
});
assert.equal(r.authenticated, false);
assert.equal(r.binPath, "/bin/cursor-agent");
});
test("probeCursorCliAuth: unrecognized status stdout keeps resolved binPath", () => {
const r = probeCursorCliAuth({
resolveBinary: () => "/bin/cursor-agent",
runStatus: () => ({ exitCode: 0, stdout: "cursor-agent 1.2.3\nnot json" }),
});
assert.equal(r.authenticated, false);
assert.equal(r.authSource, null);
assert.equal(r.binPath, "/bin/cursor-agent");
});
test("probeCursorCliAuth: thrown status probe keeps resolved binPath", () => {
const r = probeCursorCliAuth({
resolveBinary: () => "/bin/cursor-agent",
runStatus: () => { throw new Error("timeout"); },
});
assert.equal(r.authenticated, false);
assert.equal(r.authSource, null);
assert.equal(r.binPath, "/bin/cursor-agent");
});
test("probeCursorCliAuth: extracts authenticated JSON wrapped in cmd noise", () => {
const r = probeCursorCliAuth({
resolveBinary: () => "C:\\Users\\me\\AppData\\Local\\cursor-agent\\cursor-agent.cmd",
runStatus: () => ({
exitCode: 0,
stdout: "Starting...\r\n{\"status\":\"authenticated\",\"isAuthenticated\":true,\"userInfo\":{\"email\":\"user@example.com\"}}\r\n",
}),
});
assert.equal(r.authenticated, true);
assert.equal(r.authSource, "cli-login");
assert.equal(r.email, "user@example.com");
});
test("parseCursorStatusJson accepts BOM and surrounding text", () => {
const parsed = parseCursorStatusJson(
"\uFEFFnoise\n{\"isAuthenticated\":true,\"status\":\"authenticated\"}\n",
);
assert.equal(parsed.isAuthenticated, true);
assert.equal(parsed.status, "authenticated");
});
test("resolveCursorCliSpawnSpec re-exports the shared native launch helper", () => {
const shim = "C:\\Users\\me\\AppData\\Local\\cursor-agent\\cursor-agent.cmd";
const args = ["status", "--format", "json"];
assert.deepEqual(
resolveCursorCliSpawnSpec(shim, args),
resolveSharedCursorCliSpawnSpec(shim, args),
);
assert.deepEqual(
resolveSharedCursorCliSpawnSpec(shim, args, {
exists: () => false,
readFile: () => { throw new Error("missing"); },
}),
prepareCommandForSpawn(shim, args, { unwrapNativeExe: false }),
);
});

View File

@@ -0,0 +1,416 @@
/* eslint-disable no-undef */
const { StringDecoder } = require("node:string_decoder");
const DEFAULT_CODEX_CLI_TIMEOUT_MS = 10_000;
const CODEX_AUTH_VALIDATION_TIMEOUT_MS = 10_000;
const MAX_AGENT_CLI_BUFFER_CHARS = 10 * 1024 * 1024;
function createAgentCliHelpers(ctx) {
with (ctx) {
const codexAuthValidationInFlight = new Map();
async function runCommand(command, args, options) {
return await new Promise((resolve, reject) => {
let settled = false;
let closed = false;
let timeoutId = null;
let killId = null;
function clearTimers() {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (killId) {
clearTimeout(killId);
killId = null;
}
}
const spawnSpec = prepareCommandForSpawn(command, args || []);
const child = spawn(spawnSpec.command, spawnSpec.args, {
stdio: ["ignore", "pipe", "pipe"],
cwd: options?.cwd || undefined,
env: options?.env || process.env,
shell: spawnSpec.shell,
windowsHide: true,
});
let stdout = "";
let stderr = "";
let stdoutBytes = 0;
let stderrBytes = 0;
let stdoutTruncated = false;
let stderrTruncated = false;
const stdoutDecoder = new StringDecoder("utf8");
const stderrDecoder = new StringDecoder("utf8");
const timeoutMs = Number.isFinite(options?.timeoutMs) ? Number(options.timeoutMs) : 0;
child.stdout.on("data", (chunk) => {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
const remaining = Math.max(0, MAX_AGENT_CLI_BUFFER_CHARS - stdoutBytes);
const accepted = buffer.length <= remaining ? buffer : buffer.subarray(0, remaining);
if (accepted.length > 0) stdout += stdoutDecoder.write(accepted);
stdoutBytes += accepted.length;
if (accepted.length < buffer.length) stdoutTruncated = true;
});
child.stderr.on("data", (chunk) => {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
const remaining = Math.max(0, MAX_AGENT_CLI_BUFFER_CHARS - stderrBytes);
const accepted = buffer.length <= remaining ? buffer : buffer.subarray(0, remaining);
if (accepted.length > 0) stderr += stderrDecoder.write(accepted);
stderrBytes += accepted.length;
if (accepted.length < buffer.length) stderrTruncated = true;
});
child.once("error", (error) => {
closed = true;
if (settled) return;
settled = true;
clearTimers();
reject(error);
});
child.once("close", (exitCode) => {
closed = true;
clearTimers();
if (settled) return;
settled = true;
if (!stdoutTruncated || stdoutDecoder.lastNeed === 0) stdout += stdoutDecoder.end();
if (!stderrTruncated || stderrDecoder.lastNeed === 0) stderr += stderrDecoder.end();
resolve({
stdout: stripAnsi(stdout),
stderr: stripAnsi(stderr),
exitCode,
});
});
if (timeoutMs > 0) {
timeoutId = setTimeout(() => {
if (settled) return;
settled = true;
const error = new Error(`Command timed out after ${timeoutMs}ms`);
error.code = "ETIMEDOUT";
try {
if (!closed) child.kill("SIGTERM");
} catch {}
killId = setTimeout(() => {
try {
if (!closed) child.kill("SIGKILL");
} catch {}
}, 750);
if (typeof killId.unref === "function") killId.unref();
reject(error);
}, timeoutMs);
if (typeof timeoutId.unref === "function") timeoutId.unref();
}
});
}
function getCommandOutput(result) {
return [result?.stdout, result?.stderr]
.filter((chunk) => typeof chunk === "string" && chunk.length > 0)
.join("\n")
.trim();
}
function getFirstCommandOutputLine(result) {
return getCommandOutput(result).split(/\r?\n/)[0] || "";
}
async function probeCliVersion(probeCmd, probeArgs, env) {
try {
const result = await runCommand(probeCmd, probeArgs, { env, timeoutMs: 5000 });
return {
launched: true,
exitCode: result.exitCode,
output: getCommandOutput(result),
version: getFirstCommandOutputLine(result),
};
} catch {
return {
launched: false,
exitCode: null,
output: "",
version: "",
};
}
}
async function runCodexCli(args, options) {
const shellEnv = await getShellEnv();
const requestedPath = String(options?.codexPath || "").trim();
const configuredPath = requestedPath ? normalizeCliPathForPlatform?.(requestedPath) : null;
if (requestedPath && !configuredPath) {
throw new Error(`Codex CLI path not found: ${requestedPath}`);
}
const codexCliPath = configuredPath || await resolveCliFromPathAsync("codex", shellEnv) || "codex";
return await runCommand(codexCliPath, args, {
cwd: options?.cwd?.trim() || undefined,
env: shellEnv,
timeoutMs: Number.isFinite(options?.timeoutMs)
? Number(options.timeoutMs)
: DEFAULT_CODEX_CLI_TIMEOUT_MS,
});
}
async function runCodexCliChecked(args, options) {
const result = await runCodexCli(args, options);
if (result.exitCode === 0) {
return result;
}
const errorText =
result.stderr.trim() ||
result.stdout.trim() ||
`Codex command failed with exit code ${result.exitCode ?? "unknown"}`;
throw new Error(errorText);
}
async function validateCodexChatGptAuth(options) {
const maxAgeMs = options?.maxAgeMs ?? 30000;
const now = Date.now();
const rawRequestedCodexPath = String(options?.codexPath || "").trim();
const requestedCodexPath = rawRequestedCodexPath ? normalizeCliPathForPlatform?.(rawRequestedCodexPath) : null;
if (rawRequestedCodexPath && !requestedCodexPath) {
const result = {
ok: false,
checkedAt: now,
codexPath: null,
error: `Codex CLI path not found: ${rawRequestedCodexPath}`,
code: "ENOENT",
};
setCodexValidationCache(result);
return result;
}
const cached = getCodexValidationCache();
if (cached && now - cached.checkedAt < maxAgeMs && (cached.codexPath || null) === requestedCodexPath) return cached;
const inFlightKey = requestedCodexPath || "__auto__";
const existingValidation = codexAuthValidationInFlight.get(inFlightKey);
if (existingValidation) return existingValidation;
const validationPromise = (async () => {
const shellEnv = await getShellEnv();
const rawCodexPath = requestedCodexPath || await resolveSdkBinPathAsync("codex", shellEnv);
const codexPath = rawCodexPath && typeof resolveCodexExecutableForSdk === "function"
? resolveCodexExecutableForSdk(rawCodexPath) || null
: rawCodexPath;
if (!codexPath) {
const result = { ok: false, checkedAt: now, codexPath: requestedCodexPath, error: "codex binary not found", code: "ENOENT" };
setCodexValidationCache(result);
return result;
}
const abortController = new AbortController();
let timeoutId = null;
let iterator = null;
try {
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
const error = new Error(
`Codex ChatGPT auth validation timed out after ${CODEX_AUTH_VALIDATION_TIMEOUT_MS}ms`,
);
error.code = "ETIMEDOUT";
try { abortController.abort(error); } catch {}
reject(error);
}, CODEX_AUTH_VALIDATION_TIMEOUT_MS);
if (typeof timeoutId?.unref === "function") timeoutId.unref();
});
const probePromise = (async () => {
// Minimal read-only probe turn through the SDK to confirm auth works.
const { Codex } = await (typeof loadCodexSdk === "function"
? loadCodexSdk()
: import("@openai/codex-sdk"));
const codexOptions = { env: addCodexExecutableEnvForSdk(shellEnv, codexPath) };
if (codexPath) codexOptions.codexPathOverride = codexPath;
const codex = new Codex(codexOptions);
const thread = codex.startThread({ skipGitRepoCheck: true });
const { events } = await thread.runStreamed("ping", {
sandbox: "read-only",
signal: abortController.signal,
});
iterator = events?.[Symbol.asyncIterator]?.();
if (!iterator) throw new Error("Codex auth validation returned no event stream");
let failed = null;
while (true) {
const next = await iterator.next();
if (next.done) break;
const event = next.value;
if (event?.type === "turn.failed") { failed = event.error; break; }
if (event?.type === "turn.completed") break;
if (event?.type === "item.completed") break;
}
if (failed) throw failed;
})();
await Promise.race([probePromise, timeoutPromise]);
const result = { ok: true, checkedAt: now, codexPath, error: null };
setCodexValidationCache(result);
return result;
} catch (error) {
const normalized = extractCodexError(error);
const result = { ok: false, checkedAt: now, codexPath, error: normalized.message, code: normalized.code };
setCodexValidationCache(result);
return result;
} finally {
if (timeoutId) clearTimeout(timeoutId);
try { abortController.abort(); } catch {}
try { void Promise.resolve(iterator?.return?.()).catch(() => {}); } catch {}
}
})();
codexAuthValidationInFlight.set(inFlightKey, validationPromise);
try {
return await validationPromise;
} finally {
if (codexAuthValidationInFlight.get(inFlightKey) === validationPromise) {
codexAuthValidationInFlight.delete(inFlightKey);
}
}
}
function objectToPairs(value) {
if (!value || typeof value !== "object") return [];
return Object.entries(value)
.filter(([name, val]) => typeof name === "string" && typeof val === "string")
.map(([name, val]) => ({ name, value: val }));
}
function resolveCodexStdioEnv(transport, shellEnv) {
const merged = {};
if (transport?.env && typeof transport.env === "object") {
for (const [name, value] of Object.entries(transport.env)) {
if (typeof name === "string" && typeof value === "string") {
merged[name] = value;
}
}
}
if (Array.isArray(transport?.env_vars)) {
for (const envName of transport.env_vars) {
const value = shellEnv[envName] || process.env[envName];
if (typeof value === "string" && value.length > 0 && !merged[envName]) {
merged[envName] = value;
}
}
}
return merged;
}
function resolveCodexHttpHeaders(transport, shellEnv) {
const merged = {};
if (transport?.http_headers && typeof transport.http_headers === "object") {
for (const [name, value] of Object.entries(transport.http_headers)) {
if (typeof name === "string" && typeof value === "string") {
merged[name] = value;
}
}
}
if (transport?.env_http_headers && typeof transport.env_http_headers === "object") {
for (const [headerName, envName] of Object.entries(transport.env_http_headers)) {
if (typeof headerName !== "string" || typeof envName !== "string") continue;
const value = shellEnv[envName] || process.env[envName];
if (typeof value === "string" && value.length > 0) {
merged[headerName] = value;
}
}
}
const bearerEnvVar = typeof transport?.bearer_token_env_var === "string"
? transport.bearer_token_env_var.trim()
: "";
if (bearerEnvVar && !merged.Authorization) {
const token = shellEnv[bearerEnvVar] || process.env[bearerEnvVar];
if (typeof token === "string" && token.trim()) {
merged.Authorization = `Bearer ${token.trim()}`;
}
}
return merged;
}
async function resolveCodexMcpSnapshot(cwd) {
const empty = { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) };
try {
const result = await runCodexCliChecked(["mcp", "list", "--json"], {
cwd: cwd || undefined,
});
const parsed = JSON.parse(result.stdout);
if (!Array.isArray(parsed)) {
return empty;
}
const shellEnv = await getShellEnv();
const mcpServers = [];
for (const entry of parsed) {
if (!entry?.enabled || !entry?.transport || typeof entry?.name !== "string") {
continue;
}
const transportType = String(entry.transport.type || "").trim().toLowerCase();
if (transportType === "stdio") {
const command = String(entry.transport.command || "").trim();
if (!command) continue;
mcpServers.push({
name: entry.name,
type: "stdio",
command,
args: Array.isArray(entry.transport.args)
? entry.transport.args.filter((arg) => typeof arg === "string")
: [],
env: objectToPairs(resolveCodexStdioEnv(entry.transport, shellEnv)),
});
continue;
}
if (transportType === "streamable_http" || transportType === "http" || transportType === "sse") {
const url = String(entry.transport.url || "").trim();
if (!url) continue;
mcpServers.push({
name: entry.name,
type: "http",
url,
headers: objectToPairs(resolveCodexHttpHeaders(entry.transport, shellEnv)),
});
}
}
return {
mcpServers,
fingerprint: getCodexMcpFingerprint(mcpServers),
};
} catch (err) {
console.error("[Codex] Failed to resolve MCP servers:", err?.message || err);
return empty;
}
}
return {
runCommand,
getCommandOutput,
getFirstCommandOutputLine,
probeCliVersion,
runCodexCli,
runCodexCliChecked,
validateCodexChatGptAuth,
objectToPairs,
resolveCodexStdioEnv,
resolveCodexHttpHeaders,
resolveCodexMcpSnapshot,
};
}
}
module.exports = {
createAgentCliHelpers,
CODEX_AUTH_VALIDATION_TIMEOUT_MS,
DEFAULT_CODEX_CLI_TIMEOUT_MS,
MAX_AGENT_CLI_BUFFER_CHARS,
};

View File

@@ -0,0 +1,237 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { PassThrough } = require("node:stream");
const {
createAgentCliHelpers,
CODEX_AUTH_VALIDATION_TIMEOUT_MS,
DEFAULT_CODEX_CLI_TIMEOUT_MS,
MAX_AGENT_CLI_BUFFER_CHARS,
} = require("./agentCliHelpers.cjs");
function createHungChild() {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kills = [];
child.kill = (signal) => {
child.kills.push(signal);
return true;
};
return child;
}
test("runCodexCli applies a default timeout to short status commands", async () => {
const child = createHungChild();
const scheduled = [];
const helpers = createAgentCliHelpers({
prepareCommandForSpawn: (command, args) => ({ command, args, shell: false }),
spawn: () => child,
stripAnsi: (value) => value,
getShellEnv: async () => ({}),
normalizeCliPathForPlatform: (value) => value,
resolveCliFromPathAsync: async () => "/fake/codex",
setTimeout: (callback, delay) => {
scheduled.push(delay);
queueMicrotask(callback);
return { unref() {} };
},
clearTimeout() {},
});
const safetyTimer = globalThis.setTimeout(() => child.emit("close", 0), 25);
try {
await assert.rejects(
helpers.runCodexCli(["login", "status"], {}),
(error) => error?.code === "ETIMEDOUT",
);
} finally {
globalThis.clearTimeout(safetyTimer);
}
assert.equal(scheduled[0], DEFAULT_CODEX_CLI_TIMEOUT_MS);
assert.deepEqual(child.kills, ["SIGTERM", "SIGKILL"]);
});
test("runCodexCli slices a single oversized output chunk to the hard limit", async () => {
const child = createHungChild();
const helpers = createAgentCliHelpers({
prepareCommandForSpawn: (command, args) => ({ command, args, shell: false }),
spawn: () => {
queueMicrotask(() => {
child.stdout.emit("data", Buffer.from("x".repeat(MAX_AGENT_CLI_BUFFER_CHARS + 257)));
child.emit("close", 0);
});
return child;
},
stripAnsi: (value) => value,
getShellEnv: async () => ({}),
normalizeCliPathForPlatform: (value) => value,
resolveCliFromPathAsync: async () => "/fake/codex",
});
const result = await helpers.runCodexCli(["--version"], {});
assert.equal(result.stdout.length, MAX_AGENT_CLI_BUFFER_CHARS);
});
test("runCodexCli preserves split UTF-8 independently on stdout and stderr", async () => {
const child = createHungChild();
const helpers = createAgentCliHelpers({
prepareCommandForSpawn: (command, args) => ({ command, args, shell: false }),
spawn: () => {
queueMicrotask(() => {
const stdout = Buffer.from("中文", "utf8");
const stderr = Buffer.from("错误", "utf8");
child.stdout.emit("data", stdout.subarray(0, 2));
child.stderr.emit("data", stderr.subarray(0, 1));
child.stdout.emit("data", stdout.subarray(2));
child.stderr.emit("data", stderr.subarray(1));
child.emit("close", 0);
});
return child;
},
stripAnsi: (value) => value,
getShellEnv: async () => ({}),
normalizeCliPathForPlatform: (value) => value,
resolveCliFromPathAsync: async () => "/fake/codex",
});
const result = await helpers.runCodexCli(["--version"], {});
assert.deepEqual(result, { stdout: "中文", stderr: "错误", exitCode: 0 });
});
test("runCodexCli omits an incomplete UTF-8 suffix at its byte limit", async () => {
const child = createHungChild();
const helpers = createAgentCliHelpers({
prepareCommandForSpawn: (command, args) => ({ command, args, shell: false }),
spawn: () => {
queueMicrotask(() => {
child.stdout.emit("data", Buffer.from("x".repeat(MAX_AGENT_CLI_BUFFER_CHARS - 1)));
child.stdout.emit("data", Buffer.from("中", "utf8"));
child.emit("close", 0);
});
return child;
},
stripAnsi: (value) => value,
getShellEnv: async () => ({}),
normalizeCliPathForPlatform: (value) => value,
resolveCliFromPathAsync: async () => "/fake/codex",
});
const result = await helpers.runCodexCli(["--version"], {});
assert.equal(result.stdout.length, MAX_AGENT_CLI_BUFFER_CHARS - 1);
assert.doesNotMatch(result.stdout, /<2F>/u);
});
function createValidationHelpers({ loadCodexSdk, setTimeout, clearTimeout }) {
return createAgentCliHelpers({
getCodexValidationCache: () => null,
setCodexValidationCache() {},
normalizeCliPathForPlatform: (value) => value,
getShellEnv: async () => ({}),
resolveSdkBinPathAsync: async () => "/fake/codex",
resolveCodexExecutableForSdk: (value) => value,
addCodexExecutableEnvForSdk: (env) => env,
extractCodexError: (error) => ({ message: error?.message || String(error) }),
loadCodexSdk,
...(setTimeout ? { setTimeout } : {}),
...(clearTimeout ? { clearTimeout } : {}),
});
}
test("ChatGPT auth validation coalesces concurrent probes and cleans the stream", async () => {
let runCount = 0;
let returnCount = 0;
let release;
const gate = new Promise((resolve) => { release = resolve; });
const helpers = createValidationHelpers({
loadCodexSdk: async () => ({
Codex: class {
startThread() {
return {
async runStreamed(_prompt, options) {
runCount += 1;
assert.equal(options.signal.aborted, false);
const iterator = {
async next() {
await gate;
return { done: false, value: { type: "item.completed" } };
},
async return() {
returnCount += 1;
return { done: true };
},
};
return { events: { [Symbol.asyncIterator]: () => iterator } };
},
};
}
},
}),
});
const first = helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex" });
const second = helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex" });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(runCount, 1);
release();
assert.deepEqual(await Promise.all([first, second]), [
{ ok: true, checkedAt: (await first).checkedAt, codexPath: "/fake/codex", error: null },
{ ok: true, checkedAt: (await second).checkedAt, codexPath: "/fake/codex", error: null },
]);
assert.equal(returnCount, 1);
});
test("ChatGPT auth validation aborts and settles when the SDK stream hangs", async () => {
let observedSignal;
let returnCount = 0;
const scheduled = [];
let releaseSafety;
const safetyGate = new Promise((resolve) => { releaseSafety = resolve; });
const helpers = createValidationHelpers({
loadCodexSdk: async () => ({
Codex: class {
startThread() {
return {
async runStreamed(_prompt, options) {
observedSignal = options.signal;
const iterator = {
async next() {
await safetyGate;
return { done: false, value: { type: "item.completed" } };
},
async return() {
returnCount += 1;
return { done: true };
},
};
return { events: { [Symbol.asyncIterator]: () => iterator } };
},
};
}
},
}),
setTimeout: (callback, delay) => {
scheduled.push(delay);
queueMicrotask(callback);
return { unref() {} };
},
clearTimeout() {},
});
const safetyTimer = globalThis.setTimeout(releaseSafety, 25);
try {
const result = await helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex" });
assert.equal(result.ok, false);
assert.match(result.error, /timed out/i);
} finally {
globalThis.clearTimeout(safetyTimer);
releaseSafety();
}
assert.equal(scheduled[0], CODEX_AUTH_VALIDATION_TIMEOUT_MS);
assert.equal(observedSignal.aborted, true);
assert.equal(returnCount, 1);
});

View File

@@ -0,0 +1,487 @@
/* eslint-disable no-undef */
function getCursorPlatformPackageName(platform = process.platform, arch = process.arch) {
if (platform === "darwin" && (arch === "arm64" || arch === "x64")) return `@cursor/sdk-darwin-${arch}`;
if (platform === "linux" && (arch === "arm64" || arch === "x64")) return `@cursor/sdk-linux-${arch}`;
if (platform === "win32" && arch === "x64") return "@cursor/sdk-win32-x64";
return null;
}
// Bundled @cursor/sdk is importable in every Netcatty build. "installed" is the
// user's Cursor Agent CLI, not that bundled package.
function computeCursorInstallState({ sdkInstalled, cliBinPath, cliLoginOk } = {}) {
return {
sdkInstalled: Boolean(sdkInstalled),
installed: Boolean(cliBinPath) || Boolean(cliLoginOk),
};
}
async function probeCursorSdkAvailability(shellEnv, options = {}) {
const platformPackageName = getCursorPlatformPackageName();
let sdkInstalled = false;
if (platformPackageName) {
try {
await import("@cursor/sdk");
require.resolve(`${platformPackageName}/package.json`);
sdkInstalled = true;
} catch {
sdkInstalled = false;
}
}
const hasEnvApiKey = Boolean(shellEnv?.CURSOR_API_KEY);
const hasSettingsApiKey = Boolean(options?.apiKeyPresent);
const apiKeyOk = hasSettingsApiKey || hasEnvApiKey;
const probeCli = typeof options?.probeCursorCliAuth === "function"
? options.probeCursorCliAuth
: null;
let cliAuth = { authenticated: false, authSource: null, email: null, binPath: null };
try {
if (probeCli) {
cliAuth = probeCli({ env: shellEnv }) || cliAuth;
}
} catch {
cliAuth = { authenticated: false, authSource: null, email: null, binPath: null };
}
const cliLoginOk = Boolean(cliAuth.authenticated);
const authenticated = apiKeyOk || cliLoginOk;
// authSource describes the primary credential for display priority; CLI UI
// must use cliLoginOk, not this field alone.
let authSource = null;
if (hasSettingsApiKey) authSource = "settings";
else if (hasEnvApiKey) authSource = "CURSOR_API_KEY";
else if (cliLoginOk) authSource = "cli-login";
const installState = computeCursorInstallState({
sdkInstalled,
cliBinPath: cliAuth.binPath,
cliLoginOk,
});
sdkInstalled = installState.sdkInstalled;
// Available if either mode can run a turn (API key + SDK, or CLI login).
const available = (apiKeyOk && sdkInstalled) || cliLoginOk;
const installed = installState.installed;
return {
installed,
sdkInstalled,
available,
authenticated,
authSource,
apiKeyOk,
cliLoginOk,
version: sdkInstalled ? "Cursor SDK" : (cliLoginOk || cliAuth.binPath ? "Cursor Agent CLI" : null),
cliBinPath: cliAuth.binPath || null,
cliEmail: cliAuth.email || null,
};
}
function registerAgentDiscoveryHandlers(ctx) {
with (ctx) {
ipcMain.handle("netcatty:ai:agents:discover", async (event, options = {}) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
if (options?.refreshShellEnv) {
invalidateShellEnvCache();
}
const agents = [];
const knownAgents = [
{ command: "claude", name: "Claude Code", icon: "claude",
description: "Anthropic's agentic coding assistant", sdkBackend: "claude", args: [] },
{ command: "codex", name: "Codex CLI", icon: "openai",
description: "OpenAI's coding agent", sdkBackend: "codex", args: [] },
{ command: "copilot", name: "GitHub Copilot CLI", icon: "copilot",
description: "GitHub's coding agent CLI", sdkBackend: "copilot", args: [] },
{ command: "cursor", name: "Cursor", icon: "cursor",
description: "Cursor's coding agent via Cursor SDK", sdkBackend: "cursor", args: [] },
{ command: "codebuddy", name: "CodeBuddy Code", icon: "codebuddy",
description: "Tencent's coding agent CLI (Agent SDK)", sdkBackend: "codebuddy", args: [] },
{ command: "opencode", name: "OpenCode", icon: "opencode",
description: "Open source coding agent via the official OpenCode SDK", sdkBackend: "opencode", args: [] },
{ command: "grok", name: "Grok Build", icon: "grok",
description: "xAI's Grok Build coding agent CLI", sdkBackend: "grok", args: [] },
];
const shellEnv = await getShellEnv();
const seenPaths = new Set();
for (const agent of knownAgents) {
let cursorSdkStatus = null;
if (agent.command === "cursor") {
cursorSdkStatus = await probeCursorSdkAvailability(shellEnv, {
apiKeyPresent: Boolean(options?.apiKeyPresent),
probeCursorCliAuth,
});
if (!cursorSdkStatus.available) continue;
}
const resolvedPath = agent.command === "cursor"
? (cursorSdkStatus.cliLoginOk
? (cursorSdkStatus.cliBinPath || "cursor")
: (cursorSdkStatus.sdkInstalled ? "cursor" : (cursorSdkStatus.cliBinPath || "cursor")))
: await resolveCliFromPathAsync(agent.command, shellEnv); // Layer-1: locate
if (!resolvedPath || seenPaths.has(resolvedPath)) continue;
const probe = agent.command === "cursor"
? { exitCode: 0, version: cursorSdkStatus.version }
: await probeCliVersion(resolvedPath, ["--version"], shellEnv); // Layer-2: version
const hasPlausibleVersion = agent.command === "cursor"
? probe.exitCode === 0
: probe.exitCode === 0 && isPlausibleCliVersionOutput(probe.version);
if (!hasPlausibleVersion) continue;
// Layer-3: authentication (best-effort; never blocks discovery).
let auth = { authenticated: false, authSource: null };
try {
if (agent.command === "claude") {
auth = probeClaudeAuth({ env: shellEnv });
} else if (agent.command === "copilot") {
auth = probeCopilotAuth({});
} else if (agent.command === "codex") {
auth = { authenticated: false, authSource: null };
} else if (agent.command === "cursor") {
auth = {
authenticated: cursorSdkStatus.authenticated,
authSource: cursorSdkStatus.authSource,
};
} else if (agent.command === "codebuddy") {
auth = probeCodebuddyAuth({ env: shellEnv });
} else if (agent.command === "opencode") {
auth = { authenticated: true, authSource: "opencode-config" };
} else if (agent.command === "grok") {
auth = probeGrokAuth({ env: shellEnv });
}
} catch { /* auth probe is best-effort */ }
agents.push({
command: agent.command,
name: agent.name,
icon: agent.icon,
description: agent.description,
sdkBackend: agent.sdkBackend,
args: agent.args,
path: resolvedPath,
binPath: resolvedPath,
version: probe.version,
installed: agent.command === "cursor" ? Boolean(cursorSdkStatus.installed) : true,
available: true,
authenticated: auth.authenticated,
authSource: auth.authSource,
...(agent.command === "cursor" ? {
cliEmail: cursorSdkStatus.cliEmail || null,
cliBinPath: cursorSdkStatus.cliBinPath || null,
cliLoginOk: Boolean(cursorSdkStatus.cliLoginOk),
apiKeyOk: Boolean(cursorSdkStatus.apiKeyOk),
sdkInstalled: Boolean(cursorSdkStatus.sdkInstalled),
} : {}),
});
seenPaths.add(resolvedPath);
}
return agents;
});
ipcMain.handle("netcatty:ai:shell-env:prewarm", async (event) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
await getShellEnv();
return { ok: true };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
// Resolve a CLI binary path (auto-detect or validate custom path)
ipcMain.handle("netcatty:ai:resolve-cli", async (event, { command, customPath, refreshShellEnv, apiKeyPresent }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
if (refreshShellEnv) {
invalidateShellEnvCache();
}
const shellEnv = await getShellEnv();
const hasCustomPath = command !== "cursor" && Boolean(String(customPath || "").trim());
let resolvedPath;
if (hasCustomPath) {
// Normalize Windows shim paths like `codex` -> `codex.cmd` when present.
// A user-supplied path must be validated as-is; falling back to PATH would
// make Settings appear to accept one binary while actually using another.
resolvedPath = normalizeCliPathForPlatform(customPath);
} else {
resolvedPath = await resolveCliFromPathAsync(command, shellEnv);
}
if (command === "cursor") {
const cursorSdkStatus = await probeCursorSdkAvailability(shellEnv, {
apiKeyPresent: Boolean(apiKeyPresent),
probeCursorCliAuth,
});
// Prefer CLI bin only when CLI login is proven. Otherwise do not use a
// PATH `agent` binary (generic name) for API-key/SDK path identity.
const resolvedSdkPath = await resolveCliFromPathAsync(command, shellEnv);
const cursorPath = cursorSdkStatus.cliLoginOk
? (cursorSdkStatus.cliBinPath || resolvedSdkPath || "cursor")
: (resolvedSdkPath || "cursor");
// Keep the SDK sentinel path when the bundled SDK is importable so
// API-key mode still has an identity without Cursor.app / Agent CLI.
const hasCursorPath = cursorSdkStatus.sdkInstalled
|| cursorSdkStatus.installed
|| cursorSdkStatus.available;
return {
path: hasCursorPath ? cursorPath : null,
binPath: hasCursorPath ? cursorPath : null,
version: cursorSdkStatus.version,
available: cursorSdkStatus.available,
installed: cursorSdkStatus.installed,
authenticated: cursorSdkStatus.authenticated,
authSource: cursorSdkStatus.authSource,
cliEmail: cursorSdkStatus.cliEmail || null,
cliBinPath: cursorSdkStatus.cliBinPath || null,
cliLoginOk: Boolean(cursorSdkStatus.cliLoginOk),
apiKeyOk: Boolean(cursorSdkStatus.apiKeyOk),
sdkInstalled: Boolean(cursorSdkStatus.sdkInstalled),
};
}
if (!resolvedPath) {
return { path: null, binPath: null, version: null, available: false, installed: false };
}
const probe = await probeCliVersion(resolvedPath, ["--version"], shellEnv);
const hasPlausibleVersion = command === "cursor"
? probe.exitCode === 0
: probe.exitCode === 0 && isPlausibleCliVersionOutput(probe.version);
if (!hasPlausibleVersion) {
return { path: resolvedPath, binPath: resolvedPath, version: null, available: false, installed: true };
}
return { path: resolvedPath, binPath: resolvedPath, version: probe.version, available: true, installed: true };
});
ipcMain.handle("netcatty:ai:codex:get-integration", async (event, options) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
// When the user clicks "Refresh Status" in Settings we also want to
// rescan the shell env — otherwise a newly-exported variable in
// .zshrc stays invisible until they restart netcatty entirely.
if (options && options.refreshShellEnv) {
invalidateShellEnvCache();
}
try {
const codexCliOptions = { codexPath: options?.codexPath };
const result = await runCodexCli(["login", "status"], codexCliOptions);
const rawOutput = [result.stdout, result.stderr]
.filter((chunk) => chunk.trim().length > 0)
.join("\n")
.trim();
let state = normalizeCodexIntegrationState(rawOutput);
let effectiveRawOutput = rawOutput;
if (state === "connected_chatgpt" && options?.validateChatGptAuth === true) {
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000, codexPath: options?.codexPath });
if (!validation.ok) {
if (isCodexAuthError(validation)) {
try {
await runCodexCli(["logout"], codexCliOptions);
} catch {
// Ignore logout failures; we still want to surface the invalid state.
}
invalidateCodexValidationCache();
state = "not_logged_in";
}
effectiveRawOutput = appendCodexChatGptValidationFailure(
rawOutput,
validation.error || "Unknown validation error",
);
}
}
// `codex login status` only reflects ~/.codex/auth.json. A user who
// configured a custom provider directly in ~/.codex/config.toml is
// functional from the CLI but would look "not_logged_in" here. Probe
// config.toml so we can surface that as a valid ready state instead of
// pushing the user into the ChatGPT login flow.
let customConfig = null;
if (state !== "connected_chatgpt" && state !== "connected_api_key") {
try {
const shellEnv = await getShellEnv();
customConfig = readCodexCustomProviderConfig(shellEnv);
if (customConfig) {
state = "connected_custom_config";
}
} catch {
customConfig = null;
}
}
return {
state,
isConnected:
state === "connected_chatgpt" ||
state === "connected_api_key" ||
state === "connected_custom_config",
rawOutput: effectiveRawOutput,
exitCode: result.exitCode,
customConfig,
};
} catch (err) {
return {
state: "unknown",
isConnected: false,
rawOutput: err?.message || String(err),
exitCode: null,
customConfig: null,
};
}
});
ipcMain.handle("netcatty:ai:codex:start-login", async (event, options = {}) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const requestedPath = String(options?.codexPath || "").trim();
const requestedCodexPath = requestedPath ? normalizeCliPathForPlatform?.(requestedPath) : null;
if (requestedPath && !requestedCodexPath) {
return { ok: false, error: `Codex CLI path not found: ${requestedPath}` };
}
try {
const shellEnv = await getShellEnv();
const codexCliPath = requestedCodexPath
|| await resolveCliFromPathAsync("codex", shellEnv)
|| "codex";
const existingSession = getActiveCodexLoginSession();
if (existingSession) {
const existingPath = existingSession.codexPath || null;
if (existingPath && codexCliPath !== existingPath) {
return { ok: false, error: "A Codex login is already running for a different CLI path." };
}
return { ok: true, session: toCodexLoginSessionResponse(existingSession) };
}
const sessionId = `codex_login_${randomUUID()}`;
const spawnSpec = prepareCommandForSpawn(codexCliPath, ["login"]);
const child = spawn(spawnSpec.command, spawnSpec.args, {
stdio: ["ignore", "pipe", "pipe"],
env: shellEnv,
shell: spawnSpec.shell,
windowsHide: true,
});
const session = {
id: sessionId,
process: child,
state: "running",
output: "",
url: null,
error: null,
exitCode: null,
codexPath: codexCliPath,
};
const stdoutDecoder = createCodexLoginOutputDecoder(session);
const stderrDecoder = createCodexLoginOutputDecoder(session);
let outputEnded = false;
const endOutput = () => {
if (outputEnded) return;
outputEnded = true;
stdoutDecoder.end();
stderrDecoder.end();
};
child.stdout.on("data", (chunk) => stdoutDecoder.write(chunk));
child.stderr.on("data", (chunk) => stderrDecoder.write(chunk));
child.once("error", (error) => {
endOutput();
clearCodexLoginKillTimer(session);
session.state = "error";
session.error = `[codex] Failed to start login flow: ${error.message}`;
session.process = null;
recordCodexLoginSession(session);
});
child.once("close", (exitCode) => {
endOutput();
clearCodexLoginKillTimer(session);
session.exitCode = exitCode;
session.process = null;
if (session.state === "cancelled") {
recordCodexLoginSession(session);
return;
}
if (exitCode === 0) {
session.state = "success";
session.error = null;
} else {
session.state = "error";
session.error = session.error || `Codex login exited with code ${exitCode ?? "unknown"}`;
}
recordCodexLoginSession(session);
});
recordCodexLoginSession(session);
invalidateCodexValidationCache();
return { ok: true, session: toCodexLoginSessionResponse(session) };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
ipcMain.handle("netcatty:ai:codex:get-login-session", async (event, { sessionId }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const session = codexLoginSessions.get(sessionId);
if (!session) {
return { ok: false, error: "Codex login session not found" };
}
return { ok: true, session: toCodexLoginSessionResponse(session) };
});
ipcMain.handle("netcatty:ai:codex:cancel-login", async (event, { sessionId }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const session = codexLoginSessions.get(sessionId);
if (!session) {
return { ok: true, found: false };
}
session.state = "cancelled";
session.error = null;
stopCodexLoginProcess(session);
recordCodexLoginSession(session);
invalidateCodexValidationCache();
return { ok: true, found: true, session: toCodexLoginSessionResponse(session) };
});
ipcMain.handle("netcatty:ai:codex:logout", async (event, options = {}) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const codexCliOptions = { codexPath: options?.codexPath };
const logoutResult = await runCodexCli(["logout"], codexCliOptions);
invalidateCodexValidationCache();
const statusResult = await runCodexCli(["login", "status"], codexCliOptions);
const rawOutput = [statusResult.stdout, statusResult.stderr]
.filter((chunk) => chunk.trim().length > 0)
.join("\n")
.trim();
const state = normalizeCodexIntegrationState(rawOutput);
return {
ok: true,
state,
isConnected:
state === "connected_chatgpt" ||
state === "connected_api_key" ||
state === "connected_custom_config",
rawOutput,
logoutOutput: [logoutResult.stdout, logoutResult.stderr]
.filter((chunk) => chunk.trim().length > 0)
.join("\n")
.trim(),
};
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
}
}
module.exports = { registerAgentDiscoveryHandlers, computeCursorInstallState };

View File

@@ -0,0 +1,45 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { computeCursorInstallState } = require("./agentDiscoveryHandlers.cjs");
test("computeCursorInstallState: bundled SDK is not a user Cursor install", () => {
const state = computeCursorInstallState({
sdkInstalled: true,
cliBinPath: null,
cliLoginOk: false,
});
assert.equal(state.sdkInstalled, true);
assert.equal(state.installed, false);
});
test("computeCursorInstallState: Agent CLI on PATH is a user Cursor install", () => {
const state = computeCursorInstallState({
sdkInstalled: true,
cliBinPath: "/usr/local/bin/cursor-agent",
cliLoginOk: false,
});
assert.equal(state.sdkInstalled, true);
assert.equal(state.installed, true);
});
test("computeCursorInstallState: logged-out CLI path is installed without cliLoginOk", () => {
const state = computeCursorInstallState({
sdkInstalled: true,
cliBinPath: "/bin/cursor-agent",
cliLoginOk: false,
});
assert.equal(state.installed, true);
assert.equal(state.sdkInstalled, true);
});
test("computeCursorInstallState: proven CLI login is a user Cursor install", () => {
const state = computeCursorInstallState({
sdkInstalled: false,
cliBinPath: null,
cliLoginOk: true,
});
assert.equal(state.sdkInstalled, false);
assert.equal(state.installed, true);
});

View File

@@ -0,0 +1,154 @@
/* eslint-disable no-undef */
function registerAgentProcessHandlers(ctx) {
with (ctx) {
const maxCommandTimeoutSeconds = 24 * 60 * 60;
// ── MCP Server session metadata ──
ipcMain.handle("netcatty:ai:mcp:update-sessions", async (event, { sessions: sessionList, chatSessionId }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const list = Array.isArray(sessionList) ? sessionList : [];
const externalId = mcpServerBridge.EXTERNAL_MCP_CHAT_SESSION_ID;
if (chatSessionId === externalId) {
// App-wide External MCP scope is owned by the main-window full-session sync.
// Reject writes while disabled so in-flight renderer pushes cannot resurrect
// metadata after stopActiveRuntime cleared the scope.
try {
const external = typeof getExternalMcpController === "function"
? getExternalMcpController()
: null;
if (!external?.isEnabled?.()) {
return { ok: false, error: "External MCP is disabled" };
}
} catch {
return { ok: false, error: "External MCP is unavailable" };
}
}
mcpServerBridge.updateSessionMetadata(list, chatSessionId);
return { ok: true, count: list.length };
});
// App-owned live session state is independent of the optional External MCP
// surface. It lets host_open-owned chat scopes observe connection changes
// even when the opened terminal never mounts its own AI side panel.
ipcMain.handle("netcatty:ai:mcp:update-live-sessions", async (event, { sessions: sessionList }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
return mcpServerBridge.updateLiveSessionMetadata(
Array.isArray(sessionList) ? sessionList : [],
);
});
// Merge (do not replace) session metadata into a chat scope. Used when agents
// open a host mid-turn so terminal tools can target the new sessionId
// without waiting for the next full scope push.
ipcMain.handle("netcatty:ai:mcp:merge-sessions", async (event, { sessions: sessionList, chatSessionId }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
if (!chatSessionId || typeof chatSessionId !== "string") {
return { ok: false, error: "chatSessionId is required" };
}
const list = Array.isArray(sessionList) ? sessionList : [];
const externalId = mcpServerBridge.EXTERNAL_MCP_CHAT_SESSION_ID;
if (chatSessionId === externalId) {
try {
const external = typeof getExternalMcpController === "function"
? getExternalMcpController()
: null;
if (!external?.isEnabled?.()) {
return { ok: false, error: "External MCP is disabled" };
}
} catch {
return { ok: false, error: "External MCP is unavailable" };
}
}
return mcpServerBridge.mergeSessionMetadata(list, chatSessionId);
});
ipcMain.handle("netcatty:ai:mcp:update-attachments", async (event, { attachments, chatSessionId }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
mcpServerBridge.updateAttachmentMetadata(attachments || [], chatSessionId);
return { ok: true };
});
ipcMain.handle("netcatty:ai:mcp:set-command-blocklist", async (event, { blocklist }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
// Validate: must be an array of strings, each a valid regex pattern
if (!Array.isArray(blocklist)) {
return { ok: false, error: "blocklist must be an array" };
}
const validPatterns = [];
for (const pattern of blocklist) {
if (typeof pattern !== "string") continue;
try {
new RegExp(pattern, "i"); // Validate regex
validPatterns.push(pattern);
} catch {
// Skip invalid regex patterns silently
}
}
mcpServerBridge.setCommandBlocklist(validPatterns);
return { ok: true };
});
ipcMain.handle("netcatty:ai:mcp:set-command-timeout", async (event, { timeout }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const value = Number(timeout);
if (!Number.isFinite(value) || value < 1 || value > maxCommandTimeoutSeconds) {
return { ok: false, error: `timeout must be a number between 1 and ${maxCommandTimeoutSeconds}` };
}
mcpServerBridge.setCommandTimeout(value);
return { ok: true };
});
ipcMain.handle("netcatty:ai:mcp:set-max-iterations", async (event, { maxIterations }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const value = Number(maxIterations);
if (!Number.isFinite(value) || value < 1 || value > 100) {
return { ok: false, error: "maxIterations must be a number between 1 and 100" };
}
mcpServerBridge.setMaxIterations(value);
return { ok: true };
});
ipcMain.handle("netcatty:ai:mcp:set-permission-mode", async (event, { mode }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const validModes = ["observer", "confirm", "auto"];
if (!validModes.includes(mode)) {
return { ok: false, error: `mode must be one of: ${validModes.join(", ")}` };
}
mcpServerBridge.setPermissionMode(mode);
return { ok: true };
});
ipcMain.handle("netcatty:ai:mcp:set-tool-integration-mode", async (event, { mode }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const validModes = ["mcp", "skills"];
if (!validModes.includes(mode)) {
return { ok: false, error: `mode must be one of: ${validModes.join(", ")}` };
}
setToolIntegrationMode(mode);
return { ok: true };
});
ipcMain.handle("netcatty:ai:mcp:sync-permission-grants", async (event, { grants }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
mcpServerBridge.setPermissionGrants(grants);
return { ok: true, count: mcpServerBridge.getPermissionGrants().length };
});
// ── MCP Approval response (renderer → main) ──
ipcMain.handle("netcatty:ai:mcp:approval-response", async (event, { approvalId, approved }) => {
// Settings window also hosts External MCP approval cards.
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
mcpServerBridge.resolveApprovalFromRenderer(approvalId, approved);
return { ok: true };
});
// Cancel MCP approval auto-deny after the user starts reviewing the card.
ipcMain.handle("netcatty:ai:mcp:approval-cancel-timeout", async (event, { approvalId }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const cancelled = mcpServerBridge.cancelApprovalTimeoutFromRenderer?.(approvalId) === true;
return { ok: true, cancelled };
});
}
}
module.exports = { registerAgentProcessHandlers };

View File

@@ -0,0 +1,309 @@
/* eslint-disable no-undef */
// Module-level require on purpose: code inside registerCattyExecHandlers
// runs under `with (ctx)` where bare `require` resolves to ctx.require
// (based in electron/bridges/). Requiring here keeps the path unambiguous.
const { formatSyntheticEcho } = require("../ai/shellUtils.cjs");
const { ensureSessionShellKindForExec } = require("../ai/sessionShellKind.cjs");
function getWorkerExecutionMeta(mcpServerBridge, sessionId, chatSessionId) {
return mcpServerBridge.getSessionMeta?.(sessionId, chatSessionId) || {};
}
function isNetworkDeviceLike(meta) {
const protocol = meta?.protocol || "";
const isSshOrSerial = protocol === "ssh" || protocol === "serial";
return (meta?.deviceType === "network" && isSshOrSerial) || protocol === "serial";
}
async function proxyCattyExecToWorker({
event,
terminalWorkerManager,
mcpServerBridge,
sessionId,
command,
chatSessionId,
}) {
if (!terminalWorkerManager?.request) {
return { ok: false, error: "Session not found" };
}
const busyErr = mcpServerBridge.getSessionBusyError?.(sessionId);
if (busyErr) return busyErr;
const meta = getWorkerExecutionMeta(mcpServerBridge, sessionId, chatSessionId);
if (!isNetworkDeviceLike(meta)) {
// No live session here: settings additions plus common defaults only; the
// terminal worker re-runs the shell-selected defaults on the live session.
const safety = meta.shellType
? mcpServerBridge.checkCommandSafetyForShell(command, meta.shellType)
: mcpServerBridge.checkCommandSafetyCommonOnly(command);
if (safety.blocked) {
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
}
const reservation = mcpServerBridge.reserveSessionExecution?.(sessionId, "exec");
if (reservation && !reservation.ok) return reservation;
const sessionToken = reservation?.token;
const releaseLock = () => {
if (sessionToken) {
try { mcpServerBridge.releaseSessionExecution?.(sessionId, sessionToken); } catch {}
}
};
try {
return await terminalWorkerManager.request("netcatty:ai:exec", {
sessionId,
command,
chatSessionId,
commandTimeoutMs: mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000,
sessionMeta: meta,
commandBlocklist: mcpServerBridge.getCommandBlocklist?.(),
}, {
webContentsId: event?.sender?.id,
});
} catch (err) {
return { ok: false, error: err?.message || String(err) };
} finally {
releaseLock();
}
}
function registerCattyExecHandlers(ctx) {
with (ctx) {
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command, chatSessionId }) => {
// Validate IPC sender (Issue #17)
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
// Block execution in observer mode (Issue #11)
if (mcpServerBridge.getPermissionMode() === "observer") {
return { ok: false, error: "Execution blocked: permission mode is 'observer'" };
}
const session = sessions?.get(sessionId);
if (!session) {
return proxyCattyExecToWorker({
event,
terminalWorkerManager,
mcpServerBridge,
sessionId,
command,
chatSessionId,
});
}
// Honor the per-session execution lock so this IPC path does not race with
// long-running background jobs started via terminal_start.
const busyErr = mcpServerBridge.getSessionBusyError?.(sessionId);
if (busyErr) return busyErr;
const reservation = mcpServerBridge.reserveSessionExecution?.(sessionId, "exec");
if (reservation && !reservation.ok) return reservation;
const sessionToken = reservation?.token;
const releaseLock = () => {
if (sessionToken) {
try { mcpServerBridge.releaseSessionExecution?.(sessionId, sessionToken); } catch {}
}
};
// Look up device type from metadata (set by renderer from Host.deviceType).
// Mosh sessions use a shell-backed PTY, so network device mode only applies to SSH/serial.
// Prefer session.protocol (runtime truth) over meta.protocol (renderer hint)
// because Mosh tabs report as protocol:"ssh" in metadata but "mosh" in session.
const meta = mcpServerBridge.getSessionMeta(sessionId, chatSessionId) || {};
const sessionProtocol = session.protocol || session.type || meta.protocol || "";
const isSshOrSerial = sessionProtocol === "ssh" || sessionProtocol === "serial";
const isNetworkDevice = (meta.deviceType === "network" && isSshOrSerial) || sessionProtocol === "serial";
// Helper: ensure the session lock is released once the promise settles
// (or immediately on a synchronous error/early return).
const withLockRelease = (factory) => {
try {
const result = factory();
return Promise.resolve(result).finally(releaseLock);
} catch (err) {
releaseLock();
return { ok: false, error: err?.message || String(err) };
}
};
try {
if ((session.protocol === "local" || session.type === "local") && session.shellKind === "unknown") {
releaseLock();
return {
ok: false,
error: "AI execution is not supported for this local shell executable. Configure the local terminal to use bash/zsh/sh, fish, PowerShell/pwsh, or cmd.exe.",
};
}
const ptyStream = session.stream || session.pty || session.proc;
// Network devices (switches/routers) connected via SSH: use raw execution.
// Their vendor CLIs don't run a POSIX shell, so shell-wrapped commands fail.
if (isNetworkDevice && ptyStream && typeof ptyStream.write === "function") {
const { execViaRawPty } = require("./ai/ptyExec.cjs");
const timeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
return withLockRelease(() => execViaRawPty(ptyStream, command, {
timeoutMs,
trackForCancellation: mcpServerBridge.activePtyExecs,
chatSessionId,
encoding: "utf8", // SSH PTY streams use UTF-8, not latin1
}));
}
// Prefer PTY stream (visible in terminal)
if (ptyStream && typeof ptyStream.write === "function") {
const timeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
// Remote sessions historically left shellKind unset → posix wrapper
// was typed into fish login shells (issue #1854). Probe once first,
// cancellably so Stop during the probe window does not still type
// the command after the probe resolves (Codex P2 on #2061).
return withLockRelease(async () => {
const probed = await ensureSessionShellKindForExec(session, {
trackForCancellation: mcpServerBridge.activePtyExecs,
chatSessionId,
});
if (!probed.ok) return probed;
const safety = mcpServerBridge.checkCommandSafetyForShell(
command,
mcpServerBridge.resolveSessionBlocklistShellKind(session),
);
if (safety.blocked) {
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
return execViaPty(ptyStream, command, {
stripMarkers: true,
trackForCancellation: mcpServerBridge.activePtyExecs,
timeoutMs,
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
probeLiveShell: true,
onProbeAborted: (marker) => {
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
safeSend(contents, "netcatty:data", { sessionId, data: `${marker}_R\n` });
},
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
typedInput: true,
echoCommand: (rawCommand) => {
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
safeSend(contents, "netcatty:data", {
sessionId,
data: formatSyntheticEcho(rawCommand),
syntheticEcho: true,
});
},
// Catty Agent has no terminal_start fallback for long-running
// commands, so do NOT enforce a hard wall-clock timeout here.
// The inactivity timeout still applies, so genuinely hung
// processes are still terminated.
});
});
}
// Network devices require an interactive PTY for raw command execution.
if (isNetworkDevice) {
releaseLock();
return { ok: false, error: "Network device session has no writable PTY stream for command execution" };
}
// Fallback: SSH exec channel (invisible to terminal)
const sshClient = session.sshClient || session.conn;
if (sshClient && typeof sshClient.exec === "function") {
const { execViaChannel } = require("./ai/ptyExec.cjs");
const channelTimeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
return withLockRelease(async () => {
const probed = await ensureSessionShellKindForExec(session, {
trackForCancellation: mcpServerBridge.activePtyExecs,
chatSessionId,
});
if (!probed.ok) return probed;
const safety = mcpServerBridge.checkCommandSafetyForShell(
command,
mcpServerBridge.resolveSessionBlocklistShellKind(session),
);
if (safety.blocked) {
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
return execViaChannel(sshClient, command, {
timeoutMs: channelTimeoutMs,
trackForCancellation: mcpServerBridge.activePtyExecs,
chatSessionId,
});
});
}
// Serial port: raw command execution (no shell wrapping)
if (session.protocol === "serial" && session.serialPort && typeof session.serialPort.write === "function") {
if (session.ymodemActive || session.zmodemSentry?.isActive?.()) {
releaseLock();
return { ok: false, error: "Serial file transfer is already in progress" };
}
const { execViaRawPty } = require("./ai/ptyExec.cjs");
const serialTimeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
return withLockRelease(() => execViaRawPty(session.serialPort, command, {
timeoutMs: serialTimeoutMs,
trackForCancellation: mcpServerBridge.activePtyExecs,
chatSessionId,
encoding: session.serialEncoding || "utf8",
}));
}
releaseLock();
return { ok: false, error: "No terminal stream or SSH client available for this session" };
} catch (err) {
releaseLock();
return { ok: false, error: err?.message || String(err) };
}
});
// Cancel in-flight Catty Agent command executions for a chat session
ipcMain.handle("netcatty:ai:catty:cancel", async (event, { chatSessionId }) => {
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
mcpServerBridge.cancelPtyExecsForSession(chatSessionId);
void mcpServerBridge.cancelSftpOpsForSession?.(chatSessionId);
if (typeof mcpServerBridge.cancelWorkerBackgroundJobsForSession === "function") {
mcpServerBridge.cancelWorkerBackgroundJobsForSession(chatSessionId);
} else {
try {
terminalWorkerManager?.send?.("netcatty:ai:catty:cancel", { chatSessionId }, {
webContentsId: event?.sender?.id,
});
} catch {
// Worker may already be gone while cancelling a torn-down terminal.
}
}
return { ok: true };
});
ipcMain.handle("netcatty:ai:chat-session:set-cancelled", async (event, { chatSessionId, cancelled }) => {
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
if (!chatSessionId || typeof chatSessionId !== "string") {
return { ok: false, error: "chatSessionId is required" };
}
try {
return await mcpServerBridge.applyChatSessionCancelled(chatSessionId, cancelled !== false);
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
ipcMain.handle("netcatty:ai:capability", async (event, { rpcMethod, params, chatSessionId }) => {
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
if (!rpcMethod || typeof rpcMethod !== "string") {
return { ok: false, error: "rpcMethod is required" };
}
return mcpServerBridge.dispatchBuiltinRpc(rpcMethod, {
...(params || {}),
chatSessionId,
});
});
}
}
module.exports = { registerCattyExecHandlers };

View File

@@ -0,0 +1,96 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const { registerCattyExecHandlers } = require("./cattyExecHandlers.cjs");
function createFakeIpcMain() {
return {
handlers: new Map(),
handle(channel, handler) {
this.handlers.set(channel, handler);
},
};
}
test("catty AI exec proxies to the terminal worker when the real session lives in the worker", async () => {
const ipcMain = createFakeIpcMain();
const requests = [];
const terminalWorkerManager = {
request(channel, payload, options) {
requests.push({ channel, payload, options });
return Promise.resolve({ ok: true, stdout: "ok\n" });
},
};
const locks = [];
const mcpServerBridge = {
getPermissionMode: () => "auto",
getSessionBusyError: () => null,
reserveSessionExecution(sessionId, kind) {
locks.push(["reserve", sessionId, kind]);
return { ok: true, token: "token-1" };
},
releaseSessionExecution(sessionId, token) {
locks.push(["release", sessionId, token]);
},
getSessionMeta() {
return { protocol: "ssh", deviceType: "", hostname: "host.example" };
},
checkCommandSafetyForShell() {
return { blocked: false };
},
checkCommandSafetyCommonOnly() {
return { blocked: false };
},
resolveSessionBlocklistShellKind() {
return "";
},
getCommandTimeoutMs() {
return 12345;
},
getCommandBlocklist() {
return [];
},
activePtyExecs: new Map(),
};
registerCattyExecHandlers({
ipcMain,
validateSender: () => true,
sessions: new Map(),
terminalWorkerManager,
mcpServerBridge,
electronModule: {},
safeSend() {},
execViaPty() {
throw new Error("main process should not execute without a real session");
},
getFreshIdlePrompt() {
return "";
},
});
const result = await ipcMain.handlers.get("netcatty:ai:exec")(
{ sender: { id: 7 } },
{ sessionId: "ssh-1", command: "pwd", chatSessionId: "chat-1" },
);
assert.deepEqual(result, { ok: true, stdout: "ok\n" });
assert.deepEqual(requests, [
{
channel: "netcatty:ai:exec",
payload: {
sessionId: "ssh-1",
command: "pwd",
chatSessionId: "chat-1",
commandTimeoutMs: 12345,
sessionMeta: { protocol: "ssh", deviceType: "", hostname: "host.example" },
commandBlocklist: [],
},
options: { webContentsId: 7 },
},
]);
assert.deepEqual(locks, [
["reserve", "ssh-1", "exec"],
["release", "ssh-1", "token-1"],
]);
});

View File

@@ -0,0 +1,350 @@
"use strict";
const path = require("node:path");
const { spawn } = require("node:child_process");
const { createHash } = require("node:crypto");
const { StringDecoder } = require("node:string_decoder");
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
const INITIALIZE_TIMEOUT_MS = 10_000;
const MAX_STDERR_CHARS = 32_000;
const MAX_JSONL_LINE_BYTES = 16 * 1024 * 1024;
const CLOSE_KILL_GRACE_MS = 750;
function createBoundedLineReader(stream, onLine, onError, maxLineBytes) {
const decoder = new StringDecoder("utf8");
let buffer = "";
let bufferedBytes = 0;
let closed = false;
const fail = () => {
buffer = "";
bufferedBytes = 0;
onError(new Error(`Codex App Server message exceeded ${maxLineBytes} bytes`));
};
const onData = (chunk) => {
if (closed) return;
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk || ""));
bufferedBytes += bytes.length;
buffer += decoder.write(bytes);
let index;
let consumedLine = false;
while ((index = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, index).trim();
buffer = buffer.slice(index + 1);
consumedLine = true;
if (line) onLine(line);
if (closed) return;
}
if (consumedLine) bufferedBytes = Buffer.byteLength(buffer, "utf8") + decoder.lastNeed;
if (bufferedBytes > maxLineBytes) fail();
};
const onEnd = () => {
if (closed) return;
buffer += decoder.end();
const line = buffer.trim();
buffer = "";
bufferedBytes = 0;
if (line) onLine(line);
};
stream?.on?.("data", onData);
stream?.once?.("end", onEnd);
return {
close() {
if (closed) return;
closed = true;
buffer = "";
bufferedBytes = 0;
stream?.removeListener?.("data", onData);
stream?.removeListener?.("end", onEnd);
},
};
}
function buildCodexAppServerLaunch(binPath, args = ["app-server", "--stdio"], {
nodePath = process.execPath,
} = {}) {
const executable = String(binPath || "").trim();
if (!executable) {
throw new Error("Codex binary not found. Configure Codex in Settings -> AI.");
}
const extension = path.extname(executable).toLowerCase();
if (extension === ".js" || extension === ".cjs" || extension === ".mjs") {
return {
command: nodePath,
args: [executable, ...args],
env: { ELECTRON_RUN_AS_NODE: "1" },
};
}
if (extension === ".cmd" || extension === ".bat" || extension === ".ps1") {
throw new Error(
`Codex App Server cannot launch the shell shim ${executable}. ` +
"Configure the native Codex executable or reinstall the Codex CLI.",
);
}
return { command: executable, args };
}
function buildCodexAppServerKey(binPath, env) {
const fingerprint = createHash("sha256")
.update(JSON.stringify(
Object.entries(env || {})
.map(([key, value]) => [key, String(value)])
.sort(([left], [right]) => left.localeCompare(right)),
))
.digest("hex");
return `${String(binPath || "")}\u0000${fingerprint}`;
}
class CodexAppServerConnection {
constructor({
binPath,
env,
appVersion = "0.0.0",
spawnImpl = spawn,
onNotification,
onServerRequest,
onFatal,
closeKillGraceMs = CLOSE_KILL_GRACE_MS,
maxJsonlLineBytes = MAX_JSONL_LINE_BYTES,
}) {
this.binPath = binPath;
this.env = env || {};
this.appVersion = appVersion;
this.spawnImpl = spawnImpl;
this.onNotification = onNotification;
this.onServerRequest = onServerRequest;
this.onFatal = onFatal;
this.closeKillGraceMs = closeKillGraceMs;
this.maxJsonlLineBytes = maxJsonlLineBytes;
this.process = null;
this.closingProcesses = new Map();
this.readline = null;
this.nextRequestId = 1;
this.pending = new Map();
this.startPromise = null;
this.initialized = false;
this.closing = false;
this.stderr = "";
}
async start() {
if (this.initialized && this.process && !this.process.killed) return this;
if (this.startPromise) return this.startPromise;
this.startPromise = this.#startInternal().finally(() => {
this.startPromise = null;
});
return this.startPromise;
}
async #startInternal() {
this.closing = false;
this.stderr = "";
const launch = buildCodexAppServerLaunch(this.binPath);
const child = this.spawnImpl(launch.command, launch.args, {
cwd: process.cwd(),
env: { ...this.env, ...(launch.env || {}) },
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
shell: false,
});
this.process = child;
child.stderr?.setEncoding?.("utf8");
child.stderr?.on?.("data", (chunk) => {
this.stderr = `${this.stderr}${String(chunk || "")}`.slice(-MAX_STDERR_CHARS);
});
this.readline = createBoundedLineReader(
child.stdout,
(line) => this.#handleLine(line),
(error) => this.#handleFatal(error),
this.maxJsonlLineBytes,
);
child.once("error", (error) => {
if (this.closingProcesses.has(child) || this.process !== child) return;
this.#handleFatal(error);
});
child.once("exit", (code, signal) => {
const wasClosing = this.closingProcesses.has(child);
if (wasClosing) this.#releaseClosingProcess(child);
if (wasClosing || this.process !== child) return;
const detail = this.stderr.trim();
const suffix = detail ? `\n${detail}` : "";
this.#handleFatal(new Error(
`Codex App Server exited unexpectedly (code ${code ?? "null"}, signal ${signal ?? "none"}).${suffix}`,
));
});
try {
await this.request("initialize", {
clientInfo: {
name: "netcatty",
title: "Netcatty",
version: this.appVersion,
},
capabilities: {
experimentalApi: true,
requestAttestation: false,
mcpServerOpenaiFormElicitation: false,
},
}, INITIALIZE_TIMEOUT_MS, { skipStart: true });
this.notify("initialized", {});
this.initialized = true;
return this;
} catch (error) {
this.close();
const detail = this.stderr.trim();
if (detail && !String(error?.message || error).includes(detail)) {
throw new Error(`${error?.message || error}\n${detail}`);
}
throw error;
}
}
async request(method, params = {}, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, options = {}) {
if (!options.skipStart) await this.start();
const id = this.nextRequestId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Codex App Server request timed out: ${method}`));
}, timeoutMs);
this.pending.set(id, { method, resolve, reject, timer });
try {
this.#write({ id, method, params });
} catch (error) {
clearTimeout(timer);
this.pending.delete(id);
reject(error);
}
});
}
notify(method, params = {}) {
this.#write({ method, params });
}
respond(id, result) {
this.#write({ id, result });
}
respondError(id, code, message, data) {
const error = { code, message };
if (data !== undefined) error.data = data;
this.#write({ id, error });
}
#write(message) {
const stdin = this.process?.stdin;
if (!stdin || stdin.destroyed || !stdin.writable) {
throw new Error("Codex App Server stdin is unavailable");
}
stdin.write(`${JSON.stringify(message)}\n`);
}
#handleLine(rawLine) {
const line = String(rawLine || "").trim();
if (!line) return;
let message;
try {
message = JSON.parse(line);
} catch {
this.#handleFatal(new Error(`Codex App Server emitted invalid JSON: ${line.slice(0, 500)}`));
return;
}
if (Object.prototype.hasOwnProperty.call(message, "id") && !message.method) {
const entry = this.pending.get(message.id);
if (!entry) return;
this.pending.delete(message.id);
clearTimeout(entry.timer);
if (message.error) {
const error = new Error(message.error.message || `Codex App Server ${entry.method} failed`);
error.code = message.error.code;
error.data = message.error.data;
entry.reject(error);
} else {
entry.resolve(message.result);
}
return;
}
if (message.method && Object.prototype.hasOwnProperty.call(message, "id")) {
Promise.resolve(this.onServerRequest?.(message, this)).catch((error) => {
try {
this.respondError(message.id, -32603, error?.message || String(error));
} catch {}
});
return;
}
if (message.method) {
try {
this.onNotification?.(message, this);
} catch (error) {
this.#handleFatal(error);
}
}
}
#handleFatal(error) {
if (this.closing) return;
this.initialized = false;
const fatal = error instanceof Error ? error : new Error(String(error));
for (const [, entry] of this.pending) {
clearTimeout(entry.timer);
entry.reject(fatal);
}
this.pending.clear();
try { this.onFatal?.(fatal, this); } catch {}
this.close();
}
#releaseClosingProcess(child) {
if (!this.closingProcesses.has(child)) return;
clearTimeout(this.closingProcesses.get(child));
this.closingProcesses.delete(child);
}
getClosingProcessCountForTests() {
return this.closingProcesses.size;
}
close() {
this.closing = true;
this.initialized = false;
try { this.readline?.close?.(); } catch {}
this.readline = null;
for (const [, entry] of this.pending) {
clearTimeout(entry.timer);
entry.reject(new Error("Codex App Server connection closed"));
}
this.pending.clear();
const child = this.process;
this.process = null;
if (!child) return;
try { child.stdin?.end?.(); } catch {}
this.closingProcesses.set(child, null);
const killTimer = setTimeout(() => {
if (!this.closingProcesses.has(child)) return;
try { child.kill?.("SIGKILL"); } catch {}
this.#releaseClosingProcess(child);
}, this.closeKillGraceMs);
killTimer.unref?.();
this.closingProcesses.set(child, killTimer);
try { child.kill?.("SIGTERM"); } catch {}
}
}
module.exports = {
CodexAppServerConnection,
buildCodexAppServerKey,
buildCodexAppServerLaunch,
DEFAULT_REQUEST_TIMEOUT_MS,
INITIALIZE_TIMEOUT_MS,
CLOSE_KILL_GRACE_MS,
MAX_JSONL_LINE_BYTES,
};

View File

@@ -0,0 +1,200 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter, once } = require("node:events");
const { PassThrough } = require("node:stream");
const {
CodexAppServerConnection,
buildCodexAppServerKey,
buildCodexAppServerLaunch,
} = require("./connection.cjs");
function createFakeChild() {
const child = new EventEmitter();
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.killed = false;
child.kill = () => { child.killed = true; };
return child;
}
async function readJsonLine(stream) {
const [chunk] = await once(stream, "data");
return JSON.parse(String(chunk).trim());
}
test("buildCodexAppServerLaunch runs JS entries through Node without a shell", () => {
assert.deepEqual(
buildCodexAppServerLaunch("/opt/codex/bin/codex.js", ["app-server", "--help"], { nodePath: "/usr/bin/node" }),
{
command: "/usr/bin/node",
args: ["/opt/codex/bin/codex.js", "app-server", "--help"],
env: { ELECTRON_RUN_AS_NODE: "1" },
},
);
assert.deepEqual(
buildCodexAppServerLaunch("/usr/local/bin/codex"),
{ command: "/usr/local/bin/codex", args: ["app-server", "--stdio"] },
);
assert.throws(() => buildCodexAppServerLaunch("C:\\npm\\codex.cmd"), /shell shim/);
});
test("App Server connection initializes once and correlates JSONL requests", async () => {
const child = createFakeChild();
const notifications = [];
const connection = new CodexAppServerConnection({
binPath: "/usr/bin/codex",
env: { HOME: "/tmp/home" },
appVersion: "1.2.3",
spawnImpl: () => child,
onNotification: (message) => notifications.push(message),
});
const startPromise = connection.start();
const initialize = await readJsonLine(child.stdin);
assert.equal(initialize.method, "initialize");
assert.equal(initialize.params.clientInfo.name, "netcatty");
assert.equal(initialize.params.capabilities.experimentalApi, true);
child.stdout.write(`${JSON.stringify({ id: initialize.id, result: { userAgent: "codex" } })}\n`);
await startPromise;
const initialized = await readJsonLine(child.stdin);
assert.equal(initialized.method, "initialized");
const requestPromise = connection.request("model/list", { limit: 100 });
const request = await readJsonLine(child.stdin);
assert.equal(request.method, "model/list");
child.stdout.write(`${JSON.stringify({ id: request.id, result: { data: [], nextCursor: null } })}\n`);
assert.deepEqual(await requestPromise, { data: [], nextCursor: null });
child.stdout.write(`${JSON.stringify({ method: "warning", params: { message: "heads up" } })}\n`);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(notifications[0].method, "warning");
connection.close();
});
test("App Server connection preserves a Chinese response split across UTF-8 chunks", async () => {
const child = createFakeChild();
const connection = new CodexAppServerConnection({
binPath: "/usr/bin/codex",
env: {},
spawnImpl: () => child,
});
const startPromise = connection.start();
const initialize = await readJsonLine(child.stdin);
const response = Buffer.from(`${JSON.stringify({
id: initialize.id,
result: { message: "中文" },
})}\n`, "utf8");
const split = response.indexOf(Buffer.from("中", "utf8")) + 1;
child.stdout.write(response.subarray(0, split));
child.stdout.write(response.subarray(split));
await startPromise;
await readJsonLine(child.stdin);
connection.close();
});
test("App Server connection rejects an unterminated oversized JSONL message", async () => {
const child = createFakeChild();
let fatal;
const connection = new CodexAppServerConnection({
binPath: "/usr/bin/codex",
env: {},
maxJsonlLineBytes: 8,
spawnImpl: () => child,
onFatal: (error) => { fatal = error; },
});
const startPromise = connection.start();
await readJsonLine(child.stdin);
child.stdout.write("123456789");
await assert.rejects(startPromise, /message exceeded 8 bytes/);
assert.match(fatal.message, /message exceeded 8 bytes/);
assert.equal(child.killed, true);
});
test("App Server connection rejects pending RPCs when the process exits", async () => {
const child = createFakeChild();
let fatal;
const connection = new CodexAppServerConnection({
binPath: "/usr/bin/codex",
env: {},
spawnImpl: () => child,
onFatal: (error) => { fatal = error; },
});
const startPromise = connection.start();
const initialize = await readJsonLine(child.stdin);
child.stdout.write(`${JSON.stringify({ id: initialize.id, result: {} })}\n`);
await startPromise;
await readJsonLine(child.stdin); // initialized notification
const request = connection.request("thread/start", {});
await readJsonLine(child.stdin);
child.emit("exit", 1, null);
await assert.rejects(request, /exited unexpectedly/);
assert.match(fatal.message, /code 1/);
});
test("App Server close force-kills a child that ignores SIGTERM", async () => {
const child = createFakeChild();
const signals = [];
child.kill = (signal) => {
signals.push(signal);
return true;
};
const connection = new CodexAppServerConnection({
binPath: "/usr/bin/codex",
env: {},
closeKillGraceMs: 5,
spawnImpl: () => child,
});
const startPromise = connection.start();
const initialize = await readJsonLine(child.stdin);
child.stdout.write(`${JSON.stringify({ id: initialize.id, result: {} })}\n`);
await startPromise;
await readJsonLine(child.stdin);
connection.close();
await new Promise((resolve) => setTimeout(resolve, 10));
assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]);
});
test("App Server close does not retain or re-kill a child that exits synchronously on SIGTERM", async () => {
const child = createFakeChild();
const signals = [];
child.kill = (signal) => {
signals.push(signal);
if (signal === "SIGTERM") child.emit("exit", 0, "SIGTERM");
return true;
};
const connection = new CodexAppServerConnection({
binPath: "/usr/bin/codex",
env: {},
closeKillGraceMs: 5,
spawnImpl: () => child,
});
const startPromise = connection.start();
const initialize = await readJsonLine(child.stdin);
child.stdout.write(`${JSON.stringify({ id: initialize.id, result: {} })}\n`);
await startPromise;
await readJsonLine(child.stdin);
connection.close();
assert.equal(connection.getClosingProcessCountForTests(), 0);
await new Promise((resolve) => setTimeout(resolve, 10));
assert.deepEqual(signals, ["SIGTERM"]);
});
test("App Server process keys include executable and environment identity", () => {
assert.notEqual(
buildCodexAppServerKey("/a/codex", { HOME: "/a" }),
buildCodexAppServerKey("/b/codex", { HOME: "/a" }),
);
assert.notEqual(
buildCodexAppServerKey("/a/codex", { HOME: "/a" }),
buildCodexAppServerKey("/a/codex", { HOME: "/b" }),
);
});

View File

@@ -0,0 +1,44 @@
"use strict";
const { execFile } = require("node:child_process");
const { buildCodexAppServerLaunch } = require("./connection.cjs");
function execFileText(command, args, options = {}) {
return new Promise((resolve, reject) => {
execFile(command, args, options, (error, stdout, stderr) => {
if (error) {
error.stdout = stdout;
error.stderr = stderr;
reject(error);
return;
}
resolve({ stdout: String(stdout || ""), stderr: String(stderr || "") });
});
});
}
async function probeCodexAppServer({ binPath, env, execFileImpl = execFileText }) {
try {
const launch = buildCodexAppServerLaunch(binPath, ["app-server", "--help"]);
const result = await execFileImpl(launch.command, launch.args, {
env: { ...(env || {}), ...(launch.env || {}) },
encoding: "utf8",
timeout: 5_000,
windowsHide: true,
maxBuffer: 1024 * 1024,
});
const output = `${result.stdout || ""}\n${result.stderr || ""}`;
const available = /Run the app server|--listen|--stdio/i.test(output);
return available
? { available: true }
: { available: false, error: "This Codex CLI does not advertise App Server support." };
} catch (error) {
const detail = String(error?.stderr || error?.message || error || "").trim();
return {
available: false,
error: detail || "Failed to probe Codex App Server support.",
};
}
}
module.exports = { execFileText, probeCodexAppServer };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,988 @@
"use strict";
const {
CodexAppServerConnection,
buildCodexAppServerKey,
} = require("./connection.cjs");
const {
parseCodexModelSelection,
toCodexMcpConfig,
} = require("../sdk/codexDriver.cjs");
const INTERACTION_TIMEOUT_MS = 5 * 60 * 1000;
const INTERRUPT_REQUEST_TIMEOUT_MS = 5_000;
const INTERRUPT_GRACE_MS = 2_000;
const MAX_STREAMED_PREFIX_CHARS = 256 * 1024;
const MAX_TOOL_OUTPUT_CHARS = 1024 * 1024;
function appendStreamState(map, itemId, delta, maxPrefixChars = MAX_STREAMED_PREFIX_CHARS) {
const text = String(delta || "");
const previous = map.get(itemId) || { prefix: "", length: 0, truncated: false };
const remaining = Math.max(0, maxPrefixChars - previous.prefix.length);
const next = {
prefix: remaining > 0 ? previous.prefix + text.slice(0, remaining) : previous.prefix,
length: previous.length + text.length,
truncated: previous.truncated || text.length > remaining,
};
map.set(itemId, next);
return next;
}
function appendToolOutputState(map, itemId, delta) {
const text = String(delta || "");
const previous = map.get(itemId) || { text: "", totalLength: 0, truncated: false };
const remaining = Math.max(0, MAX_TOOL_OUTPUT_CHARS - previous.text.length);
const next = {
text: remaining > 0 ? previous.text + text.slice(0, remaining) : previous.text,
totalLength: previous.totalLength + text.length,
truncated: previous.truncated || text.length > remaining,
};
map.set(itemId, next);
return next;
}
function formatBoundedToolOutput(value, totalLength = String(value || "").length) {
const text = String(value || "");
if (text.length <= MAX_TOOL_OUTPUT_CHARS && totalLength <= MAX_TOOL_OUTPUT_CHARS) return text;
const kept = text.slice(0, MAX_TOOL_OUTPUT_CHARS);
return `${kept}\n[output truncated: ${Math.max(totalLength, text.length)} characters total]`;
}
function resolveCodexPermissionConfig(permissionMode) {
if (permissionMode === "observer") {
return {
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: "read-only",
sandboxPolicy: { type: "readOnly", networkAccess: false },
};
}
if (permissionMode === "auto") {
return {
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: "danger-full-access",
sandboxPolicy: { type: "dangerFullAccess" },
};
}
return {
approvalPolicy: "on-request",
approvalsReviewer: "user",
sandbox: "read-only",
sandboxPolicy: { type: "readOnly", networkAccess: false },
};
}
function buildThreadConfig(injectedMcpServers) {
return {
// Netcatty already applies its Observer/Confirm/Auto policy inside the MCP
// bridge. Tell Codex not to add a second MCP approval prompt: App Server
// otherwise routes the stable MCP elicitation request back to this client,
// and rejecting/omitting that duplicate prompt surfaces as
// "user rejected MCP tool call" before Netcatty's own gate can run.
mcp_servers: toCodexMcpConfig(injectedMcpServers, {
defaultToolsApprovalMode: "approve",
}),
model_reasoning_summary: "concise",
};
}
function normalizeFileChanges(changes) {
if (!Array.isArray(changes)) return [];
return changes
.filter((change) => change && typeof change.path === "string")
.map((change) => ({
path: change.path,
kind: change.kind?.type === "add"
? "add"
: change.kind?.type === "delete"
? "delete"
: "update",
}));
}
function normalizeGrantedPermissions(requested) {
const granted = {};
if (requested?.network != null) granted.network = requested.network;
if (requested?.fileSystem != null) granted.fileSystem = requested.fileSystem;
return granted;
}
function stringifyMcpContent(result) {
if (!result) return "";
const content = Array.isArray(result.content) ? result.content : [];
let text = "";
let totalLength = 0;
for (const item of content) {
const rawPart = item && typeof item === "object" && typeof item.text === "string"
? item.text
: typeof item === "string" ? item : JSON.stringify(item);
const part = typeof rawPart === "string" ? rawPart : "";
totalLength += part.length;
if (text.length < MAX_TOOL_OUTPUT_CHARS) {
text += part.slice(0, MAX_TOOL_OUTPUT_CHARS - text.length);
}
}
if (text || totalLength > 0) return formatBoundedToolOutput(text, totalLength);
if (result.structuredContent == null) return "";
return formatBoundedToolOutput(JSON.stringify(result.structuredContent));
}
function buildTurnInput(prompt, attachments) {
const input = [{ type: "text", text: String(prompt || ""), text_elements: [] }];
for (const attachment of attachments || []) {
if (!attachment?.filePath) continue;
if (!String(attachment.mediaType || "").toLowerCase().startsWith("image/")) continue;
input.push({ type: "localImage", path: attachment.filePath });
}
return input;
}
function getActiveTurnNotSteerableKind(error) {
const turnKind = error?.data?.activeTurnNotSteerable?.turnKind
?? error?.data?.codexErrorInfo?.activeTurnNotSteerable?.turnKind;
return turnKind === "review" || turnKind === "compact" ? turnKind : null;
}
function mapAppServerModels(rawModels) {
return (Array.isArray(rawModels) ? rawModels : [])
.filter((model) => model && model.id && !model.hidden)
.map((model) => ({
id: model.id,
name: model.displayName || model.id,
description: model.description || undefined,
thinkingLevels: Array.isArray(model.supportedReasoningEfforts)
? model.supportedReasoningEfforts
.map((option) => option?.reasoningEffort)
.filter(Boolean)
: [],
defaultThinkingLevel: model.defaultReasoningEffort || undefined,
isDefault: model.isDefault === true,
}));
}
function resolveAppServerModelSelection(model) {
if (!model) return null;
const defaultThinkingLevel = model.defaultThinkingLevel;
if (
defaultThinkingLevel
&& Array.isArray(model.thinkingLevels)
&& model.thinkingLevels.includes(defaultThinkingLevel)
) {
return `${model.id}/${defaultThinkingLevel}`;
}
return model.id;
}
class CodexAppServerRuntime {
constructor({
appVersion = "0.0.0",
connectionFactory,
sendInteractionRequest,
sendInteractionCleared,
interruptRequestTimeoutMs = INTERRUPT_REQUEST_TIMEOUT_MS,
interruptGraceMs = INTERRUPT_GRACE_MS,
} = {}) {
this.appVersion = appVersion;
this.connectionFactory = connectionFactory;
this.sendInteractionRequest = sendInteractionRequest;
this.sendInteractionCleared = sendInteractionCleared;
this.interruptRequestTimeoutMs = interruptRequestTimeoutMs;
this.interruptGraceMs = interruptGraceMs;
this.connections = new Map();
this.preferredConnectionKey = null;
this.activeByRequest = new Map();
this.activeByThread = new Map();
this.activeByTurn = new Map();
this.pendingInteractions = new Map();
this.interactionCounter = 0;
this.eventCounter = 0;
}
#scopedKey(connectionKey, id) {
return `${connectionKey}\u0000${String(id || "")}`;
}
#getConnection(binPath, env) {
const connectionKey = buildCodexAppServerKey(binPath, env);
this.preferredConnectionKey = connectionKey;
const existing = this.connections.get(connectionKey);
if (existing) {
this.#closeIdleConnections(connectionKey);
return { connection: existing, connectionKey };
}
this.#closeIdleConnections(connectionKey);
const factory = this.connectionFactory || ((options) => new CodexAppServerConnection(options));
const connection = factory({
binPath,
env,
appVersion: this.appVersion,
onNotification: (message) => this.#handleNotification(connectionKey, message),
onServerRequest: (message, source) => this.#handleServerRequest(connectionKey, source, message),
onFatal: (error) => this.#handleConnectionFatal(connectionKey, error),
});
this.connections.set(connectionKey, connection);
return { connection, connectionKey };
}
#closeIdleConnections(keepKey = this.preferredConnectionKey) {
const activeConnectionKeys = new Set(
Array.from(this.activeByRequest.values(), (context) => context.connectionKey),
);
for (const [connectionKey, connection] of this.connections) {
if (connectionKey === keepKey || activeConnectionKeys.has(connectionKey)) continue;
this.connections.delete(connectionKey);
try { connection.close(); } catch {}
}
}
#refreshPreferredConnectionKey() {
if (this.preferredConnectionKey && this.connections.has(this.preferredConnectionKey)) return;
const connectionKeys = Array.from(this.connections.keys());
this.preferredConnectionKey = connectionKeys.at(-1) || null;
}
async runTurn({
requestId,
chatSessionId,
prompt,
attachments,
cwd,
model,
permissionMode,
env,
binPath,
injectedMcpServers,
resumeThreadId,
emitter,
signal,
sender,
}) {
const throwIfAborted = () => {
if (!signal?.aborted) return;
const error = new Error("Codex App Server turn was interrupted before it started");
error.name = "AbortError";
throw error;
};
throwIfAborted();
const { connection, connectionKey } = this.#getConnection(binPath, env);
await connection.start();
throwIfAborted();
const permission = resolveCodexPermissionConfig(permissionMode);
const selection = parseCodexModelSelection(model);
const threadParams = {
model: selection.model || null,
cwd: cwd || process.cwd(),
approvalPolicy: permission.approvalPolicy,
approvalsReviewer: permission.approvalsReviewer,
sandbox: permission.sandbox,
config: buildThreadConfig(injectedMcpServers),
};
const threadResult = resumeThreadId
? await connection.request("thread/resume", { threadId: resumeThreadId, ...threadParams })
: await connection.request("thread/start", threadParams);
throwIfAborted();
const threadId = threadResult?.thread?.id || resumeThreadId;
if (!threadId) throw new Error("Codex App Server did not return a thread id");
emitter.sessionId(threadId);
const context = {
requestId,
chatSessionId,
connection,
connectionKey,
threadId,
turnId: null,
emitter,
signal,
sender,
lastError: null,
settled: false,
cancelRequested: false,
interruptPromise: null,
steerPromise: null,
reasoningOpen: false,
streamedTextByItem: new Map(),
streamedReasoningByItem: new Map(),
commandOutputByItem: new Map(),
emittedToolCalls: new Set(),
emittedToolResults: new Set(),
forceCancelTimer: null,
abortListener: null,
};
this.activeByRequest.set(requestId, context);
this.activeByThread.set(this.#scopedKey(connectionKey, threadId), context);
const completion = new Promise((resolve, reject) => {
context.resolve = resolve;
context.reject = reject;
});
if (signal) {
context.abortListener = () => { void this.cancelTurn(requestId); };
signal.addEventListener("abort", context.abortListener, { once: true });
if (signal.aborted) context.abortListener();
}
try {
const turnResult = await connection.request("turn/start", {
threadId,
input: buildTurnInput(prompt, attachments),
cwd: cwd || process.cwd(),
approvalPolicy: permission.approvalPolicy,
approvalsReviewer: permission.approvalsReviewer,
sandboxPolicy: permission.sandboxPolicy,
model: selection.model || null,
effort: selection.effort || null,
summary: "concise",
});
const turnId = turnResult?.turn?.id;
if (turnId) this.#assignTurnId(context, turnId);
await completion;
return { threadId, turnId: context.turnId };
} finally {
this.#removeContext(context);
}
}
async listModels({ binPath, env }) {
const { connection } = this.#getConnection(binPath, env);
await connection.start();
const all = [];
let cursor = null;
do {
const response = await connection.request("model/list", {
cursor,
limit: 100,
}, 10_000);
all.push(...(response?.data || []));
cursor = response?.nextCursor || null;
} while (cursor);
const models = mapAppServerModels(all);
const defaultModel = models.find((model) => model.isDefault);
return {
currentModelId: resolveAppServerModelSelection(defaultModel),
models,
};
}
async steerTurn(requestId, {
chatSessionId,
prompt,
attachments,
clientUserMessageId,
} = {}) {
const context = this.activeByRequest.get(requestId);
if (!context || context.settled || context.chatSessionId !== chatSessionId) {
return { status: "inactive" };
}
if (context.cancelRequested || context.signal?.aborted) {
return { status: "cancelled" };
}
if (!context.turnId) {
return { status: "busy", message: "Codex turn is still starting" };
}
if (context.steerPromise) {
return { status: "busy", message: "A Codex instruction is already being sent" };
}
const steerPromise = (async () => {
try {
const response = await context.connection.request("turn/steer", {
threadId: context.threadId,
expectedTurnId: context.turnId,
input: buildTurnInput(prompt, attachments),
clientUserMessageId: clientUserMessageId || null,
});
if (context.cancelRequested || context.signal?.aborted || context.settled) {
return { status: "cancelled" };
}
if (response?.turnId && response.turnId !== context.turnId) {
return {
status: "failed",
message: "Codex App Server returned a different turn id while steering",
};
}
return { status: "accepted" };
} catch (error) {
const turnKind = getActiveTurnNotSteerableKind(error);
if (turnKind) {
return {
status: "not-steerable",
turnKind,
message: error?.message || "The active Codex turn cannot be steered",
};
}
if (context.cancelRequested || context.signal?.aborted || context.settled) {
return { status: "cancelled" };
}
return {
status: "failed",
message: error?.message || String(error),
};
}
})();
context.steerPromise = steerPromise;
try {
return await steerPromise;
} finally {
if (context.steerPromise === steerPromise) context.steerPromise = null;
}
}
#assignTurnId(context, turnId) {
if (!turnId || context.turnId === turnId) return;
if (context.turnId) {
this.activeByTurn.delete(this.#scopedKey(context.connectionKey, context.turnId));
}
context.turnId = turnId;
this.activeByTurn.set(this.#scopedKey(context.connectionKey, turnId), context);
if (context.cancelRequested) void this.#interruptAndSchedule(context);
}
#interruptContext(context) {
if (!context.turnId) return Promise.resolve(false);
if (context.interruptPromise) return context.interruptPromise;
let timeout;
const request = Promise.resolve().then(() => context.connection.request("turn/interrupt", {
threadId: context.threadId,
turnId: context.turnId,
}, this.interruptRequestTimeoutMs)).then(() => true).catch(() => false);
const deadline = new Promise((resolve) => {
timeout = setTimeout(() => resolve(false), this.interruptRequestTimeoutMs);
timeout.unref?.();
});
context.interruptPromise = Promise.race([request, deadline])
.finally(() => clearTimeout(timeout));
return context.interruptPromise;
}
#scheduleForcedCancellation(context, delayMs) {
if (context.settled) return;
clearTimeout(context.forceCancelTimer);
context.forceCancelTimer = setTimeout(() => {
this.#forceCancelContext(context, "Codex App Server did not complete the interrupted turn");
}, Math.max(0, delayMs));
context.forceCancelTimer.unref?.();
}
async #interruptAndSchedule(context) {
if (context.settled) return;
if (!context.turnId) {
this.#scheduleForcedCancellation(
context,
this.interruptRequestTimeoutMs + this.interruptGraceMs,
);
return;
}
const interrupted = await this.#interruptContext(context);
if (context.settled) return;
if (!interrupted) {
this.#forceCancelContext(context, "Codex App Server could not interrupt the turn");
return;
}
this.#scheduleForcedCancellation(context, this.interruptGraceMs);
}
#forceCancelContext(context, reason) {
if (context.settled) return;
context.settled = true;
clearTimeout(context.forceCancelTimer);
context.forceCancelTimer = null;
this.#closeReasoning(context);
this.#clearInteractionsForContext(context, "cancel");
context.emitter.emitDone();
context.resolve();
const connection = this.connections.get(context.connectionKey);
if (connection === context.connection) {
this.connections.delete(context.connectionKey);
try { connection.close(); } catch {}
this.#refreshPreferredConnectionKey();
}
const error = new Error(reason);
for (const candidate of this.activeByRequest.values()) {
if (candidate === context || candidate.connectionKey !== context.connectionKey || candidate.settled) continue;
candidate.settled = true;
clearTimeout(candidate.forceCancelTimer);
candidate.forceCancelTimer = null;
this.#clearInteractionsForContext(candidate, "cancel");
candidate.reject(error);
}
}
#findContext(connectionKey, params) {
if (params?.turnId) {
const byTurn = this.activeByTurn.get(this.#scopedKey(connectionKey, params.turnId));
if (byTurn) return byTurn;
}
if (params?.threadId) {
return this.activeByThread.get(this.#scopedKey(connectionKey, params.threadId)) || null;
}
return null;
}
#handleNotification(connectionKey, message) {
const params = message.params || {};
const context = this.#findContext(connectionKey, params);
if (!context) {
if (message.method === "warning") {
const contexts = Array.from(this.activeByRequest.values())
.filter((candidate) => candidate.connectionKey === connectionKey);
for (const candidate of contexts) {
candidate.emitter.warning(
`codex-warning:connection:${++this.eventCounter}`,
params.message || "Codex warning",
);
}
}
return;
}
const emitter = context.emitter;
switch (message.method) {
case "turn/started":
this.#assignTurnId(context, params.turn?.id);
return;
case "item/agentMessage/delta": {
appendStreamState(context.streamedTextByItem, params.itemId, params.delta);
emitter.text(params.delta || "");
return;
}
case "item/reasoning/summaryTextDelta": {
appendStreamState(context.streamedReasoningByItem, params.itemId, params.delta);
emitter.reasoning(params.delta || "");
context.reasoningOpen = true;
return;
}
case "item/commandExecution/outputDelta": {
appendToolOutputState(context.commandOutputByItem, params.itemId, params.delta);
return;
}
case "item/started":
this.#handleItem(context, params.item, false);
return;
case "item/completed":
this.#handleItem(context, params.item, true);
return;
case "turn/plan/updated":
emitter.planUpdate(
`codex-plan:${params.turnId}`,
(params.plan || []).map((item) => ({
text: item.step || "",
completed: item.status === "completed",
})),
(params.plan || []).every((item) => item.status === "completed") ? "completed" : "running",
);
return;
case "thread/tokenUsage/updated": {
const usage = params.tokenUsage?.last;
if (usage) {
emitter.usage({
inputTokens: Number(usage.inputTokens) || 0,
cachedInputTokens: Number(usage.cachedInputTokens) || 0,
outputTokens: Number(usage.outputTokens) || 0,
reasoningTokens: Number(usage.reasoningOutputTokens) || 0,
totalTokens: Number(usage.totalTokens) || 0,
});
}
return;
}
case "warning":
emitter.warning(
`codex-warning:${params.turnId || context.turnId}:${++this.eventCounter}`,
params.message || "Codex warning",
);
return;
case "error":
context.lastError = params.error?.message || "Codex App Server error";
emitter.warning(
`codex-error:${params.turnId || context.turnId}:${++this.eventCounter}`,
params.willRetry ? `${context.lastError} (retrying)` : context.lastError,
);
return;
case "turn/completed":
this.#completeTurn(context, params.turn);
return;
default:
return;
}
}
#closeReasoning(context) {
if (!context.reasoningOpen) return;
context.emitter.reasoningEnd();
context.reasoningOpen = false;
}
#emitToolCallOnce(context, item, name, args) {
if (!item?.id || context.emittedToolCalls.has(item.id)) return;
context.emittedToolCalls.add(item.id);
this.#closeReasoning(context);
context.emitter.toolCall(name, args || {}, item.id);
}
#emitToolResultOnce(context, item, output, name) {
if (!item?.id || context.emittedToolResults.has(item.id)) return;
context.emittedToolResults.add(item.id);
context.emitter.toolResult(item.id, output || "", name);
}
#handleItem(context, item, completed) {
if (!item || typeof item !== "object") return;
const emitter = context.emitter;
switch (item.type) {
case "agentMessage": {
if (!completed) return;
this.#closeReasoning(context);
const streamed = context.streamedTextByItem.get(item.id);
context.streamedTextByItem.delete(item.id);
if (item.text && streamed && item.text.startsWith(streamed.prefix)) {
if (item.text.length > streamed.length) emitter.text(item.text.slice(streamed.length));
} else if (item.text && !streamed) emitter.text(item.text);
return;
}
case "reasoning": {
if (!completed) return;
const finalText = Array.isArray(item.summary) ? item.summary.join("\n") : "";
const streamed = context.streamedReasoningByItem.get(item.id);
context.streamedReasoningByItem.delete(item.id);
if (finalText && streamed && finalText.startsWith(streamed.prefix)) {
if (finalText.length > streamed.length) emitter.reasoning(finalText.slice(streamed.length));
} else if (finalText && !streamed) emitter.reasoning(finalText);
context.reasoningOpen = true;
this.#closeReasoning(context);
return;
}
case "commandExecution": {
const toolName = "codex.command";
this.#emitToolCallOnce(context, item, toolName, { command: item.command, cwd: item.cwd });
if (completed) {
const streamedOutput = context.commandOutputByItem.get(item.id);
context.commandOutputByItem.delete(item.id);
const output = item.aggregatedOutput == null
? formatBoundedToolOutput(
streamedOutput?.text || "",
streamedOutput?.totalLength || 0,
)
: formatBoundedToolOutput(item.aggregatedOutput);
const suffix = item.exitCode == null ? "" : `\n[exit code: ${item.exitCode}]`;
this.#emitToolResultOnce(context, item, `${output}${suffix}`, toolName);
}
return;
}
case "mcpToolCall": {
const toolName = `${item.server || "mcp"}.${item.tool || "tool"}`;
this.#emitToolCallOnce(context, item, toolName, item.arguments || {});
if (completed) {
const output = item.error?.message || stringifyMcpContent(item.result);
this.#emitToolResultOnce(context, item, output, toolName);
}
return;
}
case "fileChange":
if (completed) {
emitter.fileChange(
item.id,
normalizeFileChanges(item.changes),
item.status === "completed" ? "completed" : "failed",
);
}
return;
case "webSearch":
emitter.webSearch(item.id, item.query || "", completed ? "completed" : "running");
return;
default:
return;
}
}
#completeTurn(context, turn) {
if (context.settled) return;
context.settled = true;
clearTimeout(context.forceCancelTimer);
context.forceCancelTimer = null;
this.#closeReasoning(context);
this.#clearInteractionsForContext(context, "cancel");
if (turn?.status === "failed") {
context.reject(new Error(turn.error?.message || context.lastError || "Codex turn failed"));
return;
}
context.emitter.emitDone();
context.resolve();
}
async #handleServerRequest(connectionKey, connection, message) {
const params = message.params || {};
const context = this.#findContext(connectionKey, params);
const supported = new Map([
["item/commandExecution/requestApproval", "command"],
["item/fileChange/requestApproval", "file-change"],
["item/permissions/requestApproval", "permissions"],
["item/tool/requestUserInput", "user-input"],
]);
const kind = supported.get(message.method);
if (!kind) {
connection.respondError(message.id, -32601, `Unsupported Codex App Server request: ${message.method}`);
context?.emitter.warning(
`codex-unsupported-request:${++this.eventCounter}`,
`Unsupported Codex request: ${message.method}`,
);
return;
}
if (!context) {
connection.respond(message.id, this.#safeInteractionResponse(kind, params, "reject"));
return;
}
const interactionId = `codex_interaction_${++this.interactionCounter}_${Date.now()}`;
const timeoutMs = kind === "user-input" && Number(params.autoResolutionMs) > 0
? Number(params.autoResolutionMs)
: INTERACTION_TIMEOUT_MS;
// Hard ceiling from creation — review can cancel the idle timer but must
// re-arm the absolute remainder (Catty/MCP pattern; never unbounded).
const absoluteExpiresAt = Date.now() + timeoutMs;
const armTimer = (ms) => {
const pending = this.pendingInteractions.get(interactionId);
if (!pending) return;
if (pending.timer) {
clearTimeout(pending.timer);
pending.timer = null;
}
if (ms <= 0) {
this.#resolveInteraction(
interactionId,
kind === "user-input" ? { answers: {} } : { decision: "reject" },
);
return;
}
pending.timer = setTimeout(() => {
this.#resolveInteraction(
interactionId,
kind === "user-input" ? { answers: {} } : { decision: "reject" },
);
}, ms);
};
this.pendingInteractions.set(interactionId, {
interactionId,
connection,
rpcId: message.id,
kind,
params,
context,
timer: null,
absoluteExpiresAt,
idleCancelled: false,
});
armTimer(timeoutMs);
const payload = {
interactionId,
source: "codex-app-server",
kind,
requestId: context.requestId,
chatSessionId: context.chatSessionId,
itemId: params.itemId,
toolName: kind === "command"
? "codex.command"
: kind === "file-change"
? "codex.file_change"
: kind === "permissions"
? "codex.permissions"
: undefined,
args: kind === "command"
? {
command: params.command,
cwd: params.cwd,
reason: params.reason,
commandActions: params.commandActions,
}
: kind === "file-change"
? { reason: params.reason, grantRoot: params.grantRoot, itemId: params.itemId }
: kind === "permissions"
? { cwd: params.cwd, reason: params.reason, permissions: params.permissions }
: undefined,
availableDecisions: kind === "command" && Array.isArray(params.availableDecisions)
? params.availableDecisions
: undefined,
questions: kind === "user-input" ? params.questions || [] : undefined,
autoResolutionMs: kind === "user-input" ? params.autoResolutionMs : undefined,
};
let delivered = false;
try {
delivered = typeof this.sendInteractionRequest === "function"
&& this.sendInteractionRequest(payload, context) !== false;
} catch {
delivered = false;
}
if (!delivered) {
this.#resolveInteraction(interactionId, kind === "user-input" ? { answers: {} } : { decision: "reject" });
}
}
#safeInteractionResponse(kind, params, decision) {
if (kind === "user-input") return { answers: {} };
if (kind === "permissions") {
const granted = decision === "once" || decision === "session"
? normalizeGrantedPermissions(params.permissions)
: {};
return { permissions: granted, scope: decision === "session" ? "session" : "turn" };
}
const mapped = decision === "once"
? "accept"
: decision === "session"
? "acceptForSession"
: decision === "cancel"
? "cancel"
: "decline";
return { decision: mapped };
}
#resolveInteraction(interactionId, response) {
const pending = this.pendingInteractions.get(interactionId);
if (!pending) return false;
this.pendingInteractions.delete(interactionId);
clearTimeout(pending.timer);
try {
const result = pending.kind === "user-input"
? { answers: response?.answers || {} }
: this.#safeInteractionResponse(pending.kind, pending.params, response?.decision || "reject");
try { pending.connection.respond(pending.rpcId, result); } catch {}
} finally {
this.sendInteractionCleared?.({
interactionIds: [interactionId],
chatSessionId: pending.context.chatSessionId,
}, pending.context);
}
return true;
}
respondInteraction(interactionId, response, sender) {
const pending = this.pendingInteractions.get(interactionId);
if (sender && pending?.context?.sender && pending.context.sender !== sender) return false;
return this.#resolveInteraction(interactionId, response);
}
/**
* Drop the idle auto-reject timer after the user starts reviewing an approval card.
* Re-arms the absolute creation deadline so a late approve cannot outlive the
* original timeout window (matches Catty/MCP approval cancel semantics).
*/
cancelInteractionTimeout(interactionId, sender) {
const pending = this.pendingInteractions.get(interactionId);
if (!pending || pending.idleCancelled) return false;
if (sender && pending.context?.sender && pending.context.sender !== sender) return false;
pending.idleCancelled = true;
if (pending.timer) {
clearTimeout(pending.timer);
pending.timer = null;
}
const remainingMs = Math.max(0, (pending.absoluteExpiresAt ?? 0) - Date.now());
if (remainingMs <= 0) {
this.#resolveInteraction(
interactionId,
pending.kind === "user-input" ? { answers: {} } : { decision: "reject" },
);
return true;
}
pending.timer = setTimeout(() => {
this.#resolveInteraction(
interactionId,
pending.kind === "user-input" ? { answers: {} } : { decision: "reject" },
);
}, remainingMs);
return true;
}
#clearInteractionsForContext(context, decision) {
for (const [interactionId, pending] of Array.from(this.pendingInteractions)) {
if (pending.context === context) {
this.#resolveInteraction(
interactionId,
pending.kind === "user-input" ? { answers: {} } : { decision },
);
}
}
}
async cancelTurn(requestId) {
const context = this.activeByRequest.get(requestId);
if (!context) return false;
context.cancelRequested = true;
this.#clearInteractionsForContext(context, "cancel");
await this.#interruptAndSchedule(context);
return true;
}
async cleanupChatSession(chatSessionId) {
const contexts = Array.from(this.activeByRequest.values())
.filter((context) => context.chatSessionId === chatSessionId);
await Promise.all(contexts.map((context) => this.cancelTurn(context.requestId)));
}
#handleConnectionFatal(connectionKey, error) {
const connection = this.connections.get(connectionKey);
if (connection) {
this.connections.delete(connectionKey);
this.#refreshPreferredConnectionKey();
}
const contexts = Array.from(this.activeByRequest.values())
.filter((context) => context.connectionKey === connectionKey);
for (const context of contexts) {
if (context.settled) continue;
context.settled = true;
clearTimeout(context.forceCancelTimer);
context.forceCancelTimer = null;
this.#clearInteractionsForContext(context, "cancel");
context.reject(error);
}
}
#removeContext(context) {
clearTimeout(context.forceCancelTimer);
context.forceCancelTimer = null;
if (context.abortListener && context.signal) {
context.signal.removeEventListener("abort", context.abortListener);
context.abortListener = null;
}
this.activeByRequest.delete(context.requestId);
this.activeByThread.delete(this.#scopedKey(context.connectionKey, context.threadId));
if (context.turnId) this.activeByTurn.delete(this.#scopedKey(context.connectionKey, context.turnId));
this.#closeIdleConnections();
}
close() {
for (const interactionId of Array.from(this.pendingInteractions.keys())) {
this.#resolveInteraction(interactionId, { decision: "cancel", answers: {} });
}
for (const [, connection] of this.connections) connection.close();
this.connections.clear();
this.preferredConnectionKey = null;
for (const context of this.activeByRequest.values()) {
if (!context.settled) {
context.settled = true;
clearTimeout(context.forceCancelTimer);
context.forceCancelTimer = null;
context.reject(new Error("Codex App Server shut down"));
}
}
this.activeByRequest.clear();
this.activeByThread.clear();
this.activeByTurn.clear();
}
}
module.exports = {
CodexAppServerRuntime,
INTERACTION_TIMEOUT_MS,
buildThreadConfig,
buildTurnInput,
getActiveTurnNotSteerableKind,
mapAppServerModels,
normalizeFileChanges,
normalizeGrantedPermissions,
resolveAppServerModelSelection,
resolveCodexPermissionConfig,
stringifyMcpContent,
};

View File

@@ -0,0 +1,850 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
CodexAppServerRuntime,
buildTurnInput,
mapAppServerModels,
normalizeFileChanges,
resolveCodexPermissionConfig,
} = require("./runtime.cjs");
class FakeConnection {
constructor(options) {
this.options = options;
this.requests = [];
this.responses = [];
this.threadId = "thread-1";
this.turnId = "turn-1";
this.closed = false;
}
async start() { return this; }
async request(method, params) {
this.requests.push({ method, params });
if (method === "thread/start" || method === "thread/resume") {
return { thread: { id: this.threadId } };
}
if (method === "turn/start") {
if (this.turnStartGate) await this.turnStartGate;
return { turn: { id: this.turnId } };
}
if (method === "turn/steer") {
if (this.turnSteerGate) await this.turnSteerGate;
if (this.turnSteerError) throw this.turnSteerError;
return { turnId: this.turnId };
}
if (method === "turn/interrupt") {
if (this.turnInterruptGate) await this.turnInterruptGate;
if (this.turnInterruptError) throw this.turnInterruptError;
return {};
}
if (method === "model/list") {
return {
data: [
{
id: "gpt-first",
displayName: "GPT First",
description: "First model in the catalog",
hidden: false,
supportedReasoningEfforts: [{ reasoningEffort: "low" }],
defaultReasoningEffort: "low",
isDefault: false,
},
{
id: "gpt-test",
displayName: "GPT Test",
description: "Server default model",
hidden: false,
supportedReasoningEfforts: [{ reasoningEffort: "low" }, { reasoningEffort: "high" }],
defaultReasoningEffort: "high",
isDefault: true,
},
],
nextCursor: null,
};
}
return {};
}
respond(id, result) { this.responses.push({ id, result }); }
respondError(id, code, message) { this.responses.push({ id, error: { code, message } }); }
notify(message) { this.options.onNotification(message); }
serverRequest(message) { return this.options.onServerRequest(message, this); }
close() { this.closed = true; }
}
function createEmitter() {
const events = [];
return {
events,
emitDone: () => events.push(["done"]),
sessionId: (id) => events.push(["session", id]),
text: (text) => events.push(["text", text]),
reasoning: (text) => events.push(["reasoning", text]),
reasoningEnd: () => events.push(["reasoning-end"]),
toolCall: (name, args, id) => events.push(["tool-call", name, args, id]),
toolResult: (id, output, name) => events.push(["tool-result", id, output, name]),
fileChange: (id, changes, status) => events.push(["file-change", id, changes, status]),
webSearch: (id, query, status) => events.push(["web-search", id, query, status]),
planUpdate: (id, items, status) => events.push(["plan", id, items, status]),
warning: (id, message) => events.push(["warning", id, message]),
usage: (usage) => events.push(["usage", usage]),
};
}
async function waitFor(predicate) {
for (let index = 0; index < 50; index += 1) {
if (predicate()) return;
await new Promise((resolve) => setImmediate(resolve));
}
throw new Error("condition not reached");
}
test("permission modes map to fail-closed Codex policies", () => {
assert.deepEqual(resolveCodexPermissionConfig("observer"), {
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: "read-only",
sandboxPolicy: { type: "readOnly", networkAccess: false },
});
assert.equal(resolveCodexPermissionConfig("confirm").approvalPolicy, "on-request");
assert.equal(resolveCodexPermissionConfig("confirm").sandbox, "read-only");
assert.equal(resolveCodexPermissionConfig("auto").sandbox, "danger-full-access");
});
test("turn input uses text plus local images only", () => {
assert.deepEqual(buildTurnInput("hello", [
{ filePath: "/tmp/a.png", mediaType: "image/png" },
{ filePath: "/tmp/a.txt", mediaType: "text/plain" },
]), [
{ type: "text", text: "hello", text_elements: [] },
{ type: "localImage", path: "/tmp/a.png" },
]);
});
test("runtime maps lifecycle, activities, usage, and retry warnings", async () => {
let connection;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => (connection = new FakeConnection(options)),
});
const emitter = createEmitter();
const run = runtime.runTurn({
requestId: "request-1",
chatSessionId: "chat-1",
prompt: "hello",
cwd: "/repo",
model: "gpt-test/high",
permissionMode: "confirm",
env: { HOME: "/home" },
binPath: "/bin/codex",
injectedMcpServers: [],
emitter,
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
connection.notify({ method: "item/agentMessage/delta", params: { threadId: "thread-1", turnId: "turn-1", itemId: "msg-1", delta: "Hi" } });
connection.notify({ method: "turn/plan/updated", params: { threadId: "thread-1", turnId: "turn-1", plan: [{ step: "Inspect", status: "completed" }] } });
connection.notify({ method: "item/started", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "webSearch", id: "search-1", query: "Netcatty" } } });
connection.notify({ method: "item/completed", params: { threadId: "thread-1", turnId: "turn-1", item: { type: "fileChange", id: "file-1", status: "completed", changes: [{ path: "a.ts", kind: { type: "add" } }] } } });
connection.notify({ method: "thread/tokenUsage/updated", params: { threadId: "thread-1", turnId: "turn-1", tokenUsage: { last: { inputTokens: 10, cachedInputTokens: 2, outputTokens: 3, reasoningOutputTokens: 1, totalTokens: 13 } } } });
connection.notify({ method: "error", params: { threadId: "thread-1", turnId: "turn-1", willRetry: true, error: { message: "network" } } });
connection.notify({ method: "warning", params: { message: "global warning" } });
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
await run;
assert.ok(emitter.events.some((event) => event[0] === "text" && event[1] === "Hi"));
assert.ok(emitter.events.some((event) => event[0] === "plan"));
assert.ok(emitter.events.some((event) => event[0] === "web-search" && event[3] === "running"));
assert.ok(emitter.events.some((event) => event[0] === "file-change" && event[3] === "completed"));
assert.ok(emitter.events.some((event) => event[0] === "usage" && event[1].cachedInputTokens === 2));
assert.ok(emitter.events.some((event) => event[0] === "warning" && /retrying/.test(event[2])));
assert.ok(emitter.events.some((event) => event[0] === "warning" && event[2] === "global warning"));
assert.ok(emitter.events.some((event) => event[0] === "done"));
});
test("runtime bounds command output and avoids replaying streamed message prefixes", async () => {
let connection;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => (connection = new FakeConnection(options)),
});
const emitter = createEmitter();
const run = runtime.runTurn({
requestId: "request-bounded-output",
chatSessionId: "chat-bounded-output",
prompt: "run",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter,
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
connection.notify({ method: "item/agentMessage/delta", params: {
threadId: "thread-1", turnId: "turn-1", itemId: "msg-bounded", delta: "Hello",
} });
connection.notify({ method: "item/completed", params: {
threadId: "thread-1", turnId: "turn-1",
item: { type: "agentMessage", id: "msg-bounded", text: "Hello world" },
} });
connection.notify({ method: "item/commandExecution/outputDelta", params: {
threadId: "thread-1", turnId: "turn-1", itemId: "cmd-bounded",
delta: "x".repeat(1024 * 1024 + 1024),
} });
connection.notify({ method: "item/completed", params: {
threadId: "thread-1", turnId: "turn-1",
item: { type: "commandExecution", id: "cmd-bounded", command: "large", exitCode: 0 },
} });
connection.notify({ method: "turn/completed", params: {
threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null },
} });
await run;
assert.deepEqual(
emitter.events.filter((event) => event[0] === "text"),
[["text", "Hello"], ["text", " world"]],
);
const toolResult = emitter.events.find((event) => event[0] === "tool-result");
assert.ok(toolResult);
assert.ok(toolResult[2].length < 1024 * 1024 + 200);
assert.match(toolResult[2], /output truncated: 1049600 characters total/);
assert.match(toolResult[2], /\[exit code: 0\]$/);
});
test("runtime delegates injected MCP approvals to Netcatty's policy gate", async () => {
let connection;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => (connection = new FakeConnection(options)),
});
const run = runtime.runTurn({
requestId: "request-mcp-policy",
chatSessionId: "chat-mcp-policy",
prompt: "inspect the terminal",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [{
name: "netcatty-remote-hosts",
command: "/abs/electron",
args: ["/abs/server.cjs"],
env: [{ name: "NETCATTY_MCP_PERMISSION_MODE", value: "confirm" }],
}],
emitter: createEmitter(),
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
const threadStart = connection.requests.find((request) => request.method === "thread/start");
assert.deepEqual(threadStart.params.config.mcp_servers["netcatty-remote-hosts"], {
command: "/abs/electron",
args: ["/abs/server.cjs"],
env: { NETCATTY_MCP_PERMISSION_MODE: "confirm" },
default_tools_approval_mode: "approve",
});
connection.notify({
method: "turn/completed",
params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } },
});
await run;
});
test("runtime routes native approvals and request_user_input responses", async () => {
let connection;
let interaction;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => (connection = new FakeConnection(options)),
sendInteractionRequest: (payload) => { interaction = payload; return true; },
});
const run = runtime.runTurn({
requestId: "request-2",
chatSessionId: "chat-2",
prompt: "change it",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
await connection.serverRequest({
id: 70,
method: "item/commandExecution/requestApproval",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-1",
command: "npm test",
cwd: "/repo",
availableDecisions: ["accept", "acceptForSession", "decline", "cancel"],
},
});
assert.equal(interaction.kind, "command");
assert.deepEqual(interaction.availableDecisions, ["accept", "acceptForSession", "decline", "cancel"]);
runtime.respondInteraction(interaction.interactionId, { decision: "session" });
assert.deepEqual(connection.responses.at(-1), { id: 70, result: { decision: "acceptForSession" } });
await connection.serverRequest({
id: 72,
method: "item/permissions/requestApproval",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "permissions-1",
permissions: { network: { enabled: true }, fileSystem: null },
cwd: "/repo",
},
});
runtime.respondInteraction(interaction.interactionId, { decision: "once" });
assert.deepEqual(connection.responses.at(-1), {
id: 72,
result: { permissions: { network: { enabled: true } }, scope: "turn" },
});
await connection.serverRequest({
id: 71,
method: "item/tool/requestUserInput",
params: { threadId: "thread-1", turnId: "turn-1", itemId: "question-1", questions: [{ id: "choice", question: "Choose", header: "Mode", isOther: true, isSecret: false, options: null }] },
});
runtime.respondInteraction(interaction.interactionId, { answers: { choice: { answers: ["safe"] } } });
assert.deepEqual(connection.responses.at(-1), { id: 71, result: { answers: { choice: { answers: ["safe"] } } } });
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
await run;
});
test("cancelInteractionTimeout re-arms the absolute approval deadline (Catty/MCP style)", async () => {
let connection;
let interaction;
const realNow = Date.now;
let now = 5_000_000;
Date.now = () => now;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => (connection = new FakeConnection(options)),
sendInteractionRequest: (payload) => { interaction = payload; return true; },
});
try {
const run = runtime.runTurn({
requestId: "request-timeout-cancel",
chatSessionId: "chat-timeout-cancel",
prompt: "ask",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
await connection.serverRequest({
id: 90,
method: "item/tool/requestUserInput",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "question-timeout",
questions: [{ id: "choice", question: "Choose", header: "Mode", isOther: true, isSecret: false, options: null }],
autoResolutionMs: 100,
},
});
assert.ok(interaction?.interactionId);
// Jump close to the absolute ceiling, then cancel idle. Remaining absolute ~30ms.
now += 70;
assert.equal(runtime.cancelInteractionTimeout(interaction.interactionId), true);
assert.equal(runtime.cancelInteractionTimeout(interaction.interactionId), false);
await new Promise((resolve) => setTimeout(resolve, 15));
assert.equal(
connection.responses.some((response) => response.id === 90),
false,
"must stay pending before absolute expiry",
);
await new Promise((resolve) => setTimeout(resolve, 80));
assert.equal(
connection.responses.some((response) => response.id === 90),
true,
"absolute deadline must auto-reject after remaining time elapses",
);
assert.deepEqual(connection.responses.at(-1), {
id: 90,
result: { answers: {} },
});
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
await run;
} finally {
Date.now = realNow;
}
});
test("cancelInteractionTimeout still allows explicit approve before absolute expiry", async () => {
let connection;
let interaction;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => (connection = new FakeConnection(options)),
sendInteractionRequest: (payload) => { interaction = payload; return true; },
});
const run = runtime.runTurn({
requestId: "request-timeout-approve",
chatSessionId: "chat-timeout-approve",
prompt: "ask",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
await connection.serverRequest({
id: 91,
method: "item/commandExecution/requestApproval",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-approve",
command: "echo ok",
cwd: "/tmp",
reason: "demo",
},
});
assert.ok(interaction?.interactionId);
assert.equal(runtime.cancelInteractionTimeout(interaction.interactionId), true);
assert.equal(runtime.respondInteraction(interaction.interactionId, { decision: "once" }), true);
assert.deepEqual(connection.responses.at(-1), {
id: 91,
result: { decision: "accept" },
});
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
await run;
});
test("runtime steers the active turn with text, local images, and a stable user message id", async () => {
let connection;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => (connection = new FakeConnection(options)),
});
const run = runtime.runTurn({
requestId: "request-steer",
chatSessionId: "chat-steer",
prompt: "initial",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
const result = await runtime.steerTurn("request-steer", {
chatSessionId: "chat-steer",
prompt: "use this image",
attachments: [
{ filePath: "/tmp/image.png", mediaType: "image/png" },
{ filePath: "/tmp/notes.txt", mediaType: "text/plain" },
],
clientUserMessageId: "user-steer-1",
});
assert.deepEqual(result, { status: "accepted" });
assert.deepEqual(connection.requests.find((request) => request.method === "turn/steer"), {
method: "turn/steer",
params: {
threadId: "thread-1",
expectedTurnId: "turn-1",
input: [
{ type: "text", text: "use this image", text_elements: [] },
{ type: "localImage", path: "/tmp/image.png" },
],
clientUserMessageId: "user-steer-1",
},
});
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
await run;
});
test("runtime serializes steering and classifies non-steerable turns", async () => {
let connection;
let releaseSteer;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => {
connection = new FakeConnection(options);
connection.turnSteerGate = new Promise((resolve) => { releaseSteer = resolve; });
return connection;
},
});
const run = runtime.runTurn({
requestId: "request-steer-busy",
chatSessionId: "chat-steer-busy",
prompt: "initial",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
const first = runtime.steerTurn("request-steer-busy", {
chatSessionId: "chat-steer-busy",
prompt: "first",
clientUserMessageId: "user-first",
});
await waitFor(() => connection.requests.some((request) => request.method === "turn/steer"));
assert.equal((await runtime.steerTurn("request-steer-busy", {
chatSessionId: "chat-steer-busy",
prompt: "second",
clientUserMessageId: "user-second",
})).status, "busy");
releaseSteer();
assert.equal((await first).status, "accepted");
const error = new Error("active turn cannot be steered");
error.data = { activeTurnNotSteerable: { turnKind: "review" } };
connection.turnSteerError = error;
const rejected = await runtime.steerTurn("request-steer-busy", {
chatSessionId: "chat-steer-busy",
prompt: "review change",
clientUserMessageId: "user-review",
});
assert.deepEqual(rejected, {
status: "not-steerable",
turnKind: "review",
message: "active turn cannot be steered",
});
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
await run;
});
test("stop during steering cancels the UI result without creating a replacement turn", async () => {
let connection;
let releaseSteer;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => {
connection = new FakeConnection(options);
connection.turnSteerGate = new Promise((resolve) => { releaseSteer = resolve; });
return connection;
},
});
const run = runtime.runTurn({
requestId: "request-steer-stop",
chatSessionId: "chat-steer-stop",
prompt: "initial",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
const steer = runtime.steerTurn("request-steer-stop", {
chatSessionId: "chat-steer-stop",
prompt: "too late",
clientUserMessageId: "user-steer-stop",
});
await waitFor(() => connection.requests.some((request) => request.method === "turn/steer"));
assert.equal(await runtime.cancelTurn("request-steer-stop"), true);
releaseSteer();
assert.equal((await steer).status, "cancelled");
assert.equal(connection.requests.filter((request) => request.method === "turn/start").length, 1);
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "interrupted", error: null } } });
await run;
});
test("stop requested while turn/start is pending interrupts the assigned turn", async () => {
let connection;
let releaseTurnStart;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => {
connection = new FakeConnection(options);
connection.turnStartGate = new Promise((resolve) => { releaseTurnStart = resolve; });
return connection;
},
});
const emitter = createEmitter();
const run = runtime.runTurn({
requestId: "request-stop",
chatSessionId: "chat-stop",
prompt: "wait",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter,
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
assert.equal(await runtime.cancelTurn("request-stop"), true);
assert.equal(connection.requests.some((request) => request.method === "turn/interrupt"), false);
releaseTurnStart();
await waitFor(() => connection.requests.some((request) => request.method === "turn/interrupt"));
connection.notify({
method: "turn/completed",
params: { threadId: "thread-1", turn: { id: "turn-1", status: "interrupted", error: null } },
});
await run;
assert.equal(connection.requests.filter((request) => request.method === "turn/interrupt").length, 1);
assert.ok(emitter.events.some((event) => event[0] === "done"));
});
test("failed interrupt force-settles the turn and releases its connection", async () => {
let connection;
const runtime = new CodexAppServerRuntime({
interruptGraceMs: 5,
connectionFactory: (options) => {
connection = new FakeConnection(options);
connection.turnInterruptError = new Error("interrupt unavailable");
return connection;
},
});
const run = runtime.runTurn({
requestId: "request-interrupt-failure",
chatSessionId: "chat-interrupt-failure",
prompt: "wait",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
assert.equal(await runtime.cancelTurn("request-interrupt-failure"), true);
await Promise.race([
run,
new Promise((_, reject) => setTimeout(() => reject(new Error("turn did not settle")), 50)),
]);
assert.equal(connection.closed, true);
assert.equal(runtime.activeByRequest.size, 0);
assert.equal(runtime.activeByThread.size, 0);
assert.equal(runtime.activeByTurn.size, 0);
});
test("hung interrupt request times out and force-settles the turn", async () => {
let connection;
const runtime = new CodexAppServerRuntime({
interruptRequestTimeoutMs: 5,
interruptGraceMs: 5,
connectionFactory: (options) => {
connection = new FakeConnection(options);
connection.turnInterruptGate = new Promise(() => {});
return connection;
},
});
const run = runtime.runTurn({
requestId: "request-interrupt-hung",
chatSessionId: "chat-interrupt-hung",
prompt: "wait",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
assert.equal(await runtime.cancelTurn("request-interrupt-hung"), true);
await Promise.race([
run,
new Promise((_, reject) => setTimeout(() => reject(new Error("turn did not settle")), 50)),
]);
assert.equal(connection.closed, true);
assert.equal(runtime.activeByRequest.size, 0);
});
test("acknowledged interrupt force-settles when completion never arrives", async () => {
let connection;
const runtime = new CodexAppServerRuntime({
interruptGraceMs: 5,
connectionFactory: (options) => (connection = new FakeConnection(options)),
});
const run = runtime.runTurn({
requestId: "request-interrupt-no-completion",
chatSessionId: "chat-interrupt-no-completion",
prompt: "wait",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
assert.equal(await runtime.cancelTurn("request-interrupt-no-completion"), true);
await Promise.race([
run,
new Promise((_, reject) => setTimeout(() => reject(new Error("turn did not settle")), 50)),
]);
assert.equal(connection.closed, true);
assert.equal(runtime.activeByRequest.size, 0);
});
test("idle superseded app-server connections close when their active turn finishes", async () => {
const connections = [];
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => {
const connection = new FakeConnection(options);
connection.threadId = `thread-${connections.length + 1}`;
connection.turnId = `turn-${connections.length + 1}`;
connections.push(connection);
return connection;
},
});
const firstRun = runtime.runTurn({
requestId: "request-config-a",
chatSessionId: "chat-config-a",
prompt: "first",
permissionMode: "confirm",
env: { PROFILE: "a" },
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connections[0]?.requests.some((request) => request.method === "turn/start"));
const secondRun = runtime.runTurn({
requestId: "request-config-b",
chatSessionId: "chat-config-b",
prompt: "second",
permissionMode: "confirm",
env: { PROFILE: "b" },
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connections[1]?.requests.some((request) => request.method === "turn/start"));
connections[0].notify({
method: "turn/completed",
params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } },
});
await firstRun;
assert.equal(connections[0].closed, true);
assert.equal(connections[1].closed, false);
connections[1].notify({
method: "turn/completed",
params: { threadId: "thread-2", turn: { id: "turn-2", status: "completed", error: null } },
});
await secondRun;
assert.equal(connections[1].closed, false);
runtime.close();
});
test("force-cancelling the preferred connection keeps another active connection reusable", async () => {
const connections = [];
const runtime = new CodexAppServerRuntime({
interruptGraceMs: 5,
connectionFactory: (options) => {
const connection = new FakeConnection(options);
connection.threadId = `thread-reuse-${connections.length + 1}`;
connection.turnId = `turn-reuse-${connections.length + 1}`;
connections.push(connection);
return connection;
},
});
const firstRun = runtime.runTurn({
requestId: "request-reuse-a",
chatSessionId: "chat-reuse-a",
prompt: "first",
permissionMode: "confirm",
env: { PROFILE: "reuse-a" },
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connections[0]?.requests.some((request) => request.method === "turn/start"));
const secondRun = runtime.runTurn({
requestId: "request-reuse-b",
chatSessionId: "chat-reuse-b",
prompt: "second",
permissionMode: "confirm",
env: { PROFILE: "reuse-b" },
binPath: "/bin/codex",
injectedMcpServers: [],
emitter: createEmitter(),
});
await waitFor(() => connections[1]?.requests.some((request) => request.method === "turn/start"));
assert.equal(await runtime.cancelTurn("request-reuse-b"), true);
await secondRun;
connections[0].notify({
method: "turn/completed",
params: {
threadId: "thread-reuse-1",
turn: { id: "turn-reuse-1", status: "completed", error: null },
},
});
await firstRun;
assert.equal(connections[0].closed, false);
runtime.close();
});
test("unsupported requests fail immediately and warn without hanging", async () => {
let connection;
const emitter = createEmitter();
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => (connection = new FakeConnection(options)),
});
const run = runtime.runTurn({
requestId: "request-unsupported",
chatSessionId: "chat-unsupported",
prompt: "hello",
permissionMode: "confirm",
env: {},
binPath: "/bin/codex",
injectedMcpServers: [],
emitter,
});
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
await connection.serverRequest({
id: 99,
method: "item/unknown/requestApproval",
params: { threadId: "thread-1", turnId: "turn-1" },
});
assert.deepEqual(connection.responses.at(-1), {
id: 99,
error: { code: -32601, message: "Unsupported Codex App Server request: item/unknown/requestApproval" },
});
assert.ok(emitter.events.some((event) => event[0] === "warning"));
await connection.serverRequest({
id: 100,
method: "item/commandExecution/requestApproval",
params: { threadId: "thread-1", turnId: "turn-1", itemId: "cmd-no-renderer", command: "rm -rf /" },
});
assert.deepEqual(connection.responses.at(-1), { id: 100, result: { decision: "decline" } });
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
await run;
});
test("model and file-change normalization preserve UI contract", async () => {
assert.deepEqual(normalizeFileChanges([
{ path: "a", kind: { type: "add" } },
{ path: "b", kind: { type: "delete" } },
{ path: "c", kind: { type: "update", move_path: null } },
]), [
{ path: "a", kind: "add" },
{ path: "b", kind: "delete" },
{ path: "c", kind: "update" },
]);
assert.equal(mapAppServerModels([{ id: "hidden", hidden: true }]).length, 0);
let connection;
const runtime = new CodexAppServerRuntime({
connectionFactory: (options) => (connection = new FakeConnection(options)),
});
const catalog = await runtime.listModels({ binPath: "/bin/codex", env: {} });
assert.equal(connection.requests[0].method, "model/list");
assert.equal(catalog.currentModelId, "gpt-test/high");
assert.equal(catalog.models[0].id, "gpt-first");
assert.deepEqual(catalog.models[1].thinkingLevels, ["low", "high"]);
assert.equal(catalog.models[1].defaultThinkingLevel, "high");
});

View File

@@ -0,0 +1,124 @@
"use strict";
/**
* Windows launch helper for the Cursor Agent installer shim.
*
* `%LOCALAPPDATA%\cursor-agent\cursor-agent.cmd` is a batch file that runs
* `versions\<id>\node.exe` + `versions\<id>\index.js`. Node cannot spawn .cmd
* directly (EINVAL). Routing the full turn prompt through cmd.exe is also
* unsafe: 8191-char limit, %VAR% expansion, and broken quote escaping.
*
* Prefer the native node+script argv so prompts stay out of a shell.
*/
const fs = require("node:fs");
const path = require("node:path");
const { prepareCommandForSpawn } = require("../ai/shellUtils.cjs");
function defaultExists(filePath) {
try { return fs.existsSync(filePath); } catch { return false; }
}
function defaultReadFile(filePath) {
return fs.readFileSync(filePath, "utf8");
}
function defaultReaddir(dirPath) {
return fs.readdirSync(dirPath);
}
function defaultStat(filePath) {
return fs.statSync(filePath);
}
function expandWindowsShimPath(raw, shimDir) {
const dp0 = /[\\/]$/.test(shimDir) ? shimDir : `${shimDir}${path.sep}`;
let resolved = String(raw || "").replace(/%~dp0/gi, dp0);
// Installer shims use Windows separators; normalize so existsSync works
// when this helper is unit-tested on POSIX.
resolved = resolved.replace(/\\/g, path.sep);
if (!path.isAbsolute(resolved) && !path.win32.isAbsolute(resolved)) {
resolved = path.resolve(shimDir, resolved);
}
return path.normalize(resolved);
}
function parseCursorAgentCmdLaunch(shimPath, { exists, readFile } = {}) {
const existsFn = exists || defaultExists;
const readFn = readFile || defaultReadFile;
let contents;
try {
contents = readFn(shimPath);
} catch {
return null;
}
const match = String(contents || "").match(/"([^"\r\n]*node\.exe)"\s+"([^"\r\n]*index\.js)"/i);
if (!match) return null;
const shimDir = path.dirname(shimPath);
const nodeExe = expandWindowsShimPath(match[1], shimDir);
const script = expandWindowsShimPath(match[2], shimDir);
if (!existsFn(nodeExe) || !existsFn(script)) return null;
return { nodeExe, script };
}
function resolveCursorAgentVersionsLaunch(installDir, { exists, readdir, stat } = {}) {
const existsFn = exists || defaultExists;
const readdirFn = readdir || defaultReaddir;
const statFn = stat || defaultStat;
const versionsDir = path.join(installDir, "versions");
if (!existsFn(versionsDir)) return null;
let names;
try {
names = readdirFn(versionsDir);
} catch {
return null;
}
const candidates = [];
for (const name of names) {
const dir = path.join(versionsDir, name);
const nodeExe = path.join(dir, "node.exe");
const script = path.join(dir, "index.js");
if (!existsFn(nodeExe) || !existsFn(script)) continue;
let mtime = 0;
try { mtime = Number(statFn(dir)?.mtimeMs) || 0; } catch { /* ignore */ }
candidates.push({ name: String(name), nodeExe, script, mtime });
}
if (candidates.length === 0) return null;
candidates.sort((a, b) => b.mtime - a.mtime || b.name.localeCompare(a.name));
return { nodeExe: candidates[0].nodeExe, script: candidates[0].script };
}
function resolveCursorAgentNativeLaunch(binPath, io = {}) {
const normalized = String(binPath || "").trim();
if (!normalized) return null;
const ext = path.extname(normalized).toLowerCase();
if (ext !== ".cmd" && ext !== ".bat") return null;
const fromShim = parseCursorAgentCmdLaunch(normalized, io);
if (fromShim) return fromShim;
return resolveCursorAgentVersionsLaunch(path.dirname(normalized), io);
}
function resolveCursorCliSpawnSpec(binPath, args, io = {}) {
const command = String(binPath || "").trim();
const spawnArgs = Array.isArray(args) ? args : [];
const native = resolveCursorAgentNativeLaunch(command, io);
if (native) {
return {
command: native.nodeExe,
args: [native.script, ...spawnArgs],
shell: false,
};
}
// Last resort for unknown shims / short probes (status, models). Turns with
// a long prompt should have resolved the official versions/ layout above.
return prepareCommandForSpawn(command, spawnArgs, { unwrapNativeExe: false });
}
module.exports = {
expandWindowsShimPath,
parseCursorAgentCmdLaunch,
resolveCursorAgentNativeLaunch,
resolveCursorAgentVersionsLaunch,
resolveCursorCliSpawnSpec,
};

View File

@@ -0,0 +1,107 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { prepareCommandForSpawn } = require("../ai/shellUtils.cjs");
const {
expandWindowsShimPath,
parseCursorAgentCmdLaunch,
resolveCursorAgentNativeLaunch,
resolveCursorAgentVersionsLaunch,
resolveCursorCliSpawnSpec,
} = require("./cursorCliSpawn.cjs");
function writeCursorAgentInstall(root, version = "2026.06.01-abc") {
const versionDir = path.join(root, "versions", version);
fs.mkdirSync(versionDir, { recursive: true });
const nodeExe = path.join(versionDir, "node.exe");
const script = path.join(versionDir, "index.js");
fs.writeFileSync(nodeExe, "", "utf8");
fs.writeFileSync(script, "", "utf8");
const shimPath = path.join(root, "cursor-agent.cmd");
fs.writeFileSync(
shimPath,
`@ECHO off\r\n"%~dp0\\versions\\${version}\\node.exe" "%~dp0\\versions\\${version}\\index.js" %*\r\n`,
"utf8",
);
return { shimPath, nodeExe, script, versionDir };
}
test("expandWindowsShimPath expands %~dp0 relative to the shim directory", () => {
const shimDir = path.join("C:", "Users", "me", "AppData", "Local", "cursor-agent");
const resolved = expandWindowsShimPath("%~dp0\\versions\\2026.06.01-abc\\node.exe", shimDir);
assert.equal(
path.normalize(resolved),
path.normalize(path.join(shimDir, "versions", "2026.06.01-abc", "node.exe")),
);
});
test("parseCursorAgentCmdLaunch reads node.exe + index.js from the installer shim", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-shim-"));
try {
const { shimPath, nodeExe, script } = writeCursorAgentInstall(tmp);
const launch = parseCursorAgentCmdLaunch(shimPath);
assert.deepEqual(launch, { nodeExe, script });
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCursorAgentVersionsLaunch picks the newest version directory", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-versions-"));
try {
const older = writeCursorAgentInstall(tmp, "2026.01.01-old");
const newer = writeCursorAgentInstall(tmp, "2026.08.01-new");
const olderTime = new Date("2026-01-01T00:00:00Z");
const newerTime = new Date("2026-08-01T00:00:00Z");
fs.utimesSync(older.versionDir, olderTime, olderTime);
fs.utimesSync(newer.versionDir, newerTime, newerTime);
const launch = resolveCursorAgentVersionsLaunch(tmp);
assert.deepEqual(launch, { nodeExe: newer.nodeExe, script: newer.script });
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCursorAgentNativeLaunch prefers the shim's node+script over a lone exe unwrap", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-native-"));
try {
const { shimPath, nodeExe, script } = writeCursorAgentInstall(tmp);
const launch = resolveCursorAgentNativeLaunch(shimPath);
assert.deepEqual(launch, { nodeExe, script });
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCursorCliSpawnSpec puts the prompt on argv, not a cmd.exe line", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-spawn-"));
try {
const { shimPath, nodeExe, script } = writeCursorAgentInstall(tmp);
const prompt = 'do "%USERPROFILE%" and `whoami` then say hello';
const spec = resolveCursorCliSpawnSpec(shimPath, ["--print", "--trust", prompt]);
assert.deepEqual(spec, {
command: nodeExe,
args: [script, "--print", "--trust", prompt],
shell: false,
});
assert.equal(spec.command.includes("cmd.exe"), false);
assert.equal(spec.args.includes(prompt), true);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCursorCliSpawnSpec falls back to the cmd shim when versions/ is missing", () => {
const shim = "C:\\Users\\me\\AppData\\Local\\cursor-agent\\cursor-agent.cmd";
const args = ["status", "--format", "json"];
const spec = resolveCursorCliSpawnSpec(shim, args, {
exists: () => false,
readFile: () => { throw new Error("missing"); },
});
assert.deepEqual(spec, prepareCommandForSpawn(shim, args, { unwrapNativeExe: false }));
});

View File

@@ -0,0 +1,514 @@
/* eslint-disable no-undef */
const { existsSync } = require("node:fs");
function registerProviderHandlers(ctx) {
with (ctx) {
ipcMain.handle("netcatty:ai:user-skills:status", async (event) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const status = await scanUserSkills(electronModule?.app);
return { ok: true, ...toPublicUserSkillsStatus(status) };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
ipcMain.handle("netcatty:ai:user-skills:open", async (event) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const status = await scanUserSkills(electronModule?.app);
const openResult = await electronModule?.shell?.openPath?.(status.directoryPath);
return {
ok: !openResult,
error: openResult || undefined,
...toPublicUserSkillsStatus(status),
};
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
ipcMain.handle("netcatty:ai:user-skills:build-context", async (event, { prompt, selectedSkillSlugs }) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const { context, status } = await buildUserSkillsContext(electronModule?.app, prompt, selectedSkillSlugs);
return { ok: true, context, status: toPublicUserSkillsStatus(status) };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
ipcMain.handle("netcatty:ai:skills-cli:invocation", async (event) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const invocation = getSkillsCliInvocation();
return {
ok: true,
skillPath: existsSync(NETCATTY_TOOL_SKILL_PATH) ? NETCATTY_TOOL_SKILL_PATH : null,
commandPrefix: invocation.commandPrefix,
launcherPath: invocation.launcherPath,
usesLauncher: invocation.usesLauncher,
};
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
// ── Provider config sync (renderer → main, keys stay encrypted) ──
ipcMain.handle("netcatty:ai:sync-providers", async (event, { providers }) => {
if (!validateSenderOrSettings(event)) return { ok: false };
if (Array.isArray(providers)) {
providerConfigs = providers;
rebuildProviderFetchHosts();
}
return { ok: true };
});
// ── Web search config sync (renderer → main, for fetch allowlist + key decryption) ──
ipcMain.handle("netcatty:ai:sync-web-search", async (event, { apiHost, apiKey }) => {
if (!validateSenderOrSettings(event)) return { ok: false };
webSearchApiHost = typeof apiHost === "string" ? apiHost : null;
webSearchApiKeyEncrypted = typeof apiKey === "string" ? apiKey : null;
rebuildProviderFetchHosts();
return { ok: true };
});
/**
* Inject the decrypted web search API key into request headers.
* Replaces __WEB_SEARCH_KEY__ placeholder, similar to __IPC_SECURED__ for providers.
*/
function injectWebSearchKeyIntoHeaders(headers) {
if (!webSearchApiKeyEncrypted || !headers) return headers;
const realKey = decryptApiKeyValue(webSearchApiKeyEncrypted);
if (!realKey) return headers;
const patched = {};
for (const [k, v] of Object.entries(headers)) {
patched[k] = typeof v === "string" ? v.replace(WEB_SEARCH_KEY_PLACEHOLDER, realKey) : v;
}
return patched;
}
// Temporarily add a host to the fetch allowlist (used by settings model listing).
// Entries are auto-removed after 30 seconds unless they belong to a synced provider.
const TEMP_ALLOWLIST_TTL = 30_000;
// Track temporarily added entries so cleanup can distinguish them from synced ones
const tempAllowedHosts = new Set();
const tempAllowedPorts = new Set();
// Track temporarily added HTTP hosts (for rebuild restoration)
const tempHttpHosts = new Set();
// Track active expiry timers per host to avoid duplicate/premature expiry
const hostExpiryTimers = new Map();
/** Check if a host is owned by a currently synced provider config */
function isHostInProviderConfigs(host) {
for (const config of providerConfigs) {
if (!config.baseURL) continue;
try { if (new URL(config.baseURL).hostname === host) return true; } catch {}
}
return false;
}
/** Check if a host is owned by a provider config that uses http:// */
function isHttpHostInProviderConfigs(host) {
for (const config of providerConfigs) {
if (!config.baseURL) continue;
try {
const p = new URL(config.baseURL);
if (p.hostname === host && p.protocol === "http:") return true;
} catch {}
}
return false;
}
/** Check if a localhost port is owned by a currently synced provider config */
function isPortInProviderConfigs(port) {
for (const config of providerConfigs) {
if (!config.baseURL) continue;
try {
const p = new URL(config.baseURL);
if ((p.hostname === "localhost" || p.hostname === "127.0.0.1") &&
Number(p.port || (p.protocol === "https:" ? 443 : 80)) === port) return true;
} catch {}
}
return false;
}
ipcMain.handle("netcatty:ai:allowlist:add-host", async (event, { baseURL }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
if (typeof baseURL !== "string") return { ok: false, error: "baseURL must be a string" };
try {
const parsed = new URL(baseURL);
const host = parsed.hostname;
if (host === "localhost" || host === "127.0.0.1") {
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
if (!ALLOWED_LOCALHOST_PORTS.has(port)) {
ALLOWED_LOCALHOST_PORTS.add(port);
tempAllowedPorts.add(port);
setTimeout(() => {
// Only remove if still temporary (not built-in and not synced by a provider)
if (!BUILTIN_LOCALHOST_PORTS.includes(port) && !isPortInProviderConfigs(port)) {
ALLOWED_LOCALHOST_PORTS.delete(port);
}
tempAllowedPorts.delete(port);
}, TEMP_ALLOWLIST_TTL);
}
} else {
const isNewHost = !providerFetchHosts.has(host);
if (isNewHost) {
providerFetchHosts.add(host);
}
// Always track in tempAllowedHosts so rebuild can restore to providerFetchHosts
// even if the original persistent source (e.g. HTTPS provider) is removed mid-TTL
tempAllowedHosts.add(host);
if (parsed.protocol === "http:") {
providerHttpHosts.add(host);
if (!isHttpHostInProviderConfigs(host)) tempHttpHosts.add(host);
}
// Always (re-)schedule expiry timer to clean up temp entries
const existing = hostExpiryTimers.get(host);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
hostExpiryTimers.delete(host);
// Check if host is still needed by a provider config or web search
const isWebSearchHost = webSearchApiHost && (() => {
try { return new URL(webSearchApiHost).hostname === host; } catch { return false; }
})();
if (!isHostInProviderConfigs(host) && !isWebSearchHost) {
providerFetchHosts.delete(host);
providerHttpHosts.delete(host);
} else if (!isHttpHostInProviderConfigs(host)) {
providerHttpHosts.delete(host);
}
tempAllowedHosts.delete(host);
tempHttpHosts.delete(host);
}, TEMP_ALLOWLIST_TTL);
hostExpiryTimers.set(host, timer);
}
return { ok: true };
} catch {
return { ok: false, error: "Invalid URL" };
}
});
// URL allowlist: only permit requests to known AI provider domains + HTTPS
const BUILTIN_FETCH_HOSTS = new Set([
"api.openai.com",
"api.anthropic.com",
"generativelanguage.googleapis.com",
"openrouter.ai",
// Web search providers
"api.tavily.com",
"api.exa.ai",
"api.bochaai.com",
"open.bigmodel.cn",
]);
// Dynamically populated from configured provider baseURLs
const providerFetchHosts = new Set();
// Subset of providerFetchHosts where the provider baseURL explicitly uses http://
const providerHttpHosts = new Set();
/**
* Rebuild the dynamic host allowlist from the current providerConfigs.
* Called whenever providers are synced from the renderer.
*/
function rebuildProviderFetchHosts() {
providerFetchHosts.clear();
providerHttpHosts.clear();
// Reset localhost ports to built-in defaults, then add provider-configured ones
ALLOWED_LOCALHOST_PORTS.clear();
for (const port of BUILTIN_LOCALHOST_PORTS) ALLOWED_LOCALHOST_PORTS.add(port);
// Re-add any still-active temporary entries so a sync doesn't wipe them
for (const host of tempAllowedHosts) providerFetchHosts.add(host);
for (const host of tempHttpHosts) providerHttpHosts.add(host);
for (const port of tempAllowedPorts) ALLOWED_LOCALHOST_PORTS.add(port);
for (const config of providerConfigs) {
if (!config.baseURL) continue;
try {
const parsed = new URL(config.baseURL);
const host = parsed.hostname;
// Skip localhost — handled separately via port allowlist
if (host === "localhost" || host === "127.0.0.1") {
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
ALLOWED_LOCALHOST_PORTS.add(port);
} else {
providerFetchHosts.add(host);
if (parsed.protocol === "http:") providerHttpHosts.add(host);
}
} catch {
// Invalid URL in config — skip
}
}
// Add web search apiHost if configured (e.g. SearXNG self-hosted instance)
if (webSearchApiHost) {
try {
const parsed = new URL(webSearchApiHost);
const host = parsed.hostname;
if (host === "localhost" || host === "127.0.0.1") {
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
ALLOWED_LOCALHOST_PORTS.add(port);
} else {
providerFetchHosts.add(host);
}
} catch {}
}
}
// Allowed localhost ports to prevent SSRF (Issue #9)
const BUILTIN_LOCALHOST_PORTS = [
11434, // Ollama default
1234, // LM Studio default
3000, // Common local dev
3001, // Common local dev
5000, // Common local dev
5001, // Common local dev
8000, // Common local dev
8080, // Common local dev
8888, // Common local dev
];
const ALLOWED_LOCALHOST_PORTS = new Set(BUILTIN_LOCALHOST_PORTS);
// RFC1918 / link-local / loopback / IPv6 private ranges — used by SSRF guard
function isPrivateIp(ip) {
if (!ip) return false;
// Strip IPv6 brackets that URL.hostname may include
const cleaned = ip.replace(/^\[|\]$/g, "");
if (cleaned === "::1" || cleaned === "0.0.0.0" || cleaned === "::") return true;
// IPv6 private ranges: fc00::/7 (unique local), fe80::/10 (link-local), ::ffff:127.x (mapped loopback)
const lower = cleaned.toLowerCase();
if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // fc00::/7
if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true; // fe80::/10
if (lower.startsWith("::ffff:")) {
// IPv4-mapped IPv6 — extract IPv4 portion and check
const v4 = lower.slice(7);
return isPrivateIp(v4);
}
// IPv4
const parts = cleaned.split(".");
if (parts.length === 4 && parts.every(p => /^\d+$/.test(p))) {
const [a, b] = parts.map(Number);
if (a === 10) return true; // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
if (a === 192 && b === 168) return true; // 192.168.0.0/16
if (a === 127) return true; // 127.0.0.0/8
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT (Tailscale etc.)
if (a === 0) return true; // 0.0.0.0/8
}
return false;
}
function isPrivateHost(hostname) {
if (hostname === "localhost") return true;
// metadata endpoints (AWS, GCP, Azure)
if (hostname === "metadata.google.internal") return true;
return isPrivateIp(hostname);
}
function isAllowedFetchUrl(urlString, skipHostCheck) {
try {
const parsed = new URL(urlString);
// Always block private/internal hosts when skipHostCheck is set (SSRF protection)
if (skipHostCheck) {
if (isPrivateHost(parsed.hostname)) return false;
// Require HTTPS for skipHostCheck requests
if (parsed.protocol !== "https:") return false;
return true;
}
// Allow localhost/127.0.0.1 only on known ports (e.g. Ollama) — normal fetch path only
if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") {
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
return ALLOWED_LOCALHOST_PORTS.has(port);
}
// Only allow http: and https: schemes for remote hosts
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
// For HTTP, only allow providers explicitly configured with http:// or the web search apiHost
if (parsed.protocol === "http:") {
const isProviderHost = providerHttpHosts.has(parsed.hostname);
let isWebSearchHost = false;
if (webSearchApiHost) {
try { isWebSearchHost = new URL(webSearchApiHost).hostname === parsed.hostname; } catch { }
}
if (!isProviderHost && !isWebSearchHost) return false;
}
// Check built-in + provider-configured host allowlist
if (BUILTIN_FETCH_HOSTS.has(parsed.hostname)) return true;
if (providerFetchHosts.has(parsed.hostname)) return true;
return false;
} catch {
return false;
}
}
// Start a streaming chat request (proxied through main process)
ipcMain.handle("netcatty:ai:chat:stream", async (event, {
requestId,
url,
headers,
body,
providerId,
idleTimeoutMs,
}) => {
// Validate IPC sender (Issue #17)
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
try {
// Inject real API key if providerId is given (replaces placeholder in headers/URL)
const patched = injectApiKeyIntoRequest(url, headers, providerId);
const resolvedUrl = patched.url;
const resolvedHeaders = patched.headers;
// Validate URL: only allow HTTP(S) schemes
try {
const parsed = new URL(resolvedUrl);
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
return { ok: false, error: "Only HTTP(S) URLs are allowed" };
}
} catch {
return { ok: false, error: "Invalid URL" };
}
// Check URL against allowed hosts (same as netcatty:ai:fetch)
if (!isAllowedFetchUrl(resolvedUrl)) {
return { ok: false, error: "URL host is not in the allowed list" };
}
const skipTLS = shouldSkipTLSVerify(providerId);
const { statusCode, statusText } = await streamRequest(
resolvedUrl,
{ method: "POST", headers: resolvedHeaders, body, idleTimeoutMs },
event,
requestId,
skipTLS,
);
return { ok: true, statusCode, statusText };
} catch (err) {
if (err?.name === "AbortError") {
return { ok: false, aborted: true, error: "Aborted" };
}
return { ok: false, error: err?.message || String(err) };
}
});
// Cancel an active stream
ipcMain.handle("netcatty:ai:chat:cancel", async (event, { requestId }) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
const controller = activeStreams.get(requestId);
if (controller) {
controller.abort();
activeStreams.delete(requestId);
return true;
}
return false;
});
// Non-streaming request (for model listing, validation, etc.)
ipcMain.handle("netcatty:ai:fetch", async (event, { url, method, headers, body, providerId, skipHostCheck, followRedirects, skipTLSVerify }) => {
// Validate IPC sender — settings window needs this for model listing
if (!validateSenderOrSettings(event)) {
return { ok: false, status: 0, data: "", error: "Unauthorized IPC sender" };
}
// Inject real API key if providerId is given (replaces placeholder in headers/URL)
const patched = injectApiKeyIntoRequest(url, headers, providerId);
const resolvedUrl = patched.url;
// Also inject web search API key if placeholder is present
const resolvedHeaders = injectWebSearchKeyIntoHeaders(patched.headers);
// Validate URL: block non-HTTP(S) schemes and internal network access
try {
const parsed = new URL(resolvedUrl);
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
return { ok: false, status: 0, data: "", error: "Only HTTP(S) URLs are allowed" };
}
// Block file:// and other dangerous schemes (already covered above)
} catch {
return { ok: false, status: 0, data: "", error: "Invalid URL" };
}
// Check URL against allowed hosts; skipHostCheck allows public HTTPS but still blocks private/internal
if (!isAllowedFetchUrl(resolvedUrl, !!skipHostCheck)) {
return { ok: false, status: 0, data: "", error: "URL host is not in the allowed list" };
}
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB safety limit
const MAX_REDIRECTS = followRedirects ? 5 : 0;
async function doFetch(fetchUrl, redirectsLeft) {
// ctx.require is bound from aiBridge.cjs, so this path is relative to that file.
const { resolveOutboundHttpAgent } = require("./httpNetworkProxyAgent.cjs");
const skipTLS = Boolean(skipTLSVerify || shouldSkipTLSVerify(providerId));
let proxyAgent;
try {
proxyAgent = await resolveOutboundHttpAgent(fetchUrl, {
session: electronModule?.session?.defaultSession,
rejectUnauthorized: skipTLS ? false : undefined,
});
} catch {
proxyAgent = undefined;
}
return new Promise((resolve) => {
const parsedUrl = new URL(fetchUrl);
const isHttps = parsedUrl.protocol === "https:";
const lib = isHttps ? https : http;
const fetchOpts = {
method: method || "GET",
headers: withContentLength(resolvedHeaders || {}, body),
timeout: 30000,
};
if (skipTLS && isHttps) fetchOpts.rejectUnauthorized = false;
if (proxyAgent) fetchOpts.agent = proxyAgent;
const req = lib.request(parsedUrl, fetchOpts,
(res) => {
// Handle redirects
if (redirectsLeft > 0 && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
const location = new URL(res.headers.location, fetchUrl).href;
res.resume(); // drain the response
// Revalidate the redirect target hostname (blocks localhost/metadata etc.)
if (!isAllowedFetchUrl(location, !!skipHostCheck)) {
resolve({ ok: false, status: 0, data: "", error: "Redirect target is not allowed" });
return;
}
resolve(doFetch(location, redirectsLeft - 1));
return;
}
let data = "";
let totalSize = 0;
res.on("data", (chunk) => {
totalSize += chunk.length;
if (totalSize > MAX_RESPONSE_SIZE) {
req.destroy();
resolve({ ok: false, status: 0, data: "", error: "Response body exceeded maximum size (10MB)" });
return;
}
data += chunk.toString();
});
res.on("end", () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
data,
});
});
}
);
req.on("error", (err) => {
resolve({ ok: false, status: 0, data: "", error: err.message });
});
req.on("timeout", () => {
req.destroy();
resolve({ ok: false, status: 0, data: "", error: "Request timeout" });
});
if (body) req.write(body);
req.end();
});
}
return doFetch(resolvedUrl, MAX_REDIRECTS);
});
}
}
module.exports = { registerProviderHandlers };

View File

@@ -0,0 +1,74 @@
"use strict";
/**
* Repair ~/.claude.json before the claude-agent-sdk subprocess reads it.
* 1:1 port of craft options.ts ensureClaudeConfig(): a missing/empty/BOM-
* prefixed/corrupted config (or a stale .backup / .corrupted.* sibling) makes
* the Claude Code binary write plain-text recovery messages to stdout, which
* the SDK transport rejects as "CLI output was not valid JSON".
*/
const { join } = require("node:path");
const { homedir } = require("node:os");
const { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync } = require("node:fs");
const UTF8_BOM = "";
let claudeConfigChecked = false;
function writeConfigSafe(configPath, content) {
try {
writeFileSync(configPath, content, "utf-8");
} catch (err) {
const code = err && err.code;
if (process.platform === "win32" && (code === "EBUSY" || code === "EPERM")) {
const start = Date.now();
while (Date.now() - start < 100) { /* brief busy wait, runs once at startup */ }
try { writeFileSync(configPath, content, "utf-8"); } catch { /* best effort */ }
}
}
}
function ensureClaudeConfig() {
if (claudeConfigChecked) return;
claudeConfigChecked = true;
const configPath = join(homedir(), ".claude.json");
const backupPath = `${configPath}.backup`;
if (existsSync(backupPath)) {
try { unlinkSync(backupPath); } catch { /* best effort */ }
}
try {
const homeDir = homedir();
for (const file of readdirSync(homeDir)) {
if (file.startsWith(".claude.json.corrupted.")) {
try { unlinkSync(join(homeDir, file)); } catch { /* best effort */ }
}
}
} catch { /* ignore — main repair below still runs */ }
if (!existsSync(configPath)) {
writeConfigSafe(configPath, "{}");
return;
}
try {
const raw = readFileSync(configPath, "utf-8");
const content = raw.startsWith(UTF8_BOM) ? raw.slice(1) : raw;
const hasBom = raw !== content;
if (content.trim().length === 0) {
writeConfigSafe(configPath, "{}");
return;
}
JSON.parse(content);
if (hasBom) writeConfigSafe(configPath, content);
} catch {
writeConfigSafe(configPath, "{}");
}
}
function resetClaudeConfigCheck() {
claudeConfigChecked = false;
}
module.exports = { ensureClaudeConfig, resetClaudeConfigCheck };

View File

@@ -0,0 +1,389 @@
"use strict";
/**
* Claude backend driver — wraps @anthropic-ai/claude-agent-sdk query().
*
* - Spawns the user's system `claude` binary via an ABSOLUTE pathToClaudeCodeExecutable
* (SDK existsSync-checks it; PATH is not resolved — issue #205).
* - Repairs ~/.claude.json before spawn (ensureClaudeConfig).
* - Bypasses the SDK's built-in permission system and BLOCKS built-in
* side-effect tools so the agent can only act through the injected netcatty
* MCP server (approval/scope/blocklist enforced there).
* - Translates SDK messages into the canonical renderer event protocol.
*/
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
const { ensureClaudeConfig } = require("./claudeConfig.cjs");
// Built-in tools that need interactive UI netcatty doesn't provide - they would
// hang the turn waiting for a response, so they are blocked in BOTH modes.
const UI_DISALLOWED_TOOLS = ["EnterPlanMode", "ExitPlanMode", "AskUserQuestion"];
// Whitelist Claude built-ins instead of trying to track every local-capable
// built-in tool the CLI may add over time. MCP tools remain available through
// mcpServers; this only controls Claude Code's own local-machine tools.
const MCP_MODE_TOOLS = [];
const SKILLS_MODE_TOOLS = ["Bash", "Skill"];
const CLAUDE_IMAGE_MEDIA_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
function isClaudeImageAttachment(attachment) {
return Boolean(
attachment &&
CLAUDE_IMAGE_MEDIA_TYPES.has(String(attachment.mediaType || "").toLowerCase()) &&
attachment.base64Data,
);
}
/**
* Resolve built-in tools for the active tool-integration mode.
* - "skills": only Bash + Skill so the Netcatty CLI skill can run.
* - "mcp" (default): no Claude built-in local tools, forcing remote actions
* through netcatty MCP.
*/
function claudeBuiltinTools(toolIntegrationMode) {
return toolIntegrationMode === "skills"
? [...SKILLS_MODE_TOOLS]
: [...MCP_MODE_TOOLS];
}
/** Convert neutral injectMcp configs into the SDK's keyed mcpServers map. */
function toSdkMcpServers(injectedMcpServers) {
const map = {};
for (const cfg of injectedMcpServers || []) {
if (!cfg || !cfg.name) continue;
map[cfg.name] = {
type: "stdio",
command: cfg.command,
args: cfg.args || [],
env: mcpEnvPairsToObject(cfg.env),
};
}
return map;
}
/**
* Normalize the user-supplied claude `settings` value: a settings.json path
* (string) or inline JSON ("{...}" -> object). Returns undefined when empty.
* This is INDEPENDENT of CLAUDE_CONFIG_DIR (which supplies credentials + the
* base settings layer) — `settings` is an additional override the SDK merges on
* top, so the two coexist.
*/
function parseClaudeSettings(settings) {
if (settings == null) return undefined;
if (typeof settings === "object") return settings;
const str = String(settings).trim();
if (!str) return undefined;
if (str.startsWith("{")) {
try { return JSON.parse(str); } catch { return str; }
}
return str;
}
const CLAUDE_REASONING_LEVELS = new Set(["low", "medium", "high", "max"]);
function splitClaudeModelSelection(model) {
if (typeof model !== "string" || !model) {
return { model: undefined, effort: undefined };
}
const slash = model.lastIndexOf("/");
if (slash <= 0) return { model, effort: undefined };
const effort = model.slice(slash + 1);
if (!CLAUDE_REASONING_LEVELS.has(effort)) return { model, effort: undefined };
return { model: model.slice(0, slash), effort };
}
function mergeClaudeEffortSettings(settings, effort) {
if (!effort) return settings;
if (settings == null) return { effort };
if (typeof settings === "object") return { ...settings, effort };
return settings;
}
function buildClaudeQueryOptions({
cwd, model, env, pathToClaudeCodeExecutable, abortController, injectedMcpServers, settings, resume,
toolIntegrationMode,
}) {
const { model: resolvedModel, effort } = splitClaudeModelSelection(model);
const options = {
cwd,
includePartialMessages: true,
permissionMode: "bypassPermissions",
// Required companion to permissionMode:'bypassPermissions' (the SDK rejects
// the bypass without it). Netcatty blocks Claude's direct local read/write
// tools and routes remote-session actions through MCP or Skills+CLI, where
// Netcatty enforces approval/scope.
allowDangerouslySkipPermissions: true,
tools: claudeBuiltinTools(toolIntegrationMode),
disallowedTools: [...UI_DISALLOWED_TOOLS],
mcpServers: toSdkMcpServers(injectedMcpServers),
env,
abortController,
};
if (resolvedModel) options.model = resolvedModel;
if (effort) options.effort = effort;
// Resume the prior session so context carries ACROSS turns. Without this the
// SDK starts a fresh session every turn (full amnesia). The session id is
// emitted on system-init (before any turn work), so a mid-turn Stop can't lose
// it and the next turn resumes correctly. undefined => fresh session.
if (resume) options.resume = resume;
// ABSOLUTE path only (SDK does not resolve PATH). undefined => SDK auto-discovery.
if (pathToClaudeCodeExecutable) {
options.pathToClaudeCodeExecutable = pathToClaudeCodeExecutable;
}
// Optional settings.json path / inline object — additive to CLAUDE_CONFIG_DIR.
const parsedSettings = mergeClaudeEffortSettings(parseClaudeSettings(settings), effort);
if (parsedSettings !== undefined) options.settings = parsedSettings;
return options;
}
/**
* Translate one SDK message into emitter calls.
* NOTE: with includePartialMessages, streamed text arrives via stream_event;
* the consolidated assistant TEXT block is skipped to avoid duplication, but
* assistant TOOL_USE blocks are the authoritative source for tool calls.
*/
function translateClaudeMessage(message, emitter) {
if (!message || typeof message !== "object") return;
const type = message.type;
if (type === "system" && message.subtype === "init" && message.session_id) {
emitter.sessionId(message.session_id);
return;
}
if (type === "stream_event" && message.event) {
const ev = message.event;
if (ev.type === "content_block_delta" && ev.delta) {
if (ev.delta.type === "text_delta" && ev.delta.text) {
emitter.text(ev.delta.text);
} else if (ev.delta.type === "thinking_delta" && ev.delta.thinking) {
emitter.reasoning(ev.delta.thinking);
}
}
return;
}
if (type === "assistant" && message.message && Array.isArray(message.message.content)) {
for (const block of message.message.content) {
if (block?.type === "tool_use") {
emitter.toolCall(block.name, block.input || {}, block.id);
}
// text blocks intentionally skipped (already streamed via stream_event)
}
return;
}
if (type === "user" && message.message && Array.isArray(message.message.content)) {
for (const block of message.message.content) {
if (block?.type === "tool_result") {
const out = typeof block.content === "string"
? block.content
: JSON.stringify(block.content);
emitter.toolResult(block.tool_use_id, out, undefined);
}
}
return;
}
// 'result' carries final usage/cost — handled by the run loop, no per-event emit.
}
/** Classify a spawn failure. SDK wraps spawn ENOENT as a message string. */
function classifyClaudeSpawnError(error) {
const code = error && error.code;
const msg = String((error && error.message) || error || "");
const isSpawnEnoent =
code === "ENOENT" ||
/native binary not found/i.test(msg) ||
/ENOENT/i.test(msg);
return { isSpawnEnoent, message: msg };
}
function buildClaudePromptInput(prompt, attachments) {
const imageAttachments = Array.isArray(attachments)
? attachments.filter(isClaudeImageAttachment)
: [];
if (imageAttachments.length === 0) return String(prompt || "");
const content = [{ type: "text", text: String(prompt || "") }];
for (const attachment of imageAttachments) {
content.push({
type: "image",
source: {
type: "base64",
media_type: String(attachment.mediaType).toLowerCase(),
data: attachment.base64Data,
},
});
}
return (async function* claudePromptInput() {
yield {
type: "user",
message: { role: "user", content },
parent_tool_use_id: null,
};
}());
}
/**
* Run a Claude turn. Streams events via `emitter`, resolves with { sessionId }.
* @param {object} args
* @param {string} args.prompt
* @param {Array<object>} [args.attachments]
* @param {object} args.options result of buildClaudeQueryOptions
* @param {object} args.emitter createStreamEmitter(...)
* @param {Function} [args.queryFn] inject @anthropic-ai/claude-agent-sdk query (for tests)
*/
async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn }) {
ensureClaudeConfig();
let query = queryFn;
if (!query) {
let sdk;
try { sdk = await import("@anthropic-ai/claude-agent-sdk"); } catch { emitter.emitError("Claude Agent SDK not installed. Run: npm install @anthropic-ai/claude-agent-sdk"); return { sessionId: null }; }
query = sdk.query;
}
const promptInput = buildClaudePromptInput(prompt, attachments);
let sessionId = null;
let hasContent = false;
try {
const stream = query({ prompt: promptInput, options });
for await (const message of stream) {
if (options.abortController?.signal?.aborted) break;
if (message?.session_id && message.session_id !== sessionId) {
sessionId = message.session_id;
}
if (
message?.type === "stream_event" ||
(message?.type === "assistant" && Array.isArray(message?.message?.content) && message.message.content.length > 0)
) {
hasContent = true;
}
translateClaudeMessage(message, emitter);
}
if (!hasContent && !options.abortController?.signal?.aborted) {
emitter.emitError(
"Claude returned an empty response. Run `claude` in a terminal to log in, " +
"or set ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN.",
);
return { sessionId };
}
emitter.emitDone();
return { sessionId };
} catch (error) {
const classified = classifyClaudeSpawnError(error);
if (classified.isSpawnEnoent) {
emitter.emitError(
`Claude Code binary not found or not runnable (${options.pathToClaudeCodeExecutable || "auto-discovery"}). ` +
"Install with `npm i -g @anthropic-ai/claude-code` and ensure it's on PATH.",
);
} else {
emitter.emitError(classified.message || "Claude turn failed");
}
return { sessionId };
}
}
/** Map claude-agent-sdk ModelInfo[] -> renderer preset shape {id,name,description}. */
function mapClaudeModels(models) {
if (!Array.isArray(models)) return [];
return models
.filter((m) => m && m.value)
.map((m) => ({
id: m.value,
name: m.displayName || m.value,
description: m.description,
thinkingLevels: ["low", "medium", "high", "max"],
defaultThinkingLevel: "medium",
}));
}
/**
* Fetch available Claude models via the SDK control channel. Opens a streaming
* (idle) session so no turn is billed, asks supportedModels(), then tears down.
* Returns [] on failure (the caller falls back to the UI's curated presets).
* @param {object} args
* @param {string} [args.pathToClaudeCodeExecutable]
* @param {object} [args.env]
* @param {Function} [args.queryFn] inject query() for tests
*/
async function listClaudeModels({
pathToClaudeCodeExecutable,
env,
queryFn,
abortController,
signal,
}) {
ensureClaudeConfig();
const externalSignal = signal || abortController?.signal;
if (externalSignal?.aborted) return [];
let query = queryFn;
if (!query) {
let sdk;
try { sdk = await import("@anthropic-ai/claude-agent-sdk"); } catch { return []; }
query = sdk.query;
}
const queryAbortController = new AbortController();
const forwardAbort = () => {
try { queryAbortController.abort(externalSignal?.reason); } catch {}
};
if (externalSignal) {
externalSignal.addEventListener("abort", forwardAbort, { once: true });
if (externalSignal.aborted) forwardAbort();
}
// Idle streaming input: keeps the session open (init handshake completes)
// without sending a turn, so supportedModels() resolves; then we abort.
async function* idleInput() {
await new Promise((resolve) => {
if (queryAbortController.signal.aborted) return resolve();
queryAbortController.signal.addEventListener("abort", () => resolve(), { once: true });
});
}
let q;
try {
q = query({
prompt: idleInput(),
options: {
pathToClaudeCodeExecutable,
env,
abortController: queryAbortController,
includePartialMessages: false,
},
});
const result = await Promise.race([
Promise.resolve(q.supportedModels()).then((models) => ({ type: "models", models })),
new Promise((resolve) => {
if (queryAbortController.signal.aborted) return resolve({ type: "aborted" });
queryAbortController.signal.addEventListener(
"abort",
() => resolve({ type: "aborted" }),
{ once: true },
);
}),
]);
return result.type === "models" ? mapClaudeModels(result.models) : [];
} catch {
return [];
} finally {
if (externalSignal) externalSignal.removeEventListener("abort", forwardAbort);
queryAbortController.abort();
try { void Promise.resolve(q?.return?.(undefined)).catch(() => {}); } catch { /* best effort */ }
}
}
module.exports = {
buildClaudeQueryOptions,
parseClaudeSettings,
splitClaudeModelSelection,
mergeClaudeEffortSettings,
translateClaudeMessage,
classifyClaudeSpawnError,
buildClaudePromptInput,
runClaudeTurn,
listClaudeModels,
mapClaudeModels,
claudeBuiltinTools,
UI_DISALLOWED_TOOLS,
MCP_MODE_TOOLS,
SKILLS_MODE_TOOLS,
toSdkMcpServers,
};

View File

@@ -0,0 +1,256 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { translateClaudeMessage, buildClaudeQueryOptions, buildClaudePromptInput, classifyClaudeSpawnError, listClaudeModels, mapClaudeModels, parseClaudeSettings, splitClaudeModelSelection } = require("./claudeDriver.cjs");
function collector() {
const events = [];
const emitter = {
text: (t) => events.push({ k: "text", t }),
reasoning: (d) => events.push({ k: "reasoning", d }),
reasoningEnd: () => events.push({ k: "reasoningEnd" }),
toolCall: (name, args, id) => events.push({ k: "toolCall", name, args, id }),
toolResult: (id, out, name) => events.push({ k: "toolResult", id, out, name }),
status: (m) => events.push({ k: "status", m }),
sessionId: (s) => events.push({ k: "sessionId", s }),
};
return { events, emitter };
}
test("init system message -> sessionId event", () => {
const { events, emitter } = collector();
translateClaudeMessage({ type: "system", subtype: "init", session_id: "sess-1" }, emitter);
assert.deepEqual(events, [{ k: "sessionId", s: "sess-1" }]);
});
test("stream_event text_delta -> text event", () => {
const { events, emitter } = collector();
translateClaudeMessage(
{ type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "hello" } } },
emitter,
);
assert.deepEqual(events, [{ k: "text", t: "hello" }]);
});
test("assistant tool_use block -> toolCall event", () => {
const { events, emitter } = collector();
translateClaudeMessage(
{
type: "assistant",
message: { content: [{ type: "tool_use", id: "tu-1", name: "mcp__netcatty-remote-hosts__terminal_execute", input: { command: "ls" } }] },
},
emitter,
);
assert.deepEqual(events, [
{ k: "toolCall", name: "mcp__netcatty-remote-hosts__terminal_execute", args: { command: "ls" }, id: "tu-1" },
]);
});
test("assistant text block (non-partial) is NOT double-emitted when partials enabled", () => {
// With includePartialMessages, text arrives via stream_event; the assistant
// message text block is the consolidated copy and must be skipped to avoid dupes.
const { events, emitter } = collector();
translateClaudeMessage(
{ type: "assistant", message: { content: [{ type: "text", text: "consolidated" }] } },
emitter,
);
assert.deepEqual(events, []);
});
test("user tool_result block -> toolResult event", () => {
const { events, emitter } = collector();
translateClaudeMessage(
{ type: "user", message: { content: [{ type: "tool_result", tool_use_id: "tu-1", content: "output text" }] } },
emitter,
);
assert.deepEqual(events, [{ k: "toolResult", id: "tu-1", out: "output text", name: undefined }]);
});
test("buildClaudeQueryOptions sets bypassPermissions, built-in tools, mcp stdio, abort", () => {
const ac = new AbortController();
const opts = buildClaudeQueryOptions({
cwd: "/tmp",
model: "claude-opus-4-6",
env: { PATH: "/usr/bin" },
pathToClaudeCodeExecutable: "/abs/claude",
abortController: ac,
injectedMcpServers: [{
name: "netcatty-remote-hosts", type: "stdio",
command: "/abs/electron", args: ["/abs/server.cjs"],
env: [{ name: "NETCATTY_MCP_PORT", value: "1" }],
}],
});
assert.equal(opts.permissionMode, "bypassPermissions");
// required companion to bypassPermissions (SDK rejects the bypass without it)
assert.equal(opts.allowDangerouslySkipPermissions, true);
assert.equal(opts.includePartialMessages, true);
assert.equal(opts.pathToClaudeCodeExecutable, "/abs/claude");
assert.equal(opts.abortController, ac);
// MCP mode disables Claude Code built-ins entirely; injected MCP tools remain wired below.
assert.deepEqual(opts.tools, []);
for (const t of ["EnterPlanMode", "ExitPlanMode", "AskUserQuestion"]) {
assert.ok(opts.disallowedTools.includes(t), `expected ${t} disallowed`);
}
// netcatty MCP wired as keyed stdio with env object (not pair array)
assert.equal(opts.mcpServers["netcatty-remote-hosts"].type, "stdio");
assert.deepEqual(opts.mcpServers["netcatty-remote-hosts"].env, { NETCATTY_MCP_PORT: "1" });
});
test("built-in tools are mode-aware: Skills+CLI allows only Bash/Skill, MCP blocks all built-ins", () => {
const skills = buildClaudeQueryOptions({ env: {}, toolIntegrationMode: "skills" });
// Bash + Skill are the only Claude Code built-ins exposed so the agent can
// drive the netcatty CLI skill without direct file/search/web/local tools.
assert.deepEqual(skills.tools, ["Bash", "Skill"]);
for (const t of ["Read", "Edit", "Write", "MultiEdit", "Glob", "Grep", "WebFetch", "WebSearch", "Task", "Agent", "REPL", "Workflow"]) {
assert.ok(!skills.tools.includes(t), `expected ${t} absent from skills mode tool whitelist`);
}
// UI-coupled tools still blocked in BOTH modes as defense-in-depth.
for (const t of ["EnterPlanMode", "ExitPlanMode", "AskUserQuestion"]) {
assert.ok(skills.disallowedTools.includes(t), `expected ${t} blocked in skills mode`);
}
// MCP mode (and the undefined default) disables all Claude Code built-ins.
assert.deepEqual(buildClaudeQueryOptions({ env: {}, toolIntegrationMode: "mcp" }).tools, []);
assert.deepEqual(buildClaudeQueryOptions({ env: {} }).tools, []);
});
test("classifyClaudeSpawnError detects ENOENT 'native binary not found'", () => {
const r = classifyClaudeSpawnError(new Error("Claude Code native binary not found at /abs/claude"));
assert.equal(r.isSpawnEnoent, true);
});
test("classifyClaudeSpawnError detects code:ENOENT", () => {
const e = new Error("spawn failed"); e.code = "ENOENT"; e.syscall = "spawn";
assert.equal(classifyClaudeSpawnError(e).isSpawnEnoent, true);
});
test("mapClaudeModels maps {value,displayName,description} -> {id,name,description} and drops value-less", () => {
const out = mapClaudeModels([
{ value: "claude-opus-4-6", displayName: "Opus 4.6", description: "Recommended" },
{ value: "claude-sonnet-4-6", displayName: "Sonnet 4.6" },
{ displayName: "no value -> dropped" },
]);
assert.deepEqual(out, [
{
id: "claude-opus-4-6",
name: "Opus 4.6",
description: "Recommended",
thinkingLevels: ["low", "medium", "high", "max"],
defaultThinkingLevel: "medium",
},
{
id: "claude-sonnet-4-6",
name: "Sonnet 4.6",
description: undefined,
thinkingLevels: ["low", "medium", "high", "max"],
defaultThinkingLevel: "medium",
},
]);
assert.deepEqual(mapClaudeModels(null), []);
});
test("splitClaudeModelSelection only treats known trailing effort as thinking", () => {
assert.deepEqual(splitClaudeModelSelection("sonnet/high"), { model: "sonnet", effort: "high" });
assert.deepEqual(splitClaudeModelSelection("claude-opus-4-6"), {
model: "claude-opus-4-6",
effort: undefined,
});
assert.deepEqual(splitClaudeModelSelection("org/custom-model"), {
model: "org/custom-model",
effort: undefined,
});
});
test("buildClaudeQueryOptions splits model/effort into model + settings.effort", () => {
const opts = buildClaudeQueryOptions({
cwd: "/tmp",
model: "sonnet/high",
env: {},
settings: { model: "sonnet" },
});
assert.equal(opts.model, "sonnet");
assert.equal(opts.effort, "high");
assert.deepEqual(opts.settings, { model: "sonnet", effort: "high" });
});
test("parseClaudeSettings: path string, inline JSON object, empty, and bad JSON", () => {
assert.equal(parseClaudeSettings("/path/to/settings.json"), "/path/to/settings.json");
assert.deepEqual(parseClaudeSettings('{"model":"sonnet"}'), { model: "sonnet" });
assert.deepEqual(parseClaudeSettings({ model: "opus" }), { model: "opus" });
assert.equal(parseClaudeSettings(""), undefined);
assert.equal(parseClaudeSettings(null), undefined);
assert.equal(parseClaudeSettings("{bad json"), "{bad json"); // invalid JSON -> treated as a path
});
test("buildClaudeQueryOptions wires settings (additive to CLAUDE_CONFIG_DIR) and omits when absent", () => {
const withS = buildClaudeQueryOptions({ env: {}, settings: "/abs/settings.json" });
assert.equal(withS.settings, "/abs/settings.json");
const without = buildClaudeQueryOptions({ env: {} });
assert.equal("settings" in without, false);
});
test("buildClaudeQueryOptions wires resume so context carries across turns; omits when absent", () => {
// Without options.resume the SDK starts a fresh session every turn (amnesia).
assert.equal(buildClaudeQueryOptions({ env: {}, resume: "sess-1" }).resume, "sess-1");
assert.equal("resume" in buildClaudeQueryOptions({ env: {} }), false);
});
test("buildClaudePromptInput sends supported images as native image blocks", async () => {
const input = buildClaudePromptInput("describe this", [
{ filename: "shot.png", mediaType: "image/png", filePath: "/tmp/shot.png", base64Data: "abc" },
{ filename: "bad.svg", mediaType: "image/svg+xml", filePath: "/tmp/bad.svg", base64Data: "def" },
]);
const messages = [];
for await (const message of input) messages.push(message);
assert.deepEqual(messages, [{
type: "user",
message: {
role: "user",
content: [
{ type: "text", text: "describe this" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } },
],
},
parent_tool_use_id: null,
}]);
});
test("buildClaudePromptInput keeps plain text when there are no supported images", () => {
assert.equal(
buildClaudePromptInput("hello", [{ filename: "note.txt", mediaType: "text/plain", base64Data: "abc" }]),
"hello",
);
});
test("listClaudeModels aborts a hung SDK query and returns it for cleanup", async () => {
const abortController = new AbortController();
let queryAbortSignal;
let returnCount = 0;
let releaseModels;
const pendingModels = new Promise((resolve) => { releaseModels = resolve; });
const queryFn = ({ options }) => {
queryAbortSignal = options.abortController.signal;
return {
supportedModels: () => pendingModels,
async return() {
returnCount += 1;
},
};
};
const modelsPromise = listClaudeModels({
pathToClaudeCodeExecutable: "/bin/claude",
env: {},
queryFn,
abortController,
});
abortController.abort();
const outcome = await Promise.race([
modelsPromise.then(() => "settled"),
new Promise((resolve) => setTimeout(() => resolve("hung"), 20)),
]);
if (outcome === "hung") releaseModels([]);
assert.equal(outcome, "settled");
assert.deepEqual(await modelsPromise, []);
assert.equal(queryAbortSignal.aborted, true);
assert.equal(returnCount, 1);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,881 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { getEventListeners } = require("node:events");
const {
buildCodebuddyQueryOptions,
buildCodebuddyCanUseTool,
buildCodebuddyPromptInput,
codebuddyBuiltinTools,
mapCodebuddyModels,
runCodebuddyTurn,
translateCodebuddyMessage,
buildCodebuddyHooks,
buildCodebuddyElicitation,
toSdkMcpServers,
} = require("./codebuddyDriver.cjs");
function collector() {
const events = [];
const emitter = {
text: (t) => events.push({ k: "text", t }),
reasoning: (d) => events.push({ k: "reasoning", d }),
toolCall: (name, args, id) => events.push({ k: "toolCall", name, args, id }),
toolResult: (id, out, name) => events.push({ k: "toolResult", id, out, name }),
usage: (usage) => events.push({ k: "usage", usage }),
status: (m) => events.push({ k: "status", m }),
sessionId: (s) => events.push({ k: "sessionId", s }),
emitDone: () => events.push({ k: "done" }),
emitError: (m) => events.push({ k: "error", m }),
};
return { events, emitter };
}
test("buildCodebuddyQueryOptions wires SDK options in isolated mode", () => {
const ac = new AbortController();
const opts = buildCodebuddyQueryOptions({
cwd: "/tmp",
model: "codebuddy-1",
env: { PATH: "/usr/bin", CODEBUDDY_INTERNET_ENVIRONMENT: "ioa" },
pathToCodebuddyCode: "/opt/codebuddy/bin/codebuddy",
abortController: ac,
resume: "sess-1",
injectedMcpServers: [{
name: "netcatty-remote-hosts",
command: "/abs/electron",
args: ["/abs/server.cjs"],
env: [{ name: "NETCATTY_MCP_PORT", value: "1" }],
}],
});
assert.equal(opts.cwd, "/tmp");
assert.equal(opts.model, "codebuddy-1");
assert.equal(opts.includePartialMessages, true);
assert.equal(opts.permissionMode, "bypassPermissions");
assert.equal(opts.allowDangerouslySkipPermissions, true);
assert.deepEqual(opts.extraArgs, { "dangerously-skip-permissions": null });
assert.deepEqual(opts.settingSources, []);
assert.equal(opts.env.CODEBUDDY_INTERNET_ENVIRONMENT, "ioa");
assert.equal(opts.pathToCodebuddyCode, "/opt/codebuddy/bin/codebuddy");
assert.equal(opts.abortController, ac);
assert.equal(opts.resume, "sess-1");
assert.deepEqual(opts.tools, []);
// allowedTools must stay unset in mcp mode: tools:[] disables built-ins, while
// allowedTools:[] would prevent injected Netcatty MCP tools from running.
assert.ok(!("allowedTools" in opts));
assert.ok(opts.disallowedTools.includes("AskUserQuestion"));
assert.equal(opts.mcpServers["netcatty-remote-hosts"].type, "stdio");
assert.deepEqual(opts.mcpServers["netcatty-remote-hosts"].env, { NETCATTY_MCP_PORT: "1" });
});
test("built-in tools are mode-aware", () => {
assert.deepEqual(codebuddyBuiltinTools("mcp"), []);
assert.deepEqual(codebuddyBuiltinTools(undefined), []);
assert.deepEqual(codebuddyBuiltinTools("skills"), ["Bash"]);
});
test("translateCodebuddyMessage emits assistant text fallback", () => {
const { events, emitter } = collector();
translateCodebuddyMessage(
{ type: "assistant", message: { content: [{ type: "text", text: "hello" }] } },
emitter,
);
assert.deepEqual(events, [{ k: "text", t: "hello" }]);
});
test("translateCodebuddyMessage can skip consolidated assistant text after stream deltas", () => {
const { events, emitter } = collector();
translateCodebuddyMessage(
{ type: "assistant", message: { content: [{ type: "text", text: "consolidated" }] } },
emitter,
{ skipAssistantText: true },
);
assert.deepEqual(events, []);
});
test("translateCodebuddyMessage preserves consolidated reasoning without deltas", () => {
const { events, emitter } = collector();
translateCodebuddyMessage(
{
type: "assistant",
message: { content: [{ type: "thinking", thinking: "check the fallback" }] },
},
emitter,
);
assert.deepEqual(events, [{ k: "reasoning", d: "check the fallback" }]);
});
test("translateCodebuddyMessage maps stream deltas, tool calls, and tool results", () => {
const { events, emitter } = collector();
translateCodebuddyMessage(
{ type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "hi" } } },
emitter,
);
translateCodebuddyMessage(
{ type: "stream_event", event: { type: "content_block_delta", delta: { type: "thinking_delta", thinking: "why" } } },
emitter,
);
translateCodebuddyMessage(
{ type: "assistant", message: { content: [{ type: "tool_use", id: "tu-1", name: "Bash", input: { command: "ls" } }] } },
emitter,
);
translateCodebuddyMessage(
{ type: "user", message: { content: [{ type: "tool_result", tool_use_id: "tu-1", content: "ok" }] } },
emitter,
);
assert.deepEqual(events, [
{ k: "text", t: "hi" },
{ k: "reasoning", d: "why" },
{ k: "toolCall", name: "Bash", args: { command: "ls" }, id: "tu-1" },
{ k: "toolResult", id: "tu-1", out: "ok", name: undefined },
]);
});
test("translateCodebuddyMessage emits system session id and status text", () => {
const { events, emitter } = collector();
translateCodebuddyMessage(
{ type: "system", session_id: "sess-1", message: "initializing" },
emitter,
);
assert.deepEqual(events, [
{ k: "sessionId", s: "sess-1" },
{ k: "status", m: "initializing" },
]);
});
test("runCodebuddyTurn preserves explicit SDK error messages", async () => {
const { events, emitter } = collector();
async function* fakeQuery() {
yield {
type: "error",
session_id: "sess-error",
error: "Provider quota exceeded",
};
}
const result = await runCodebuddyTurn({
prompt: "hello",
options: { abortController: new AbortController() },
emitter,
queryFn: fakeQuery,
});
assert.deepEqual(result, { sessionId: "sess-error" });
assert.deepEqual(events, [{ k: "error", m: "Provider quota exceeded" }]);
});
test("translateCodebuddyMessage emits actual result usage", () => {
const { events, emitter } = collector();
const result = translateCodebuddyMessage({
type: "result",
subtype: "success",
is_error: false,
num_turns: 1,
total_cost_usd: 0,
usage: {
input_tokens: 321,
output_tokens: 45,
cache_read_input_tokens: 100,
cache_creation_input_tokens: 20,
},
}, emitter);
assert.deepEqual(result, { terminalError: false });
assert.deepEqual(events, [
{
k: "usage",
usage: {
inputTokens: 441,
cachedInputTokens: 100,
outputTokens: 45,
totalTokens: 486,
},
},
{ k: "status", m: "CodeBuddy: 1 turns" },
]);
});
test("runCodebuddyTurn reports terminal result subtypes instead of an auth error", async () => {
const { events, emitter } = collector();
async function* fakeQuery() {
yield {
type: "result",
subtype: "error_max_budget_usd",
is_error: true,
num_turns: 2,
total_cost_usd: 1,
usage: { input_tokens: 10, output_tokens: 2 },
permission_denials: [],
};
}
await runCodebuddyTurn({
prompt: "spend",
options: { abortController: new AbortController() },
emitter,
queryFn: () => fakeQuery(),
});
assert.deepEqual(events, [
{
k: "usage",
usage: {
inputTokens: 10,
cachedInputTokens: 0,
outputTokens: 2,
totalTokens: 12,
},
},
{ k: "status", m: "CodeBuddy: 2 turns, $1.0000" },
{ k: "error", m: "CodeBuddy stopped after reaching the configured budget limit." },
]);
});
test("runCodebuddyTurn renders a successful result fallback when no text delta arrives", async () => {
const { events, emitter } = collector();
async function* fakeQuery() {
yield {
type: "stream_event",
event: { type: "message_start", message: { content: [] } },
};
yield {
type: "result",
subtype: "success",
is_error: false,
num_turns: 1,
result: "fallback answer",
total_cost_usd: 0,
usage: { input_tokens: 4, output_tokens: 2 },
permission_denials: [],
};
}
await runCodebuddyTurn({
prompt: "answer",
options: { abortController: new AbortController() },
emitter,
queryFn: () => fakeQuery(),
});
assert.deepEqual(events, [
{
k: "usage",
usage: {
inputTokens: 4,
cachedInputTokens: 0,
outputTokens: 2,
totalTokens: 6,
},
},
{ k: "status", m: "CodeBuddy: 1 turns" },
{ k: "text", t: "fallback answer" },
{ k: "done" },
]);
});
test("runCodebuddyTurn does not duplicate assistant text after streamed text", async () => {
const { events, emitter } = collector();
async function* fakeQuery() {
yield { type: "system", session_id: "sess-1" };
yield { type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "hello" } } };
yield { type: "assistant", message: { content: [{ type: "text", text: "hello" }] } };
}
const result = await runCodebuddyTurn({
prompt: "say hi",
options: { abortController: new AbortController() },
emitter,
queryFn: () => fakeQuery(),
});
assert.deepEqual(result, { sessionId: "sess-1" });
assert.deepEqual(events, [
{ k: "sessionId", s: "sess-1" },
{ k: "text", t: "hello" },
{ k: "done" },
]);
});
test("runCodebuddyTurn interrupts the SDK query as soon as abort is signaled", async () => {
const events = [];
let sawSession;
const sessionSeen = new Promise((resolve) => { sawSession = resolve; });
const emitter = {
text: (t) => events.push({ k: "text", t }),
reasoning: (d) => events.push({ k: "reasoning", d }),
toolCall: (name, args, id) => events.push({ k: "toolCall", name, args, id }),
toolResult: (id, out, name) => events.push({ k: "toolResult", id, out, name }),
status: (m) => events.push({ k: "status", m }),
sessionId: (s) => { events.push({ k: "sessionId", s }); sawSession(); },
emitDone: () => events.push({ k: "done" }),
emitError: (m) => events.push({ k: "error", m }),
};
const ac = new AbortController();
let interruptCount = 0;
let release;
const fakeQuery = () => ({
interrupt: async () => { interruptCount += 1; release?.(); },
async *[Symbol.asyncIterator]() {
yield { type: "system", session_id: "sess-1" };
await new Promise((resolve) => { release = resolve; });
},
});
const turn = runCodebuddyTurn({
prompt: "wait",
options: { abortController: ac },
emitter,
queryFn: fakeQuery,
});
await sessionSeen;
ac.abort();
const result = await turn;
assert.deepEqual(result, { sessionId: "sess-1" });
assert.ok(interruptCount >= 1);
assert.deepEqual(events, [
{ k: "sessionId", s: "sess-1" },
{ k: "done" },
]);
});
test("runCodebuddyTurn treats an abort rejection as normal completion", async () => {
const ac = new AbortController();
let rejectStream;
const fakeQuery = () => ({
async *[Symbol.asyncIterator]() {
yield { type: "system", session_id: "sess-abort" };
await new Promise((_resolve, reject) => {
rejectStream = reject;
});
},
async interrupt() {
rejectStream?.(new Error("interrupted"));
},
});
const { events, emitter } = collector();
const turn = runCodebuddyTurn({
prompt: "wait",
options: { abortController: ac },
emitter,
queryFn: fakeQuery,
});
await new Promise((resolve) => setImmediate(resolve));
ac.abort();
assert.deepEqual(await turn, { sessionId: "sess-abort" });
assert.deepEqual(events, [
{ k: "sessionId", s: "sess-abort" },
{ k: "done" },
]);
});
test("runCodebuddyTurn does not start the legacy CLI after an early abort", async () => {
const { events, emitter } = collector();
const abortController = new AbortController();
abortController.abort();
let queryCalls = 0;
const result = await runCodebuddyTurn({
prompt: "hello",
attachments: [],
options: { abortController },
emitter,
queryFn() {
queryCalls += 1;
throw new Error("must not start");
},
});
assert.equal(queryCalls, 0);
assert.deepEqual(result, { sessionId: null });
assert.deepEqual(events, [{ k: "done" }]);
});
test("buildCodebuddyPromptInput sends supported images as native image blocks", async () => {
const input = buildCodebuddyPromptInput("describe this", [
{ filename: "shot.png", mediaType: "image/png", filePath: "/tmp/shot.png", base64Data: "abc" },
{ filename: "bad.svg", mediaType: "image/svg+xml", filePath: "/tmp/bad.svg", base64Data: "def" },
]);
const messages = [];
for await (const message of input) messages.push(message);
assert.deepEqual(messages, [{
type: "user",
message: {
role: "user",
content: [
{ type: "text", text: "describe this" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } },
],
},
parent_tool_use_id: null,
}]);
});
test("mapCodebuddyModels maps model ids and drops invalid entries", () => {
assert.deepEqual(mapCodebuddyModels([
// Real CLI wire shape ({id,name}) — must NOT be dropped.
{ id: "glm-5.1", name: "GLM-5.1" },
{ modelId: "cb-1", name: "CodeBuddy 1", description: "default" },
{ value: "cb-2", displayName: "CodeBuddy 2" },
{ name: "missing id" },
]), [
{
id: "glm-5.1",
name: "GLM-5.1",
description: undefined,
thinkingLevels: ["low", "medium", "high", "xhigh"],
defaultThinkingLevel: "medium",
encodeDefaultThinking: false,
},
{
id: "cb-1",
name: "CodeBuddy 1",
description: "default",
thinkingLevels: ["low", "medium", "high", "xhigh"],
defaultThinkingLevel: "medium",
encodeDefaultThinking: false,
},
{
id: "cb-2",
name: "CodeBuddy 2",
description: undefined,
thinkingLevels: ["low", "medium", "high", "xhigh"],
defaultThinkingLevel: "medium",
encodeDefaultThinking: false,
},
]);
assert.deepEqual(mapCodebuddyModels(null), []);
});
// ---------------------------------------------------------------------------
// SDK 0.3.230 options
// ---------------------------------------------------------------------------
test("buildCodebuddyQueryOptions passes SDK 0.3.230 options", () => {
const opts = buildCodebuddyQueryOptions({
cwd: "/tmp",
env: {},
systemPrompt: "You are a server admin assistant.",
effort: "high",
maxTurns: 10,
maxBudgetUsd: 0.5,
fallbackModel: "glm-4",
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
agents: { auditor: { description: "Security auditor", prompt: "Audit", tools: ["Bash"] } },
outputFormat: { type: "json_schema", schema: { type: "object" } },
enableFileCheckpointing: true,
traceId: "trace-123",
parentSpanId: "span-456",
persistSession: false,
sessionId: "custom-sess",
});
assert.deepEqual(opts.systemPrompt, { append: "You are a server admin assistant." });
assert.equal(opts.effort, "high");
assert.equal(opts.maxTurns, 10);
assert.equal(opts.maxBudgetUsd, 0.5);
assert.equal(opts.fallbackModel, "glm-4");
assert.deepEqual(opts.sandbox, { enabled: true, autoAllowBashIfSandboxed: true });
assert.deepEqual(opts.agents, { auditor: { description: "Security auditor", prompt: "Audit", tools: ["Bash"] } });
assert.deepEqual(opts.outputFormat, { type: "json_schema", schema: { type: "object" } });
assert.equal(opts.enableFileCheckpointing, true);
assert.equal(opts.traceId, "trace-123");
assert.equal(opts.parentSpanId, "span-456");
assert.equal(opts.persistSession, false);
assert.equal(opts.sessionId, "custom-sess");
});
test("buildCodebuddyQueryOptions does not set maxThinkingTokens (deprecated removed)", () => {
const opts = buildCodebuddyQueryOptions({
cwd: "/tmp",
env: { NETCATTY_CODEBUDDY_THINKING: "enabled:8000" },
});
assert.deepEqual(opts.thinking, { type: "enabled", budgetTokens: 8000 });
assert.ok(!("maxThinkingTokens" in opts));
});
test("buildCodebuddyQueryOptions splits model/effort and prefers it over settings effort", () => {
const fromModel = buildCodebuddyQueryOptions({
cwd: "/tmp",
model: "glm-5.1/high",
effort: "low",
});
assert.equal(fromModel.model, "glm-5.1");
assert.equal(fromModel.effort, "high");
const fromSettings = buildCodebuddyQueryOptions({
cwd: "/tmp",
model: "glm-5.1",
effort: "low",
});
assert.equal(fromSettings.model, "glm-5.1");
assert.equal(fromSettings.effort, "low");
});
test("buildCodebuddyQueryOptions drops invalid numeric guardrails", () => {
const fractionalTurns = buildCodebuddyQueryOptions({
maxTurns: 1.5,
maxBudgetUsd: Number.POSITIVE_INFINITY,
});
assert.equal(fractionalTurns.maxTurns, undefined);
assert.equal(fractionalTurns.maxBudgetUsd, undefined);
const valid = buildCodebuddyQueryOptions({
maxTurns: 2,
maxBudgetUsd: 0.25,
});
assert.equal(valid.maxTurns, 2);
assert.equal(valid.maxBudgetUsd, 0.25);
});
test("buildCodebuddyQueryOptions drops disabled or malformed advanced options", () => {
const opts = buildCodebuddyQueryOptions({
cwd: "/tmp",
effort: "ultra",
fallbackModel: { id: "fallback" },
sandbox: { enabled: false },
enableFileCheckpointing: false,
});
assert.equal(opts.effort, undefined);
assert.equal(opts.fallbackModel, undefined);
assert.equal(opts.sandbox, undefined);
assert.equal(opts.enableFileCheckpointing, undefined);
});
test("buildCodebuddyQueryOptions accepts object systemPrompt directly", () => {
const opts = buildCodebuddyQueryOptions({
cwd: "/tmp",
env: {},
systemPrompt: { append: "custom append" },
});
assert.deepEqual(opts.systemPrompt, { append: "custom append" });
});
// ---------------------------------------------------------------------------
// Hooks
// ---------------------------------------------------------------------------
test("buildCodebuddyHooks returns hook matchers that emit events", async () => {
const { events, emitter } = collector();
emitter.emitEvent = (ev) => events.push({ k: "event", ev });
const hooks = buildCodebuddyHooks(emitter);
assert.ok(Array.isArray(hooks.PreToolUse));
assert.ok(Array.isArray(hooks.PostToolUse));
assert.ok(Array.isArray(hooks.PostToolUseFailure));
assert.ok(Array.isArray(hooks.SessionEnd));
assert.ok(Array.isArray(hooks.Notification));
// Invoke PreToolUse hook callback
const preHook = hooks.PreToolUse[0].hooks[0];
const result = await preHook(
{ tool_name: "Bash", tool_input: { command: "ls" }, tool_use_id: "tu-1" },
"tu-1",
{ signal: new AbortController().signal },
);
assert.deepEqual(result, { continue: true });
assert.equal(events.length, 1);
assert.equal(events[0].ev.hookEvent, "PreToolUse");
assert.equal(events[0].ev.toolName, "Bash");
});
test("buildCodebuddyHooks blocks non-Netcatty Bash commands in skills mode", async () => {
const { emitter } = collector();
emitter.emitEvent = () => {};
const hooks = buildCodebuddyHooks(emitter, {
toolIntegrationMode: "skills",
allowedCliCommandPrefix: "netcatty-tool-cli",
});
const preHook = hooks.PreToolUse[0].hooks[0];
assert.deepEqual(
await preHook(
{ tool_name: "Bash", tool_input: { command: "ls -la" }, tool_use_id: "tu-local" },
"tu-local",
{ signal: new AbortController().signal },
),
{
continue: true,
decision: "block",
reason:
"Only Netcatty CLI commands are allowed in Skills mode. " +
"Use the netcatty-tool-cli command prefix provided by the host.",
},
);
assert.deepEqual(
await preHook(
{
tool_name: "Bash",
tool_input: {
command: "netcatty-tool-cli session --session s1 --chat-session c1 --json",
},
tool_use_id: "tu-cli",
},
"tu-cli",
{ signal: new AbortController().signal },
),
{ continue: true },
);
assert.equal(
(await preHook(
{
tool_name: "Bash",
tool_input: {
command: "/tmp/netcatty-tool-cli status --json",
},
tool_use_id: "tu-impostor",
},
"tu-impostor",
{ signal: new AbortController().signal },
)).decision,
"block",
);
assert.equal(
(await preHook(
{
tool_name: "Bash",
tool_input: {
command: "netcatty-tool-cli status --json",
run_in_background: true,
},
tool_use_id: "tu-background",
},
"tu-background",
{ signal: new AbortController().signal },
)).decision,
"block",
);
});
test("buildCodebuddyHooks retains caller-provided lifecycle hooks", () => {
const { emitter } = collector();
emitter.emitEvent = () => {};
const custom = { hooks: [async () => ({ continue: true })] };
const hooks = buildCodebuddyHooks(emitter, {
toolIntegrationMode: "skills",
additionalHooks: { PreToolUse: [custom] },
});
assert.equal(hooks.PreToolUse.length, 2);
assert.equal(hooks.PreToolUse[1], custom);
});
// ---------------------------------------------------------------------------
// Elicitation
// ---------------------------------------------------------------------------
test("buildCodebuddyElicitation forwards create and resolves on response", async () => {
const { events, emitter } = collector();
emitter.emitEvent = (ev) => events.push({ k: "event", ev });
const pendingMap = new Map();
const handler = buildCodebuddyElicitation(emitter, pendingMap);
const controller = new AbortController();
const createPromise = handler.create(
{ _meta: { "codebuddy.ai": { elicitationId: "el-1" } }, message: "Confirm?" },
{ signal: controller.signal },
);
// Should have emitted elicitation-create event
assert.equal(events.length, 1);
assert.equal(events[0].ev.type, "elicitation-create");
assert.equal(events[0].ev.elicitationId, "el-1");
// Resolve the pending elicitation
assert.ok(pendingMap.has("el-1"));
pendingMap.get("el-1").resolve({ action: "accept", content: { confirmed: true } });
const response = await createPromise;
assert.deepEqual(response, { action: "accept", content: { confirmed: true } });
assert.equal(pendingMap.size, 0);
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
});
test("buildCodebuddyElicitation cancels immediately for an aborted signal", async () => {
const { events, emitter } = collector();
emitter.emitEvent = (ev) => events.push({ k: "event", ev });
const pendingMap = new Map();
const handler = buildCodebuddyElicitation(emitter, pendingMap);
const controller = new AbortController();
controller.abort();
const response = await handler.create(
{ _meta: { "codebuddy.ai": { elicitationId: "el-aborted" } } },
{ signal: controller.signal },
);
assert.deepEqual(response, { action: "cancel" });
assert.equal(pendingMap.size, 0);
assert.equal(events.length, 0);
});
test("buildCodebuddyElicitation tags pendings with chatSessionId and uses UUID fallback ids", async () => {
const { events, emitter } = collector();
emitter.emitEvent = (ev) => events.push({ k: "event", ev });
const pendingMap = new Map();
const handler = buildCodebuddyElicitation(emitter, pendingMap, { chatSessionId: "chat-1" });
// No _meta id — the fallback must be a UUID, distinct across creates so a
// same-millisecond collision cannot cancel the earlier pending.
const first = handler.create({ message: "one" }, {});
const second = handler.create({ message: "two" }, {});
const ids = [...pendingMap.keys()];
assert.equal(ids.length, 2);
assert.notEqual(ids[0], ids[1]);
for (const id of ids) {
assert.match(
id,
/^codebuddy:chat-1:elicitation_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
assert.equal(pendingMap.get(id).chatSessionId, "chat-1");
}
pendingMap.get(ids[0]).resolve({ action: "accept" });
pendingMap.get(ids[1]).resolve({ action: "cancel" });
assert.deepEqual(await first, { action: "accept" });
assert.deepEqual(await second, { action: "cancel" });
assert.equal(pendingMap.size, 0);
});
test("buildCodebuddyElicitation complete cancels and removes a pending create", async () => {
const { events, emitter } = collector();
emitter.emitEvent = (ev) => events.push({ k: "event", ev });
const pendingMap = new Map();
const handler = buildCodebuddyElicitation(emitter, pendingMap);
const controller = new AbortController();
const createPromise = handler.create(
{ _meta: { "codebuddy.ai": { elicitationId: "el-complete" } } },
{ signal: controller.signal },
);
handler.complete({ elicitationId: "el-complete" });
assert.deepEqual(await createPromise, { action: "cancel" });
assert.equal(pendingMap.size, 0);
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
assert.deepEqual(events.map(({ ev }) => ev.type), [
"elicitation-create",
"elicitation-complete",
]);
});
test("buildCodebuddyElicitation scopes identical protocol ids to their chat", async () => {
const pendingMap = new Map();
const firstEvents = [];
const secondEvents = [];
const firstHandler = buildCodebuddyElicitation(
{ emitEvent: (event) => firstEvents.push(event) },
pendingMap,
{ chatSessionId: "chat/one" },
);
const secondHandler = buildCodebuddyElicitation(
{ emitEvent: (event) => secondEvents.push(event) },
pendingMap,
{ chatSessionId: "chat/two" },
);
const first = firstHandler.create({
_meta: { "codebuddy.ai": { elicitationId: "confirm:1" } },
});
const second = secondHandler.create({
_meta: { "codebuddy.ai": { elicitationId: "confirm:1" } },
});
const firstId = firstEvents[0].elicitationId;
const secondId = secondEvents[0].elicitationId;
assert.equal(firstId, "codebuddy:chat%2Fone:confirm%3A1");
assert.equal(secondId, "codebuddy:chat%2Ftwo:confirm%3A1");
assert.equal(pendingMap.size, 2);
pendingMap.get(firstId).resolve({ action: "accept", content: { chat: "one" } });
pendingMap.get(secondId).resolve({ action: "decline" });
assert.deepEqual(await first, { action: "accept", content: { chat: "one" } });
assert.deepEqual(await second, { action: "decline" });
});
// ---------------------------------------------------------------------------
// MCP SSE/HTTP support
// ---------------------------------------------------------------------------
test("toSdkMcpServers supports sse, http, and sdk transport types", () => {
const fakeInstance = { __brand: "sdk-mcp" };
const map = toSdkMcpServers([
{ name: "stdio-server", command: "/bin/server", args: ["--port", "0"], env: [] },
{ name: "sse-server", type: "sse", url: "http://localhost:3000/sse", headers: { Authorization: "Bearer x" } },
{ name: "http-server", type: "http", url: "http://localhost:4000/mcp" },
{ name: "sdk-server", type: "sdk", instance: fakeInstance },
]);
assert.equal(map["stdio-server"].type, "stdio");
assert.equal(map["stdio-server"].command, "/bin/server");
assert.equal(map["sse-server"].type, "sse");
assert.equal(map["sse-server"].url, "http://localhost:3000/sse");
assert.deepEqual(map["sse-server"].headers, { Authorization: "Bearer x" });
assert.equal(map["http-server"].type, "http");
assert.equal(map["http-server"].url, "http://localhost:4000/mcp");
assert.equal(map["sdk-server"].type, "sdk");
assert.equal(map["sdk-server"].name, "sdk-server");
assert.equal(map["sdk-server"].instance, fakeInstance);
});
// ---------------------------------------------------------------------------
// Permission handler (canUseTool)
// ---------------------------------------------------------------------------
test("buildCodebuddyCanUseTool auto mode allows without prompting", async () => {
const handler = buildCodebuddyCanUseTool({ permissionMode: "auto" });
const result = await handler("Bash", { command: "rm -rf /tmp/x" }, {});
assert.deepEqual(result, { behavior: "allow" });
});
test("buildCodebuddyCanUseTool observer mode denies with message", async () => {
const handler = buildCodebuddyCanUseTool({ permissionMode: "observer" });
const result = await handler("Bash", { command: "ls" }, {});
assert.equal(result.behavior, "deny");
assert.ok(result.message.includes("Observer mode"));
});
test("buildCodebuddyCanUseTool confirm mode forwards to approval UI and allows on approve", async () => {
const calls = [];
const requestApproval = async (toolName, args, chatSessionId) => {
calls.push({ toolName, args, chatSessionId });
return true;
};
const handler = buildCodebuddyCanUseTool({
permissionMode: "confirm",
chatSessionId: "chat-1",
requestApproval,
});
const result = await handler("Bash", { command: "apt install nginx" }, {});
assert.deepEqual(result, { behavior: "allow" });
assert.equal(calls.length, 1);
assert.equal(calls[0].toolName, "Bash");
assert.deepEqual(calls[0].args, { command: "apt install nginx" });
assert.equal(calls[0].chatSessionId, "chat-1");
});
test("buildCodebuddyCanUseTool confirm mode denies on user rejection", async () => {
const handler = buildCodebuddyCanUseTool({
permissionMode: "confirm",
chatSessionId: "chat-1",
requestApproval: async () => false,
});
const result = await handler("Bash", { command: "reboot" }, {});
assert.equal(result.behavior, "deny");
assert.ok(result.message.includes("User denied"));
});
test("buildCodebuddyCanUseTool confirm mode denies when no approval channel", async () => {
const handler = buildCodebuddyCanUseTool({ permissionMode: "confirm" });
const result = await handler("Bash", {}, {});
assert.equal(result.behavior, "deny");
assert.ok(result.message.includes("no approval channel"));
});
test("buildCodebuddyQueryOptions attaches canUseTool handler", () => {
const handler = async () => ({ behavior: "allow" });
const opts = buildCodebuddyQueryOptions({ cwd: "/tmp", env: {}, canUseTool: handler });
assert.equal(opts.canUseTool, handler);
});

View File

@@ -0,0 +1,430 @@
"use strict";
/**
* CodeBuddy V2 Session Manager — @experimental
*
* Manages persistent multi-turn sessions using the SDK's unstable_v2 Session
* API (createSession / resumeSession). Falls back to the legacy query() path
* when the V2 API is unavailable.
*
* Benefits over query()-per-turn:
* - CLI process stays warm across turns (faster subsequent responses)
* - True multi-turn context without replaying history
* - Supports steer (mid-turn追加消息) via session.send()
*/
const {
buildCodebuddyQueryOptions,
buildCodebuddyPromptInput,
buildCodebuddyHooks,
buildCodebuddyElicitation,
translateCodebuddyMessage,
inspectCodebuddyMessageContent,
codebuddyResultFallbackText,
classifyCodebuddySpawnError,
} = require("./codebuddyDriver.cjs");
/**
* Compute a stable fingerprint from option-affecting fields so we can detect
* when the user changes model, env, permission mode, tools, etc. between turns.
* Only JSON-serializable fields are included; function-valued fields (hooks,
* canUseTool, elicitation) are excluded since they are rebuilt every turn.
*/
function computeOptionsFingerprint(sessionOptions) {
const relevant = {
cwd: sessionOptions.cwd,
model: sessionOptions.model,
env: sessionOptions.env,
pathToCodebuddyCode: sessionOptions.pathToCodebuddyCode,
mcpServers: sessionOptions.mcpServers,
permissionMode: sessionOptions.permissionMode,
extraArgs: sessionOptions.extraArgs,
systemPrompt: sessionOptions.systemPrompt,
tools: sessionOptions.tools,
disallowedTools: sessionOptions.disallowedTools,
settingSources: sessionOptions.settingSources,
maxTurns: sessionOptions.maxTurns,
agents: sessionOptions.agents,
thinking: sessionOptions.thinking,
effort: sessionOptions.effort,
hasHooks: Boolean(sessionOptions.hooks),
hasCanUseTool: typeof sessionOptions.canUseTool === "function",
hasElicitation: Boolean(sessionOptions.elicitation),
};
try {
return JSON.stringify(relevant);
} catch {
return null;
}
}
function createSessionCallbackState(sessionOptions) {
const state = {
elicitation: sessionOptions.elicitation,
elicitationDelegate: null,
};
if (state.elicitation) {
state.elicitationDelegate = {
create(request, options) {
const handler = state.elicitation;
return handler?.create
? handler.create(request, options)
: Promise.resolve({ action: "cancel" });
},
complete(notification) {
return state.elicitation?.complete?.(notification);
},
};
}
return state;
}
function refreshSessionCallbacks(entry, sessionOptions) {
if (sessionOptions.hooks) {
if (typeof entry.session.setHooks !== "function") return false;
entry.session.setHooks(sessionOptions.hooks);
}
if (typeof sessionOptions.canUseTool === "function") {
if (typeof entry.session.setCanUseTool !== "function") return false;
entry.session.setCanUseTool(sessionOptions.canUseTool);
}
if (sessionOptions.elicitation) {
if (!entry.callbackState?.elicitationDelegate) return false;
entry.callbackState.elicitation = sessionOptions.elicitation;
}
return true;
}
class CodebuddySessionManager {
constructor({ loadSdk } = {}) {
/** @type {Map<string, {
* session: object,
* fingerprint: string|null,
* callbackState?: ReturnType<typeof createSessionCallbackState>,
* }>} */
this.sessions = new Map();
/** @type {Map<string, { resolve: Function, reject: Function }>} */
this.elicitationPending = new Map();
this.loadSdk = loadSdk || (() => import("@tencent-ai/agent-sdk"));
}
/**
* Get an existing session or create/resume one.
* If the session exists but its option-affecting fields have changed,
* the stale session is closed and a fresh one is created.
* @param {object} args
* @param {string} args.sessionKey unique key (chatSessionId + backend + binPath)
* @param {object} args.sessionOptions SDK SessionOptions
* @param {string} [args.resumeSessionId] resume an existing session by ID
* @returns {Promise<object|null>} session instance or null if V2 unavailable
*/
async getOrCreateSession({ sessionKey, sessionOptions, resumeSessionId }) {
const fingerprint = computeOptionsFingerprint(sessionOptions);
const existing = this.sessions.get(sessionKey);
if (existing) {
// Reuse only when serialized options still match, but always refresh
// turn-scoped callbacks so events target the current request emitter.
if (fingerprint !== null && existing.fingerprint === fingerprint) {
try {
if (refreshSessionCallbacks(existing, sessionOptions)) {
return existing.session;
}
} catch {
// Recreate below if the installed SDK cannot refresh callbacks.
}
}
// Options changed — close the stale session and create a fresh one.
try { existing.session.close(); } catch { /* best effort */ }
this.sessions.delete(sessionKey);
}
let sdk;
try {
sdk = await this.loadSdk();
} catch {
return null;
}
const createSession = sdk.unstable_v2_createSession;
const resumeSession = sdk.unstable_v2_resumeSession;
if (!createSession || !resumeSession) return null;
let session;
try {
const callbackState = createSessionCallbackState(sessionOptions);
const sdkSessionOptions = callbackState.elicitationDelegate
? { ...sessionOptions, elicitation: callbackState.elicitationDelegate }
: sessionOptions;
if (resumeSessionId) {
session = resumeSession(resumeSessionId, sdkSessionOptions);
} else {
session = createSession(sdkSessionOptions);
}
// Do not connect before the first send. In resume mode, send() marks the
// initialization as having a prompt so the SDK does not replay historical
// messages into the new turn's stream.
this.sessions.set(sessionKey, { session, fingerprint, callbackState });
return session;
} catch {
// A factory failure can still leave a partially constructed session.
try { session?.close(); } catch { /* best effort */ }
// V2 session creation failed — caller should fall back to query().
return null;
}
}
/**
* Run a turn using the V2 Session API.
* Returns { sessionId, usedV2: true } on success, or null to signal fallback.
*/
async runTurn({
sessionKey, prompt, attachments, options, emitter,
sessionOptions, resumeSessionId,
}) {
const signal = options.abortController?.signal;
if (signal?.aborted) {
emitter.emitDone();
return { sessionId: null, usedV2: true };
}
const session = await this.getOrCreateSession({
sessionKey, sessionOptions, resumeSessionId,
});
if (!session) {
if (signal?.aborted) {
emitter.emitDone();
return { sessionId: null, usedV2: true };
}
return null; // signal caller to use query() fallback
}
const promptInput = buildCodebuddyPromptInput(prompt, attachments);
let sessionId = session.sessionId || null;
let hasContent = false;
let hasAssistantText = false;
let hasStreamedText = false;
let hasStreamedReasoning = false;
let hasTerminalError = false;
let resultFallbackText = "";
let emittedSessionId = null;
let removeAbortListener = null;
try {
// Register before sending so cancellation during connection or send
// cannot start a prompt without also interrupting the SDK session.
const interruptSession = () => {
if (typeof session.interrupt === "function") {
void Promise.resolve(session.interrupt()).catch((err) => {
console.debug("[CodeBuddy SDK] session interrupt failed:", err?.message || err);
});
}
};
if (signal) {
signal.addEventListener("abort", interruptSession, { once: true });
removeAbortListener = () => signal.removeEventListener("abort", interruptSession);
if (signal.aborted) {
interruptSession();
emitter.emitDone();
return { sessionId, usedV2: true };
}
}
try {
// Send before the initial connection so resumed sessions suppress
// historical replay and stream only the response to this prompt.
if (typeof promptInput === "string") {
await session.send(promptInput);
} else {
// Async iterable of UserMessage — send first message.
for await (const msg of promptInput) {
await session.send(msg);
}
}
} catch {
// Initial transport setup happens inside send(). Release any acquired
// session lock/process before the caller falls back to legacy query().
this.closeSession(sessionKey);
if (signal?.aborted) {
emitter.emitDone();
return { sessionId, usedV2: true };
}
return null;
}
if (signal?.aborted) {
emitter.emitDone();
return { sessionId, usedV2: true };
}
if (sessionId) {
emitter.sessionId(sessionId);
emittedSessionId = sessionId;
}
// Stream responses.
for await (const message of session.stream()) {
if (options.abortController?.signal?.aborted) {
try { await session.interrupt(); } catch (err) {
// Best effort — surface for diagnostics without failing the turn.
console.debug("[CodeBuddy SDK] session interrupt failed:", err?.message || err);
}
break;
}
if (message?.session_id && message.session_id !== sessionId) {
sessionId = message.session_id;
}
if (sessionId && sessionId !== emittedSessionId) {
emitter.sessionId(sessionId);
emittedSessionId = sessionId;
}
const contentState = inspectCodebuddyMessageContent(message);
if (contentState.hasContent) hasContent = true;
if (contentState.hasText) hasAssistantText = true;
resultFallbackText ||= codebuddyResultFallbackText(message);
const translation = translateCodebuddyMessage(
message,
emitter,
{
skipAssistantText: hasStreamedText,
skipAssistantReasoning: hasStreamedReasoning,
skipSessionId: true,
},
);
if (translation?.terminalError) hasTerminalError = true;
if (contentState.streamedText) hasStreamedText = true;
if (contentState.streamedReasoning) hasStreamedReasoning = true;
}
if (hasTerminalError) {
return { sessionId, usedV2: true };
}
if (!hasAssistantText && resultFallbackText) {
emitter.text(resultFallbackText);
hasContent = true;
}
if (!hasContent && !options.abortController?.signal?.aborted) {
emitter.emitError(
"CodeBuddy returned an empty response. Run `codebuddy` in a terminal to log in, " +
"or set CODEBUDDY_API_KEY / CODEBUDDY_AUTH_TOKEN.",
);
return { sessionId, usedV2: true };
}
emitter.emitDone();
return { sessionId, usedV2: true };
} catch (error) {
if (signal?.aborted) {
emitter.emitDone();
return { sessionId, usedV2: true };
}
// A stream failure means the transport is no longer safe to reuse. Close
// it now so the next turn can create/resume a fresh V2 session.
this.closeSession(sessionKey);
const classified = classifyCodebuddySpawnError(error);
if (classified.isSpawnEnoent) {
emitter.emitError(
"CodeBuddy CLI not found or not runnable. " +
"Install codebuddy and ensure it's on PATH, or set CODEBUDDY_CODE_PATH.",
);
} else {
emitter.emitError(classified.message || "CodeBuddy turn failed");
}
return { sessionId, usedV2: true };
} finally {
removeAbortListener?.();
}
}
/**
* Report mid-turn steer as unsupported for the current V2 Session API.
*/
async steer() {
// SDK 0.3.230 Session.send() starts a new turn by resetting the shared
// message iterator and discarding pending messages. Calling it while
// runTurn() owns session.stream() can strand that active consumer.
// Keep this disabled until the SDK exposes a dedicated mid-turn steer API.
return { status: "unsupported" };
}
/**
* Set model at runtime without rebuilding the session.
*/
async setModel(sessionKey, model) {
const entry = this.sessions.get(sessionKey);
if (!entry) return false;
try {
await entry.session.setModel(model);
return true;
} catch {
return false;
}
}
/**
* Close a specific session.
*/
closeSession(sessionKey) {
const entry = this.sessions.get(sessionKey);
if (entry) {
try { entry.session.close(); } catch { /* best effort */ }
this.sessions.delete(sessionKey);
}
}
/**
* Close all sessions for a given chat session prefix.
* Also cancels pending elicitations scoped to the chat so main-process
* promises cannot leak when the renderer never responds (chat closed).
*/
closeForChat(chatSessionId) {
const prefix = `${String(chatSessionId || "")}\u0000`;
for (const key of this.sessions.keys()) {
if (key.startsWith(prefix)) {
this.closeSession(key);
}
}
this.cancelElicitationsForChat(chatSessionId);
}
/**
* Close all sessions (app shutdown).
*/
closeAll() {
for (const key of [...this.sessions.keys()]) {
this.closeSession(key);
}
for (const [elicitationId, pending] of [...this.elicitationPending]) {
this.elicitationPending.delete(elicitationId);
try { pending.resolve({ action: "cancel" }); } catch { /* best effort */ }
}
}
/**
* Cancel pending elicitations belonging to a chat session, resolving each
* as { action: "cancel" } so waiting create() promises settle.
*/
cancelElicitationsForChat(chatSessionId) {
const target = String(chatSessionId || "");
for (const [elicitationId, pending] of [...this.elicitationPending]) {
if (String(pending?.chatSessionId || "") !== target) continue;
this.elicitationPending.delete(elicitationId);
try { pending.resolve({ action: "cancel" }); } catch { /* best effort */ }
}
}
/**
* Resolve a pending elicitation response from the renderer.
*/
resolveElicitation(elicitationId, response) {
const pending = this.elicitationPending.get(elicitationId);
if (pending) {
this.elicitationPending.delete(elicitationId);
pending.resolve(response);
return true;
}
return false;
}
}
// Singleton instance shared across the app lifecycle.
const codebuddySessionManager = new CodebuddySessionManager();
module.exports = { CodebuddySessionManager, codebuddySessionManager, computeOptionsFingerprint };

View File

@@ -0,0 +1,607 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { CodebuddySessionManager, computeOptionsFingerprint } = require("./codebuddySessionManager.cjs");
function collector() {
const events = [];
const emitter = {
text: (t) => events.push({ k: "text", t }),
reasoning: (d) => events.push({ k: "reasoning", d }),
toolCall: (name, args, id) => events.push({ k: "toolCall", name, args, id }),
toolResult: (id, out, name) => events.push({ k: "toolResult", id, out, name }),
usage: (usage) => events.push({ k: "usage", usage }),
status: (m) => events.push({ k: "status", m }),
sessionId: (s) => events.push({ k: "sessionId", s }),
emitDone: () => events.push({ k: "done" }),
emitError: (m) => events.push({ k: "error", m }),
emitEvent: (ev) => events.push({ k: "event", ev }),
};
return { events, emitter };
}
/** Create a fake V2 session that yields predefined messages. */
function fakeSession(messages, opts = {}) {
let sentMessages = [];
let closed = false;
let interruptCalls = 0;
return {
sessionId: opts.sessionId || "fake-sess-1",
sentMessages,
get closed() { return closed; },
get interruptCalls() { return interruptCalls; },
async connect() {},
async send(msg) { sentMessages.push(msg); },
async *stream() { for (const m of messages) yield m; },
async interrupt() { interruptCalls += 1; },
async setModel(model) { this._model = model; },
setHooks(hooks) { this._hooks = hooks; },
setCanUseTool(handler) { this._canUseTool = handler; },
close() { closed = true; },
};
}
test("getOrCreateSession reuses existing session when options match", async () => {
const mgr = new CodebuddySessionManager();
const session = fakeSession([], { sessionId: "existing-sess" });
const opts = { cwd: "/tmp", model: "glm-5" };
mgr.sessions.set("reuse-key", { session, fingerprint: computeOptionsFingerprint(opts) });
const result = await mgr.getOrCreateSession({
sessionKey: "reuse-key",
sessionOptions: opts,
});
assert.equal(result, session);
});
test("getOrCreateSession refreshes turn-scoped callbacks on a reused session", async () => {
let createdOptions;
const session = fakeSession([], { sessionId: "callback-session" });
const mgr = new CodebuddySessionManager({
loadSdk: async () => ({
unstable_v2_createSession: (options) => {
createdOptions = options;
return session;
},
unstable_v2_resumeSession: () => session,
}),
});
const firstEvents = [];
const secondEvents = [];
const firstOptions = {
cwd: "/tmp",
hooks: { Notification: [{ hooks: [() => firstEvents.push("hook")] }] },
canUseTool: async () => ({ behavior: "allow", updatedInput: {} }),
elicitation: {
create: async () => {
firstEvents.push("elicitation");
return { action: "accept" };
},
},
};
const secondOptions = {
cwd: "/tmp",
hooks: { Notification: [{ hooks: [() => secondEvents.push("hook")] }] },
canUseTool: async () => ({ behavior: "deny", message: "second turn" }),
elicitation: {
create: async () => {
secondEvents.push("elicitation");
return { action: "decline" };
},
},
};
const first = await mgr.getOrCreateSession({
sessionKey: "callback-key",
sessionOptions: firstOptions,
});
const second = await mgr.getOrCreateSession({
sessionKey: "callback-key",
sessionOptions: secondOptions,
});
assert.equal(first, session);
assert.equal(second, session);
assert.equal(session._hooks, secondOptions.hooks);
assert.equal(session._canUseTool, secondOptions.canUseTool);
assert.notEqual(createdOptions.elicitation, firstOptions.elicitation);
await session._hooks.Notification[0].hooks[0]();
assert.deepEqual(await session._canUseTool(), {
behavior: "deny",
message: "second turn",
});
assert.deepEqual(
await createdOptions.elicitation.create({}, { signal: new AbortController().signal }),
{ action: "decline" },
);
assert.deepEqual(firstEvents, []);
assert.deepEqual(secondEvents, ["hook", "elicitation"]);
});
test("getOrCreateSession closes stale session when options change", async () => {
const oldSession = fakeSession([], { sessionId: "old-sess" });
const replacementSession = fakeSession([], { sessionId: "new-sess" });
const mgr = new CodebuddySessionManager({
loadSdk: async () => ({
unstable_v2_createSession: () => replacementSession,
unstable_v2_resumeSession: () => replacementSession,
}),
});
const oldOpts = { cwd: "/tmp", model: "glm-4" };
const newOpts = { cwd: "/tmp", model: "glm-5" };
mgr.sessions.set("stale-key", {
session: oldSession,
fingerprint: computeOptionsFingerprint(oldOpts),
});
const result = await mgr.getOrCreateSession({
sessionKey: "stale-key",
sessionOptions: newOpts,
});
assert.ok(oldSession.closed);
assert.equal(result, replacementSession);
assert.equal(mgr.sessions.get("stale-key").session, replacementSession);
assert.equal(
mgr.sessions.get("stale-key").fingerprint,
computeOptionsFingerprint(newOpts),
);
});
test("runTurn closes a session when initial send fails before fallback", async () => {
const session = fakeSession([], { sessionId: "failed-connect-session" });
session.send = async () => {
throw new Error("connect failed");
};
const mgr = new CodebuddySessionManager({
loadSdk: async () => ({
unstable_v2_createSession: () => session,
unstable_v2_resumeSession: () => session,
}),
});
const { events, emitter } = collector();
const result = await mgr.runTurn({
sessionKey: "failed-connect-key",
prompt: "hello",
attachments: [],
options: { abortController: new AbortController() },
emitter,
sessionOptions: {},
});
assert.equal(result, null);
assert.equal(session.closed, true);
assert.equal(mgr.sessions.has("failed-connect-key"), false);
assert.deepEqual(events, []);
});
test("runTurn closes and evicts a session when response streaming fails", async () => {
const session = fakeSession([], { sessionId: "failed-stream-session" });
session.stream = async function* stream() {
throw new Error("transport died");
};
const mgr = new CodebuddySessionManager({
loadSdk: async () => ({
unstable_v2_createSession: () => session,
unstable_v2_resumeSession: () => session,
}),
});
const { events, emitter } = collector();
const result = await mgr.runTurn({
sessionKey: "failed-stream-key",
prompt: "hello",
attachments: [],
options: { abortController: new AbortController() },
emitter,
sessionOptions: {},
});
assert.deepEqual(result, {
sessionId: "failed-stream-session",
usedV2: true,
});
assert.equal(session.closed, true);
assert.equal(mgr.sessions.has("failed-stream-key"), false);
assert.deepEqual(events, [
{ k: "sessionId", s: "failed-stream-session" },
{ k: "error", m: "transport died" },
]);
});
test("computeOptionsFingerprint detects option changes", () => {
const base = {
cwd: "/tmp",
model: "glm-5",
maxTurns: 10,
effort: "high",
extraArgs: { "dangerously-skip-permissions": null },
};
const same = {
cwd: "/tmp",
model: "glm-5",
maxTurns: 10,
effort: "high",
extraArgs: { "dangerously-skip-permissions": null },
};
const diffModel = { cwd: "/tmp", model: "glm-4", maxTurns: 10, effort: "high" };
const diffMaxTurns = { cwd: "/tmp", model: "glm-5", maxTurns: 20, effort: "high" };
const diffEffort = { cwd: "/tmp", model: "glm-5", maxTurns: 10, effort: "low" };
const diffExtraArgs = {
...base,
extraArgs: { "dangerously-skip-permissions": "false" },
};
assert.equal(computeOptionsFingerprint(base), computeOptionsFingerprint(same));
assert.notEqual(computeOptionsFingerprint(base), computeOptionsFingerprint(diffModel));
assert.notEqual(computeOptionsFingerprint(base), computeOptionsFingerprint(diffMaxTurns));
assert.notEqual(computeOptionsFingerprint(base), computeOptionsFingerprint(diffEffort));
assert.notEqual(computeOptionsFingerprint(base), computeOptionsFingerprint(diffExtraArgs));
});
test("getOrCreateSession never reuses sessions with unserializable option fingerprints", async () => {
const circular = {};
circular.self = circular;
const oldSession = fakeSession([], { sessionId: "circular-old" });
const replacementSession = fakeSession([], { sessionId: "circular-new" });
const mgr = new CodebuddySessionManager({
loadSdk: async () => ({
unstable_v2_createSession: () => replacementSession,
unstable_v2_resumeSession: () => replacementSession,
}),
});
mgr.sessions.set("circular-key", {
session: oldSession,
fingerprint: computeOptionsFingerprint({ mcpServers: circular }),
});
const result = await mgr.getOrCreateSession({
sessionKey: "circular-key",
sessionOptions: { mcpServers: circular },
});
assert.equal(result, replacementSession);
assert.equal(oldSession.closed, true);
assert.equal(mgr.sessions.get("circular-key").session, replacementSession);
});
test("runTurn streams messages via V2 session when available", async () => {
const mgr = new CodebuddySessionManager();
const messages = [
{ type: "system", session_id: "sess-v2" },
{ type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "hi from v2" } } },
];
const session = fakeSession(messages, { sessionId: "sess-v2" });
// Pre-populate the session map to bypass SDK import.
mgr.sessions.set("preloaded-key", { session, fingerprint: computeOptionsFingerprint({}) });
const { events, emitter } = collector();
const result = await mgr.runTurn({
sessionKey: "preloaded-key",
prompt: "say hi",
attachments: [],
options: { abortController: new AbortController() },
emitter,
sessionOptions: {},
});
assert.deepEqual(result, { sessionId: "sess-v2", usedV2: true });
assert.ok(events.some((e) => e.k === "text" && e.t === "hi from v2"));
assert.ok(events.some((e) => e.k === "done"));
assert.deepEqual(
events.filter((event) => event.k === "sessionId"),
[{ k: "sessionId", s: "sess-v2" }],
);
assert.ok(session.sentMessages.includes("say hi"));
});
test("runTurn sends before connecting a resumed session and skips replayed history", async () => {
let explicitlyConnected = false;
const session = fakeSession([], { sessionId: "resumed-session" });
session.connect = async () => {
explicitlyConnected = true;
};
session.send = async (message) => {
session.sentMessages.push(message);
};
session.stream = async function* stream() {
if (explicitlyConnected) {
yield {
type: "assistant",
message: { content: [{ type: "text", text: "old response" }] },
};
}
yield {
type: "assistant",
message: { content: [{ type: "text", text: "new response" }] },
};
};
const mgr = new CodebuddySessionManager({
loadSdk: async () => ({
unstable_v2_createSession: () => session,
unstable_v2_resumeSession: () => session,
}),
});
const { events, emitter } = collector();
const result = await mgr.runTurn({
sessionKey: "resumed-key",
prompt: "new question",
attachments: [],
options: { abortController: new AbortController() },
emitter,
sessionOptions: {},
resumeSessionId: "resumed-session",
});
assert.deepEqual(result, { sessionId: "resumed-session", usedV2: true });
assert.equal(explicitlyConnected, false);
assert.deepEqual(session.sentMessages, ["new question"]);
assert.deepEqual(
events.filter((event) => event.k === "text").map((event) => event.t),
["new response"],
);
assert.deepEqual(
events.filter((event) => event.k === "sessionId"),
[{ k: "sessionId", s: "resumed-session" }],
);
});
test("runTurn does not connect or send when already aborted", async () => {
let loadSdkCalls = 0;
const mgr = new CodebuddySessionManager({
loadSdk: async () => {
loadSdkCalls += 1;
return {};
},
});
const controller = new AbortController();
controller.abort();
const { events, emitter } = collector();
const result = await mgr.runTurn({
sessionKey: "pre-aborted-key",
prompt: "must not run",
attachments: [],
options: { abortController: controller },
emitter,
sessionOptions: {},
});
assert.deepEqual(result, { sessionId: null, usedV2: true });
assert.equal(loadSdkCalls, 0);
assert.deepEqual(events, [{ k: "done" }]);
});
test("runTurn does not stream when aborted while the initial send connects", async () => {
let releaseSend;
const session = fakeSession([], { sessionId: "slow-connect-session" });
session.send = (message) => new Promise((resolve) => {
session.sentMessages.push(message);
releaseSend = resolve;
});
const mgr = new CodebuddySessionManager({
loadSdk: async () => ({
unstable_v2_createSession: () => session,
unstable_v2_resumeSession: () => session,
}),
});
const controller = new AbortController();
const { events, emitter } = collector();
const runPromise = mgr.runTurn({
sessionKey: "slow-connect-key",
prompt: "must not run",
attachments: [],
options: { abortController: controller },
emitter,
sessionOptions: {},
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(typeof releaseSend, "function");
controller.abort();
releaseSend();
const result = await runPromise;
assert.deepEqual(result, {
sessionId: "slow-connect-session",
usedV2: true,
});
assert.deepEqual(session.sentMessages, ["must not run"]);
assert.equal(session.interruptCalls, 1);
assert.deepEqual(events, [{ k: "done" }]);
});
test("runTurn treats an abort rejection while streaming as normal completion", async () => {
let rejectStream;
const session = fakeSession([], { sessionId: "stream-abort-session" });
session.stream = async function* stream() {
await new Promise((_resolve, reject) => {
rejectStream = reject;
});
};
session.interrupt = async () => {
rejectStream?.(new Error("interrupted"));
};
const mgr = new CodebuddySessionManager({
loadSdk: async () => ({
unstable_v2_createSession: () => session,
unstable_v2_resumeSession: () => session,
}),
});
const controller = new AbortController();
const { events, emitter } = collector();
const runPromise = mgr.runTurn({
sessionKey: "stream-abort-key",
prompt: "wait",
attachments: [],
options: { abortController: controller },
emitter,
sessionOptions: {},
});
await new Promise((resolve) => setImmediate(resolve));
controller.abort();
assert.deepEqual(await runPromise, {
sessionId: "stream-abort-session",
usedV2: true,
});
assert.ok(mgr.sessions.has("stream-abort-key"));
assert.equal(session.closed, false);
assert.deepEqual(events, [
{ k: "sessionId", s: "stream-abort-session" },
{ k: "done" },
]);
});
test("steer returns unsupported when no session exists", async () => {
const mgr = new CodebuddySessionManager();
const { emitter } = collector();
const result = await mgr.steer({
sessionKey: "nonexistent",
prompt: "follow up",
attachments: [],
emitter,
});
assert.deepEqual(result, { status: "unsupported" });
});
test("steer stays unsupported because Session.send resets the active SDK stream", async () => {
const mgr = new CodebuddySessionManager();
const session = fakeSession([]);
mgr.sessions.set("steer-key", {
session,
fingerprint: computeOptionsFingerprint({}),
});
const result = await mgr.steer({
sessionKey: "steer-key",
prompt: "now do this",
attachments: [],
});
assert.deepEqual(result, { status: "unsupported" });
assert.deepEqual(session.sentMessages, []);
});
test("closeSession removes and closes the session", () => {
const mgr = new CodebuddySessionManager();
const session = fakeSession([]);
mgr.sessions.set("close-key", { session, fingerprint: null });
mgr.closeSession("close-key");
assert.ok(!mgr.sessions.has("close-key"));
assert.ok(session.closed);
});
test("closeForChat closes all sessions matching the chat prefix", () => {
const mgr = new CodebuddySessionManager();
const s1 = fakeSession([]);
const s2 = fakeSession([]);
const s3 = fakeSession([]);
mgr.sessions.set("chat1\u0000codebuddy\u0000/bin/cb\u0000sdk", { session: s1, fingerprint: null });
mgr.sessions.set("chat1\u0000codebuddy\u0000/other/cb\u0000sdk", { session: s2, fingerprint: null });
mgr.sessions.set("chat2\u0000codebuddy\u0000/bin/cb\u0000sdk", { session: s3, fingerprint: null });
mgr.closeForChat("chat1");
assert.ok(!mgr.sessions.has("chat1\u0000codebuddy\u0000/bin/cb\u0000sdk"));
assert.ok(!mgr.sessions.has("chat1\u0000codebuddy\u0000/other/cb\u0000sdk"));
assert.ok(mgr.sessions.has("chat2\u0000codebuddy\u0000/bin/cb\u0000sdk"));
assert.ok(s1.closed);
assert.ok(s2.closed);
assert.ok(!s3.closed);
});
test("closeForChat cancels pending elicitations scoped to the chat", () => {
const mgr = new CodebuddySessionManager();
const resolved = [];
mgr.elicitationPending.set("el-chat1", {
resolve: (v) => resolved.push(["el-chat1", v]),
reject: () => {},
chatSessionId: "chat1",
});
mgr.elicitationPending.set("el-chat2", {
resolve: (v) => resolved.push(["el-chat2", v]),
reject: () => {},
chatSessionId: "chat2",
});
mgr.closeForChat("chat1");
assert.deepEqual(resolved, [["el-chat1", { action: "cancel" }]]);
assert.ok(!mgr.elicitationPending.has("el-chat1"));
assert.ok(mgr.elicitationPending.has("el-chat2"));
});
test("closeAll closes every session", () => {
const mgr = new CodebuddySessionManager();
const s1 = fakeSession([]);
const s2 = fakeSession([]);
mgr.sessions.set("a", { session: s1, fingerprint: null });
mgr.sessions.set("b", { session: s2, fingerprint: null });
mgr.closeAll();
assert.equal(mgr.sessions.size, 0);
assert.ok(s1.closed);
assert.ok(s2.closed);
});
test("closeAll cancels every pending elicitation", () => {
const mgr = new CodebuddySessionManager();
const resolved = [];
mgr.elicitationPending.set("el-a", {
resolve: (v) => resolved.push(["el-a", v]),
reject: () => {},
chatSessionId: "chat1",
});
mgr.elicitationPending.set("el-b", {
resolve: (v) => resolved.push(["el-b", v]),
reject: () => {},
chatSessionId: "chat2",
});
mgr.closeAll();
assert.equal(mgr.elicitationPending.size, 0);
assert.deepEqual(resolved, [
["el-a", { action: "cancel" }],
["el-b", { action: "cancel" }],
]);
});
test("setModel returns false when session does not exist", async () => {
const mgr = new CodebuddySessionManager();
const result = await mgr.setModel("missing", "new-model");
assert.equal(result, false);
});
test("setModel delegates to the session", async () => {
const mgr = new CodebuddySessionManager();
const session = fakeSession([]);
mgr.sessions.set("model-key", { session, fingerprint: null });
const result = await mgr.setModel("model-key", "glm-5");
assert.equal(result, true);
assert.equal(session._model, "glm-5");
});
test("resolveElicitation resolves pending and returns true", () => {
const mgr = new CodebuddySessionManager();
let resolved;
mgr.elicitationPending.set("el-1", {
resolve: (v) => { resolved = v; },
reject: () => {},
});
const ok = mgr.resolveElicitation("el-1", { action: "accept" });
assert.equal(ok, true);
assert.deepEqual(resolved, { action: "accept" });
assert.ok(!mgr.elicitationPending.has("el-1"));
});
test("resolveElicitation returns false for unknown id", () => {
const mgr = new CodebuddySessionManager();
const ok = mgr.resolveElicitation("unknown", { action: "cancel" });
assert.equal(ok, false);
});

View File

@@ -0,0 +1,418 @@
"use strict";
/**
* Codex backend driver — wraps @openai/codex-sdk.
*
* new Codex({ codexPathOverride, env, apiKey, config }).startThread({...}).runStreamed(...)
* - sandbox:'read-only' blocks local writes; side effects must go through the
* injected netcatty MCP server (config.mcp_servers).
* - thread.id is the resumable session id; codex.resumeThread(id) continues it.
*
* Constructor/event field names are calibrated against @openai/codex-sdk's type
* defs (CodexOptions.codexPathOverride; AgentMessageItem / CommandExecutionItem /
* McpToolCallItem). `env` is also passed so the binary resolves on PATH. Live
* smoke confirms end-to-end behavior.
*/
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
function isImageAttachment(attachment) {
return Boolean(
attachment &&
typeof attachment.filePath === "string" &&
attachment.filePath.length > 0 &&
String(attachment.mediaType || "").toLowerCase().startsWith("image/"),
);
}
function buildCodexPromptInput(prompt, attachments) {
const imageAttachments = Array.isArray(attachments)
? attachments.filter(isImageAttachment)
: [];
if (imageAttachments.length === 0) return String(prompt || "");
return [
{ type: "text", text: String(prompt || "") },
...imageAttachments.map((attachment) => ({
type: "local_image",
path: attachment.filePath,
})),
];
}
function toCodexMcpConfig(injectedMcpServers, { defaultToolsApprovalMode } = {}) {
const mcp_servers = {};
for (const cfg of injectedMcpServers || []) {
if (!cfg || !cfg.name) continue;
mcp_servers[cfg.name] = {
command: cfg.command,
args: cfg.args || [],
env: mcpEnvPairsToObject(cfg.env),
...(defaultToolsApprovalMode
? { default_tools_approval_mode: defaultToolsApprovalMode }
: {}),
};
}
return mcp_servers;
}
function buildCodexConstructorOptions({ codexPath, env, apiKey, injectedMcpServers, baseUrl }) {
const options = {
env,
config: {
mcp_servers: toCodexMcpConfig(injectedMcpServers),
// Force codex to emit reasoning SUMMARY items in the JSON stream. The
// default ("auto") emits nothing in non-interactive `codex exec` (measured:
// 0 summaries across runs), so the thinking panel went empty after the SDK
// migration. "concise" restores visible step-by-step reasoning reliably
// (measured: a summary on every reasoning turn) at the right altitude for a
// terminal assistant — "detailed" is richer but noisier and less reliable.
model_reasoning_summary: "concise",
},
};
if (codexPath) options.codexPathOverride = codexPath; // 🔬 SMOKE-CALIBRATE [codex-path]
if (apiKey) options.apiKey = apiKey;
if (baseUrl) options.baseUrl = baseUrl;
return options;
}
// codex-sdk reasoning-effort levels (GPT-5.6 also advertises max/ultra).
const CODEX_REASONING_EFFORTS = new Set([
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
"ultra",
]);
function parseCodexModelSelection(model) {
const value = String(model || "");
const slash = value.lastIndexOf("/");
const effort = slash > 0 ? value.slice(slash + 1) : "";
if (slash > 0 && CODEX_REASONING_EFFORTS.has(effort)) {
return { model: value.slice(0, slash), effort };
}
return { model: value || undefined, effort: undefined };
}
function buildCodexThreadOptions({ cwd, model }) {
// model + sandboxMode + workingDirectory belong to ThreadOptions (startThread).
// runStreamed's TurnOptions only accepts { outputSchema, signal }, so passing
// them there (the previous behavior) silently dropped both model selection and
// the read-only sandbox.
//
// Non-interactive `codex exec` CANCELS every MCP tool call ("user cancelled
// MCP tool call", failing in 0ns before the server is even invoked) unless
// approvals are fully bypassed. Empirically (tested across all sandbox ×
// approval combos) the ONLY combo that lets injected netcatty MCP tools run is
// sandbox "danger-full-access" + approvalPolicy "never" — i.e. codex's
// `--dangerously-bypass-approvals-and-sandbox`. read-only and workspace-write
// both cancel under every approval policy, because codex wants an interactive
// approver for MCP calls and exec has no channel to answer one.
//
// Safe for netcatty's model: the REAL guardrails (approval prompts, command
// blocklist, observer/confirm permission modes, session scope) are enforced by
// the injected netcatty MCP server on every remote-host action — NOT by codex's
// local sandbox. claude blocks its built-in side-effect tools via
// disallowedTools and copilot is MCP-only; codex-sdk exposes no tool-disable
// switch, so the sandbox is the only lever and it has to be fully open for the
// MCP path to work at all.
const opts = { sandboxMode: "danger-full-access", approvalPolicy: "never", skipGitRepoCheck: true };
if (cwd) opts.workingDirectory = cwd;
if (model) {
// The renderer encodes codex reasoning effort as "<modelId>/<effort>"
// (e.g. "gpt-5.5/high"). codex-sdk wants them as separate ThreadOptions.
// Only split when the trailing segment is a real effort — custom/OpenRouter
// model ids may legitimately contain "/".
const selection = parseCodexModelSelection(model);
opts.model = selection.model;
if (selection.effort) opts.modelReasoningEffort = selection.effort;
}
return opts;
}
/**
* Extract a display string from a Codex mcp_tool_call item.
* Calibrated against @openai/codex-sdk McpToolCallItem: successful calls carry
* `result.content` as an MCP ContentBlock[] (text blocks); failures carry
* `error.message`.
*/
function extractMcpResultText(item) {
if (item.error && item.error.message) return String(item.error.message);
const content = item.result && item.result.content;
if (Array.isArray(content)) {
return content
.map((b) => (b && typeof b.text === "string" ? b.text : (b == null ? "" : JSON.stringify(b))))
.join("");
}
if (item.result != null) return JSON.stringify(item.result);
return "";
}
function ensureStateSet(state, key) {
if (!state[key]) state[key] = new Set();
return state[key];
}
function ensureStateMap(state, key) {
if (!state[key]) state[key] = new Map();
return state[key];
}
function emitCodexReasoning(item, emitter, state) {
if (!item || typeof item.text !== "string" || !item.text) return;
const textById = ensureStateMap(state, "reasoningTextById");
const itemId = item.id || "__default_reasoning";
const previous = textById.get(itemId) || "";
const delta = item.text.startsWith(previous) ? item.text.slice(previous.length) : item.text;
textById.set(itemId, item.text);
if (delta) {
emitter.reasoning(delta);
state.reasoningOpen = true;
}
}
function emitCodexToolCallOnce(item, emitter, state, toolName, args) {
if (!item || !item.id) return false;
const emittedToolCalls = ensureStateSet(state, "emittedToolCalls");
if (emittedToolCalls.has(item.id)) return false;
emittedToolCalls.add(item.id);
emitter.toolCall(toolName, args || {}, item.id);
return true;
}
function emitCodexToolResultOnce(item, emitter, state, output, toolName) {
if (!item || !item.id) return false;
const emittedToolResults = ensureStateSet(state, "emittedToolResults");
if (emittedToolResults.has(item.id)) return false;
emittedToolResults.add(item.id);
emitter.toolResult(item.id, output || "", toolName);
return true;
}
/**
* Codex emits mid-turn `type:"error"` JSONL events while it reconnects after a
* dropped SSE/response body (`Reconnecting...`, `retrying N/M`). Those are
* recoverable — the same turn keeps producing items afterward. Treating them
* as fatal settles the Netcatty sidebar turn and stops UI refresh while the CLI
* process continues (issue #2456).
*
* Explicit `willRetry: false` / `will_retry: false` means Codex exhausted its
* retry budget — always fatal, even when the message still mentions stream /
* transport wording. Truly terminal failures also arrive as `turn.failed`.
*/
function isCodexRetryableStreamError(event) {
if (!event || typeof event !== "object") return false;
if (event.willRetry === false || event.will_retry === false) return false;
if (event.willRetry === true || event.will_retry === true) return true;
const message = String(event.message || "").toLowerCase();
if (!message) return false;
return /\breconnecting\b/.test(message) || /\bretrying\b/.test(message);
}
/**
* Translate one Codex ThreadEvent into emitter calls.
* `state` ({ reasoningOpen }) is threaded across events so reasoning summary
* items render as a single collapsible thinking panel that closes when the first
* non-reasoning content (assistant message / tool call) arrives.
*/
function translateCodexEvent(event, emitter, state) {
if (!event || typeof event !== "object") return;
const st = state || {};
const closeReasoning = () => {
if (st.reasoningOpen) { emitter.reasoningEnd(); st.reasoningOpen = false; }
};
if (event.type === "turn.failed") {
closeReasoning();
st.fatalError = true;
emitter.emitError(event.error?.message || "Codex turn failed");
return;
}
if (event.type === "error") {
const message = event.message || "Codex stream failed";
if (isCodexRetryableStreamError(event)) {
// Keep reasoning open — the turn is still in progress after Codex retries.
const warningCount = (st.streamWarningCount = (st.streamWarningCount || 0) + 1);
emitter.warning(`codex-stream-error:${warningCount}`, message);
return;
}
closeReasoning();
st.fatalError = true;
emitter.emitError(message);
return;
}
if (event.type === "turn.completed") {
const usage = event.usage;
const hasUsage = usage && [
usage.input_tokens,
usage.cached_input_tokens,
usage.output_tokens,
usage.reasoning_output_tokens,
].some((value) => Number.isFinite(value));
if (!hasUsage) return;
const inputTokens = Number(usage.input_tokens) || 0;
const outputTokens = Number(usage.output_tokens) || 0;
emitter.usage({
inputTokens,
cachedInputTokens: Number(usage.cached_input_tokens) || 0,
outputTokens,
reasoningTokens: Number(usage.reasoning_output_tokens) || 0,
totalTokens: inputTokens + outputTokens,
});
return;
}
if (!["item.started", "item.updated", "item.completed"].includes(event.type) || !event.item) return;
const item = event.item;
// Reasoning summary items feed the thinking panel. Codex may update the same
// item with cumulative text before completion, so emit only the new suffix.
if (item.type === "reasoning") {
emitCodexReasoning(item, emitter, st);
return;
}
closeReasoning();
switch (item.type) {
case "agent_message":
if (event.type === "item.completed" && item.text) emitter.text(item.text);
return;
case "command_execution": {
// Calibrated against @openai/codex-sdk CommandExecutionItem (command +
// aggregated_output).
emitCodexToolCallOnce(item, emitter, st, "shell", { command: item.command || "" });
if (event.type === "item.completed" && item.aggregated_output) {
emitCodexToolResultOnce(item, emitter, st, item.aggregated_output, "shell");
}
return;
}
case "mcp_tool_call": {
// Calibrated against @openai/codex-sdk McpToolCallItem (tool + arguments;
// result.content is an MCP ContentBlock[], errors carry .message).
const toolName = item.tool || "mcp_tool";
emitCodexToolCallOnce(item, emitter, st, toolName, item.arguments || {});
if (event.type === "item.completed") {
emitCodexToolResultOnce(item, emitter, st, extractMcpResultText(item), toolName);
}
return;
}
case "file_change":
if (event.type === "item.completed") {
emitter.fileChange(
item.id,
Array.isArray(item.changes) ? item.changes : [],
item.status === "failed" ? "failed" : "completed",
);
}
return;
case "web_search":
emitter.webSearch(
item.id,
item.query || "",
event.type === "item.completed" ? "completed" : "running",
);
return;
case "todo_list":
emitter.planUpdate(
item.id,
Array.isArray(item.items) ? item.items : [],
event.type === "item.completed" ? "completed" : "running",
);
return;
case "error":
if (event.type === "item.completed") {
emitter.warning(item.id, item.message || "Codex reported a recoverable error");
}
return;
default:
return;
}
}
/**
* Run a Codex turn.
* @param {object} args
* @param {string} args.prompt
* @param {Array<object>} [args.attachments]
* @param {object} args.constructorOptions buildCodexConstructorOptions(...)
* @param {object} args.threadOptions buildCodexThreadOptions(...) — model / sandboxMode / workingDirectory
* @param {string} [args.resumeThreadId]
* @param {object} args.emitter
* @param {AbortSignal} [args.signal]
* @param {Function} [args.CodexCtor] inject Codex class (for tests)
*/
async function runCodexTurn({
prompt, attachments, constructorOptions, threadOptions, resumeThreadId, emitter, signal, CodexCtor,
}) {
const Codex = CodexCtor || (await import("@openai/codex-sdk")).Codex;
const promptInput = buildCodexPromptInput(prompt, attachments);
let threadId = null;
try {
const codex = new Codex(constructorOptions);
// ThreadOptions (model + read-only sandbox + cwd) must be applied on resume too.
const thread = resumeThreadId
? codex.resumeThread(resumeThreadId, threadOptions)
: codex.startThread(threadOptions);
const { events } = await thread.runStreamed(promptInput, signal ? { signal } : undefined);
let hasContent = false;
const state = { reasoningOpen: false };
for await (const event of events) {
// Capture + emit the resumable thread id as EARLY as possible — it exists
// the moment `thread.started` arrives (the first event). Emitting it only at
// the END of the turn (the old behavior) meant a mid-turn Stop never
// persisted it, so the NEXT turn opened a fresh thread and the whole session
// lost its memory. Verified: codex resume survives an aborted turn, so
// preserving the id is enough to keep context across a Stop.
if (!threadId) {
const tid = thread.id || (event && event.type === "thread.started" ? event.thread_id : null);
if (tid) { threadId = tid; emitter.sessionId(threadId); }
}
if (signal?.aborted) break;
if (event?.type === "item.completed") hasContent = true;
translateCodexEvent(event, emitter, state);
if (state.fatalError) break;
}
if (state.reasoningOpen) emitter.reasoningEnd();
if (!threadId) {
threadId = thread.id || resumeThreadId || null;
if (threadId) emitter.sessionId(threadId);
}
if (state.fatalError) {
return { threadId };
}
if (!hasContent && !signal?.aborted) {
emitter.emitError(
"Codex returned an empty response. Reconnect Codex in Settings -> AI (codex login), " +
"or configure a provider in ~/.codex/config.toml.",
);
return { threadId };
}
emitter.emitDone();
return { threadId };
} catch (error) {
const code = error && error.code;
const msg = String((error && error.message) || error || "");
if (code === "ENOENT" || /ENOENT/i.test(msg)) {
emitter.emitError(
"Codex binary not found. Install with `npm i -g @openai/codex` (or `brew install --cask codex`).",
);
} else {
emitter.emitError(msg || "Codex turn failed");
}
return { threadId };
}
}
module.exports = {
buildCodexConstructorOptions,
buildCodexThreadOptions,
buildCodexPromptInput,
parseCodexModelSelection,
translateCodexEvent,
runCodexTurn,
toCodexMcpConfig,
};

View File

@@ -0,0 +1,535 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
translateCodexEvent,
buildCodexConstructorOptions,
buildCodexThreadOptions,
buildCodexPromptInput,
runCodexTurn,
toCodexMcpConfig,
} = require("./codexDriver.cjs");
function collector() {
const events = [];
return {
events,
emitter: {
text: (t) => events.push({ k: "text", t }),
reasoning: (d) => events.push({ k: "reasoning", d }),
reasoningEnd: () => events.push({ k: "reasoningEnd" }),
toolCall: (n, a, id) => events.push({ k: "toolCall", n, a, id }),
toolResult: (id, o, n) => events.push({ k: "toolResult", id, o, n }),
fileChange: (id, changes, status) => events.push({ k: "fileChange", id, changes, status }),
webSearch: (id, query, status) => events.push({ k: "webSearch", id, query, status }),
planUpdate: (id, items, status) => events.push({ k: "planUpdate", id, items, status }),
warning: (id, message) => events.push({ k: "warning", id, message }),
usage: (usage) => events.push({ k: "usage", usage }),
status: (m) => events.push({ k: "status", m }),
sessionId: (s) => events.push({ k: "sessionId", s }),
emitError: (e) => events.push({ k: "error", e }),
emitDone: () => events.push({ k: "done" }),
},
};
}
test("agent_message item -> text event", () => {
const { events, emitter } = collector();
translateCodexEvent({ type: "item.completed", item: { type: "agent_message", text: "answer" } }, emitter);
assert.deepEqual(events, [{ k: "text", t: "answer" }]);
});
test("reasoning item -> reasoning event (thinking panel), not plain text", () => {
const { events, emitter } = collector();
const state = { reasoningOpen: false };
translateCodexEvent({ type: "item.completed", item: { type: "reasoning", text: "**Plan**" } }, emitter, state);
assert.deepEqual(events, [{ k: "reasoning", d: "**Plan**" }]);
assert.equal(state.reasoningOpen, true);
});
test("reasoning then agent_message -> reasoning, reasoningEnd, text (block closes on content)", () => {
const { events, emitter } = collector();
const state = { reasoningOpen: false };
translateCodexEvent({ type: "item.completed", item: { type: "reasoning", text: "step 1" } }, emitter, state);
translateCodexEvent({ type: "item.completed", item: { type: "reasoning", text: "step 2" } }, emitter, state);
translateCodexEvent({ type: "item.completed", item: { type: "agent_message", text: "done" } }, emitter, state);
assert.deepEqual(events, [
{ k: "reasoning", d: "step 1" },
{ k: "reasoning", d: "step 2" },
{ k: "reasoningEnd" },
{ k: "text", t: "done" },
]);
assert.equal(state.reasoningOpen, false);
});
test("reasoning item updates stream only new thinking text", () => {
const { events, emitter } = collector();
const state = { reasoningOpen: false };
const item = { id: "r-1", type: "reasoning" };
translateCodexEvent({ type: "item.started", item: { ...item, text: "step 1" } }, emitter, state);
translateCodexEvent({ type: "item.updated", item: { ...item, text: "step 1\nstep 2" } }, emitter, state);
translateCodexEvent({ type: "item.completed", item: { ...item, text: "step 1\nstep 2" } }, emitter, state);
translateCodexEvent({ type: "item.completed", item: { type: "agent_message", text: "done" } }, emitter, state);
assert.deepEqual(events, [
{ k: "reasoning", d: "step 1" },
{ k: "reasoning", d: "\nstep 2" },
{ k: "reasoningEnd" },
{ k: "text", t: "done" },
]);
});
test("mcp_tool_call item -> toolCall + toolResult events (extracts content text)", () => {
const { events, emitter } = collector();
translateCodexEvent(
{
type: "item.completed",
item: {
type: "mcp_tool_call", id: "i-1",
server: "netcatty-remote-hosts", tool: "terminal_execute",
arguments: { command: "ls" },
result: { content: [{ type: "text", text: "files" }] },
status: "completed",
},
},
emitter,
);
assert.deepEqual(events.map((e) => e.k), ["toolCall", "toolResult"]);
assert.equal(events[0].id, "i-1");
assert.equal(events[0].n, "terminal_execute");
assert.equal(events[1].o, "files");
});
test("mcp_tool_call streams start early and completes without duplicate tool cards", () => {
const { events, emitter } = collector();
const state = { reasoningOpen: false };
const item = {
type: "mcp_tool_call", id: "i-live",
server: "netcatty-remote-hosts", tool: "terminal_execute",
arguments: { command: "uptime" },
};
translateCodexEvent({ type: "item.started", item: { ...item, status: "in_progress" } }, emitter, state);
assert.deepEqual(events, [
{ k: "toolCall", n: "terminal_execute", a: { command: "uptime" }, id: "i-live" },
]);
translateCodexEvent({ type: "item.updated", item: { ...item, status: "in_progress" } }, emitter, state);
translateCodexEvent(
{
type: "item.completed",
item: {
...item,
result: { content: [{ type: "text", text: "up 1 day" }] },
status: "completed",
},
},
emitter,
state,
);
assert.deepEqual(events, [
{ k: "toolCall", n: "terminal_execute", a: { command: "uptime" }, id: "i-live" },
{ k: "toolResult", id: "i-live", o: "up 1 day", n: "terminal_execute" },
]);
});
test("command_execution streams start early and completes without duplicate tool cards", () => {
const { events, emitter } = collector();
const state = { reasoningOpen: false };
const item = { type: "command_execution", id: "cmd-live", command: "pwd" };
translateCodexEvent({ type: "item.started", item: { ...item, status: "in_progress", aggregated_output: "" } }, emitter, state);
assert.deepEqual(events, [
{ k: "toolCall", n: "shell", a: { command: "pwd" }, id: "cmd-live" },
]);
translateCodexEvent({ type: "item.updated", item: { ...item, status: "in_progress", aggregated_output: "/tmp" } }, emitter, state);
translateCodexEvent({ type: "item.completed", item: { ...item, status: "completed", aggregated_output: "/tmp\n" } }, emitter, state);
assert.deepEqual(events, [
{ k: "toolCall", n: "shell", a: { command: "pwd" }, id: "cmd-live" },
{ k: "toolResult", id: "cmd-live", o: "/tmp\n", n: "shell" },
]);
});
test("mcp_tool_call failure -> toolResult carries the error message", () => {
const { events, emitter } = collector();
translateCodexEvent(
{
type: "item.completed",
item: {
type: "mcp_tool_call", id: "i-2",
server: "netcatty-remote-hosts", tool: "terminal_execute",
arguments: {}, error: { message: "denied by observer" }, status: "failed",
},
},
emitter,
);
assert.equal(events[1].o, "denied by observer");
});
test("turn.failed -> error event", () => {
const { events, emitter } = collector();
translateCodexEvent({ type: "turn.failed", error: { message: "stale login" } }, emitter);
assert.deepEqual(events, [{ k: "error", e: "stale login" }]);
});
test("turn.completed emits actual token usage", () => {
const { events, emitter } = collector();
translateCodexEvent({
type: "turn.completed",
usage: {
input_tokens: 100,
cached_input_tokens: 40,
output_tokens: 25,
reasoning_output_tokens: 10,
},
}, emitter);
assert.deepEqual(events, [{
k: "usage",
usage: {
inputTokens: 100,
cachedInputTokens: 40,
outputTokens: 25,
reasoningTokens: 10,
totalTokens: 125,
},
}]);
});
test("turn.completed without usage preserves the estimated fallback", () => {
const { events, emitter } = collector();
translateCodexEvent({ type: "turn.completed", usage: {} }, emitter);
assert.deepEqual(events, []);
});
test("file changes emit once on completion", () => {
const { events, emitter } = collector();
const item = {
id: "patch-1",
type: "file_change",
changes: [{ path: "src/app.ts", kind: "update" }],
status: "completed",
};
translateCodexEvent({ type: "item.started", item }, emitter);
translateCodexEvent({ type: "item.completed", item }, emitter);
assert.deepEqual(events, [{
k: "fileChange",
id: "patch-1",
changes: item.changes,
status: "completed",
}]);
});
test("web search and todo list updates keep stable item ids", () => {
const { events, emitter } = collector();
translateCodexEvent({
type: "item.started",
item: { id: "search-1", type: "web_search", query: "Codex SDK events" },
}, emitter);
translateCodexEvent({
type: "item.completed",
item: { id: "search-1", type: "web_search", query: "Codex SDK events" },
}, emitter);
translateCodexEvent({
type: "item.updated",
item: {
id: "plan-1",
type: "todo_list",
items: [{ text: "Map events", completed: false }],
},
}, emitter);
translateCodexEvent({
type: "item.completed",
item: {
id: "plan-1",
type: "todo_list",
items: [{ text: "Map events", completed: true }],
},
}, emitter);
assert.deepEqual(events.map((event) => [event.k, event.id, event.status]), [
["webSearch", "search-1", "running"],
["webSearch", "search-1", "completed"],
["planUpdate", "plan-1", "running"],
["planUpdate", "plan-1", "completed"],
]);
});
test("item errors and reconnectable stream errors are warnings; other stream errors stay fatal", () => {
const { events, emitter } = collector();
const state = {};
translateCodexEvent({
type: "item.completed",
item: { id: "warning-1", type: "error", message: "Search result was unavailable" },
}, emitter, state);
translateCodexEvent({
type: "error",
message: "Reconnecting... 1/5 (stream disconnected before completion: Transport error: network error: error decoding response body)",
}, emitter, state);
translateCodexEvent({
type: "error",
message: "stream disconnected before completion: Transport error: error decoding response body; retrying 2/5 in 361ms…",
}, emitter, state);
translateCodexEvent({ type: "error", message: "stream disconnected", willRetry: true }, emitter, state);
translateCodexEvent({ type: "error", message: "transport error", will_retry: true }, emitter, state);
translateCodexEvent({ type: "error", message: "stream disconnected" }, emitter, state);
translateCodexEvent({ type: "error", message: "error decoding response body" }, emitter, state);
translateCodexEvent({ type: "error", message: "transport error" }, emitter, state);
translateCodexEvent({
type: "error",
message: "Reconnecting... 5/5 (stream disconnected before completion: Transport error)",
willRetry: false,
}, emitter, state);
translateCodexEvent({
type: "error",
message: "transport error; retrying 5/5 after retries exhausted",
will_retry: false,
}, emitter, state);
translateCodexEvent({ type: "error", message: "not authenticated" }, emitter, state);
assert.equal(events.filter((event) => event.k === "warning").length, 5);
assert.deepEqual(events.filter((event) => event.k === "error"), [
{ k: "error", e: "stream disconnected" },
{ k: "error", e: "error decoding response body" },
{ k: "error", e: "transport error" },
{ k: "error", e: "Reconnecting... 5/5 (stream disconnected before completion: Transport error)" },
{ k: "error", e: "transport error; retrying 5/5 after retries exhausted" },
{ k: "error", e: "not authenticated" },
]);
assert.match(events[1].message, /Reconnecting|error decoding response body/);
});
test("explicit non-retryable stream disconnect fails the turn even after partial content", async () => {
const { events, emitter } = collector();
class FakeCodex {
startThread() {
return {
id: "thr-exhausted",
async runStreamed() {
return {
events: (async function* () {
yield { type: "thread.started", thread_id: "thr-exhausted" };
yield {
type: "item.completed",
item: { type: "agent_message", text: "partial answer" },
};
yield {
type: "error",
message: "Reconnecting... 5/5 (stream disconnected before completion: Transport error)",
willRetry: false,
};
})(),
};
},
};
}
resumeThread() { return this.startThread(); }
}
await runCodexTurn({
prompt: "hi", constructorOptions: {}, threadOptions: {}, emitter, CodexCtor: FakeCodex,
});
assert.deepEqual(events.filter((event) => event.k === "text"), [{ k: "text", t: "partial answer" }]);
assert.deepEqual(events.filter((event) => event.k === "error"), [
{ k: "error", e: "Reconnecting... 5/5 (stream disconnected before completion: Transport error)" },
]);
assert.equal(events.some((event) => event.k === "done"), false);
});
test("message-only transport failure fails the turn even after partial content", async () => {
const { events, emitter } = collector();
class FakeCodex {
startThread() {
return {
id: "thr-disconnected",
async runStreamed() {
return {
events: (async function* () {
yield { type: "thread.started", thread_id: "thr-disconnected" };
yield {
type: "item.completed",
item: { type: "agent_message", text: "partial answer" },
};
yield {
type: "error",
message: "stream disconnected before completion: Transport error",
};
})(),
};
},
};
}
resumeThread() { return this.startThread(); }
}
await runCodexTurn({
prompt: "hi", constructorOptions: {}, threadOptions: {}, emitter, CodexCtor: FakeCodex,
});
assert.deepEqual(events.filter((event) => event.k === "text"), [{ k: "text", t: "partial answer" }]);
assert.deepEqual(events.filter((event) => event.k === "error"), [
{ k: "error", e: "stream disconnected before completion: Transport error" },
]);
assert.equal(events.some((event) => event.k === "done"), false);
});
test("reconnectable Codex stream errors keep the turn open for later output", async () => {
const { events, emitter } = collector();
class FakeCodex {
startThread() {
return {
id: "thr-reconnect",
async runStreamed() {
return {
events: (async function* () {
yield { type: "thread.started", thread_id: "thr-reconnect" };
yield {
type: "error",
message: "Reconnecting... 1/5 (stream disconnected before completion: error decoding response body)",
};
yield {
type: "item.completed",
item: { type: "agent_message", text: "recovered answer" },
};
})(),
};
},
};
}
resumeThread() { return this.startThread(); }
}
await runCodexTurn({
prompt: "hi", constructorOptions: {}, threadOptions: {}, emitter, CodexCtor: FakeCodex,
});
assert.ok(events.some((event) => event.k === "warning" && /decoding response body|Reconnecting/.test(event.message)));
assert.deepEqual(events.filter((event) => event.k === "text"), [{ k: "text", t: "recovered answer" }]);
assert.ok(events.some((event) => event.k === "done"));
assert.equal(events.some((event) => event.k === "error"), false);
});
test("runCodexTurn captures+emits the thread id early so an aborted turn still resumes", async () => {
// Simulate a Stop that kills the stream mid-turn: thread.started arrives, then
// the event stream throws. The id must already be emitted (renderer) and
// returned (handler) so the NEXT turn resumes this thread instead of starting
// fresh (which is what made the whole session lose its memory after a Stop).
const { events, emitter } = collector();
class FakeCodex {
startThread() {
return {
id: "thr-abc",
async runStreamed() {
return {
events: (async function* () {
yield { type: "thread.started", thread_id: "thr-abc" };
throw new Error("stream aborted mid-turn");
})(),
};
},
};
}
resumeThread() { return this.startThread(); }
}
const result = await runCodexTurn({
prompt: "hi", constructorOptions: {}, threadOptions: {}, emitter, CodexCtor: FakeCodex,
});
assert.deepEqual(events.filter((e) => e.k === "sessionId"), [{ k: "sessionId", s: "thr-abc" }]);
assert.equal(result.threadId, "thr-abc");
});
test("buildCodexPromptInput sends image attachments as native local_image inputs", () => {
const input = buildCodexPromptInput("describe this", [
{ filename: "shot.png", mediaType: "image/png", filePath: "/tmp/shot.png", base64Data: "abc" },
{ filename: "note.txt", mediaType: "text/plain", filePath: "/tmp/note.txt", base64Data: "def" },
]);
assert.deepEqual(input, [
{ type: "text", text: "describe this" },
{ type: "local_image", path: "/tmp/shot.png" },
]);
});
test("runCodexTurn passes native image input to the SDK", async () => {
const { emitter } = collector();
let capturedInput = null;
class FakeCodex {
startThread() {
return {
id: "thr-img",
async runStreamed(input) {
capturedInput = input;
return {
events: (async function* () {
yield { type: "thread.started", thread_id: "thr-img" };
yield { type: "item.completed", item: { type: "agent_message", text: "ok" } };
})(),
};
},
};
}
}
await runCodexTurn({
prompt: "what is in this image",
attachments: [{ mediaType: "image/png", filePath: "/tmp/a.png", base64Data: "abc" }],
constructorOptions: {},
threadOptions: {},
emitter,
CodexCtor: FakeCodex,
});
assert.deepEqual(capturedInput, [
{ type: "text", text: "what is in this image" },
{ type: "local_image", path: "/tmp/a.png" },
]);
});
test("buildCodexConstructorOptions sets path override + env + mcp config table", () => {
const opts = buildCodexConstructorOptions({
codexPath: "/abs/codex",
env: { PATH: "/usr/bin" },
apiKey: undefined,
injectedMcpServers: [{
name: "netcatty-remote-hosts", command: "/abs/electron",
args: ["/abs/server.cjs"], env: [{ name: "NETCATTY_MCP_PORT", value: "1" }],
}],
});
assert.equal(opts.codexPathOverride, "/abs/codex");
assert.equal(opts.env.PATH, "/usr/bin");
assert.deepEqual(opts.config.mcp_servers["netcatty-remote-hosts"], {
command: "/abs/electron", args: ["/abs/server.cjs"], env: { NETCATTY_MCP_PORT: "1" },
});
// request visible reasoning summaries (default "auto" emits none in exec mode)
assert.equal(opts.config.model_reasoning_summary, "concise");
});
test("toCodexMcpConfig can delegate MCP approval to the embedding client", () => {
const config = toCodexMcpConfig([{
name: "netcatty-remote-hosts",
command: "/abs/electron",
args: ["/abs/server.cjs"],
env: [],
}], { defaultToolsApprovalMode: "approve" });
assert.equal(
config["netcatty-remote-hosts"].default_tools_approval_mode,
"approve",
);
});
test("buildCodexThreadOptions enables MCP via danger-full-access + approvalPolicy never", () => {
// codex-sdk: model/sandboxMode/workingDirectory are ThreadOptions (startThread),
// not runStreamed TurnOptions. Non-interactive `codex exec` cancels MCP tool
// calls under read-only/workspace-write (any approval policy); only the full
// bypass (danger-full-access + never) lets injected netcatty MCP tools run.
// Real guardrails live in the netcatty MCP server, not codex's local sandbox.
const t = buildCodexThreadOptions({ cwd: "/tmp", model: "gpt-5.5" });
assert.equal(t.sandboxMode, "danger-full-access");
assert.equal(t.approvalPolicy, "never");
assert.equal(t.workingDirectory, "/tmp");
assert.equal(t.model, "gpt-5.5");
assert.equal(t.modelReasoningEffort, undefined);
assert.equal(t.skipGitRepoCheck, true);
});
test("buildCodexThreadOptions splits <model>/<effort> into model + modelReasoningEffort", () => {
const t = buildCodexThreadOptions({ model: "gpt-5.5/high" });
assert.equal(t.model, "gpt-5.5");
assert.equal(t.modelReasoningEffort, "high");
// GPT-5.6 advertises max/ultra reasoning efforts in the Codex catalog.
const solMax = buildCodexThreadOptions({ model: "gpt-5.6-sol/max" });
assert.equal(solMax.model, "gpt-5.6-sol");
assert.equal(solMax.modelReasoningEffort, "max");
const solUltra = buildCodexThreadOptions({ model: "gpt-5.6-sol/ultra" });
assert.equal(solUltra.model, "gpt-5.6-sol");
assert.equal(solUltra.modelReasoningEffort, "ultra");
// a trailing segment that isn't a valid effort (custom/OpenRouter id) is kept whole
const c = buildCodexThreadOptions({ model: "openrouter/some-model" });
assert.equal(c.model, "openrouter/some-model");
assert.equal(c.modelReasoningEffort, undefined);
});

View File

@@ -0,0 +1,565 @@
"use strict";
/**
* Copilot backend driver — wraps @github/copilot-sdk.
*
* new CopilotClient({ connection: RuntimeConnection.forStdio({ path }), useLoggedInUser })
* .createSession({ model, streaming, onPermissionRequest: approveAll, mcpServers })
* .sendAndWait({ prompt }) -> response.data.content
*
* - The bundled copilot runtime (@github/copilot) is excluded from packaging
* (bring-your-own-CLI), so we MUST point `connection` at the user's system
* `copilot` binary via RuntimeConnection.forStdio({ path }) — otherwise the SDK
* falls back to the (absent) bundled runtime in the shipped app.
* - MCP mode: side effects route through the injected netcatty MCP server
* (stdio). The permission handler rejects local Copilot tools and allows
* only MCP requests; netcatty MCP then enforces approval/scope/blocklist.
* - Skills mode: only builtin bash is exposed (CLI instructions are injected via
* the host prompt; the skill builtin is omitted because its read/custom-tool
* permission kinds are not shell-safe to auto-approve). Shell permission
* requests are approved only for Netcatty CLI invocations; discovery env is
* passed to the Copilot runtime so `netcatty-tool-cli` can reach the host.
*
* 🔬 SMOKE-CALIBRATE [copilot-stream]: sendAndWait returns only the final
* assistant text. A follow-up can subscribe via session.on(handler) to stream
* text + per-tool-call events (assistant.message / tool execution events).
*/
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
// Neutral client options. The real CopilotClient options (with RuntimeConnection)
// are assembled in runCopilotTurn, because RuntimeConnection comes from the SDK
// module which is loaded via dynamic import().
function buildCopilotClientOptions({ cliPath, gitHubToken }) {
const options = {};
if (cliPath) options.cliPath = cliPath;
if (gitHubToken) options.gitHubToken = gitHubToken;
return options;
}
function toCopilotMcpServers(injectedMcpServers) {
const map = {};
for (const cfg of injectedMcpServers || []) {
if (!cfg || !cfg.name) continue;
map[cfg.name] = {
// Local subprocess MCP server (MCPStdioServerConfig). 'stdio' is the
// SDK's canonical value for local/subprocess servers.
type: "stdio",
command: cfg.command,
args: cfg.args || [],
env: mcpEnvPairsToObject(cfg.env),
tools: ["*"],
};
}
return map;
}
const COPILOT_SKILLS_AVAILABLE_TOOLS = ["builtin:bash"];
function copilotBuiltinTools(toolIntegrationMode) {
return toolIntegrationMode === "skills" ? [...COPILOT_SKILLS_AVAILABLE_TOOLS] : null;
}
function buildCopilotSessionOptions({ model, injectedMcpServers, toolIntegrationMode }) {
// onPermissionRequest is wired in runCopilotTurn (it needs the SDK's approveAll).
const options = {
mcpServers: toCopilotMcpServers(injectedMcpServers),
// Copilot SDK enables assistant.message_delta / assistant.reasoning_delta
// from SessionConfig.streaming, not from MessageOptions. Without this the
// renderer only receives final assistant.message and the thinking panel never
// has live reasoning to render.
streaming: true,
};
const availableTools = copilotBuiltinTools(toolIntegrationMode);
if (availableTools) options.availableTools = availableTools;
if (model) options.model = model;
return options;
}
// Shell chaining/redirection in the local Netcatty CLI prefix (not after exec `--`).
const LOCAL_SHELL_METACHAR_PATTERN = /(?:[;&|`]|&&|\|\||\$\(|\$\{|<<?|>{1,2}|\r?\n)/;
const LOCAL_SHELL_WRAPPER_PATTERN = /^(?:\/[^\s]+\/)?(?:ba|z|fi)?sh(?:\.exe)?\s+-c\b/i;
const NETCATTY_CLI_TOKEN = String.raw`netcatty-tool-cli(?:\.(?:cjs|cmd))?`;
const NETCATTY_CLI_PATH_SUFFIX = String.raw`(?:[\\/]|^)${NETCATTY_CLI_TOKEN}`;
/** Find the last exec/job-start payload separator outside shell quotes. */
function findExecPayloadSeparatorIndex(command) {
const text = String(command || "");
let inSingle = false;
let inDouble = false;
let escape = false;
let lastIndex = -1;
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
if (escape) {
escape = false;
continue;
}
if (ch === "\\" && (inSingle || inDouble)) {
escape = true;
continue;
}
if (!inDouble && ch === "'") {
inSingle = !inSingle;
continue;
}
if (!inSingle && ch === '"') {
inDouble = !inDouble;
continue;
}
if (!inSingle && !inDouble && text.startsWith(" -- ", i)) {
lastIndex = i;
i += 3;
}
}
return lastIndex;
}
function matchesShellMetacharAt(text, index) {
const match = LOCAL_SHELL_METACHAR_PATTERN.exec(String(text || "").slice(index));
return Boolean(match && match.index === 0);
}
function containsUnsafeShellMetachar(text) {
let inSingle = false;
let inDouble = false;
let escape = false;
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
if (escape) {
escape = false;
continue;
}
if (ch === "\\" && (inSingle || inDouble)) {
escape = true;
continue;
}
if (!inDouble && ch === "'") {
inSingle = !inSingle;
continue;
}
if (!inSingle && ch === '"') {
inDouble = !inDouble;
continue;
}
if (inSingle) continue;
if (inDouble) {
if (text.startsWith("$(", i) || ch === "`") return true;
continue;
}
if (matchesShellMetacharAt(text, i)) return true;
}
return false;
}
/** Split before the final exec/job-start remote payload (` -- cmd`), not flag values. */
function getLocalNetcattyCliPrefix(fullCommandText) {
const command = String(fullCommandText || "").trim();
const splitAt = findExecPayloadSeparatorIndex(command);
if (splitAt >= 0) {
return command.slice(0, splitAt).trim();
}
return command;
}
function isNetcattyCliInvocationPrefix(localPart) {
const text = String(localPart || "").trim();
if (!text) return false;
const pathPrefix = String.raw`(?:\.\./|\./|/|[A-Za-z]:[\\/])[\w. \\-]*[\\/]`;
const invocation = new RegExp(
String.raw`^(?:(?:[A-Za-z_][\w.-]*=[^\s]+\s+)*)?(?:` +
String.raw`"[^"]*${NETCATTY_CLI_PATH_SUFFIX}"|` +
String.raw `'[^']*${NETCATTY_CLI_PATH_SUFFIX}'|` +
String.raw `${NETCATTY_CLI_TOKEN}(?=\s|$)|` +
String.raw `${pathPrefix}${NETCATTY_CLI_TOKEN}(?=\s|$)|` +
String.raw `node\s+(?:${NETCATTY_CLI_TOKEN}(?=\s|$)|${pathPrefix}${NETCATTY_CLI_TOKEN}(?=\s|$)|` +
String.raw `(?:[\w.-]+(?:[\\/][\w.-]+)*[\\/])?${NETCATTY_CLI_TOKEN}(?=\s|$)|` +
String.raw `"[^"]*${NETCATTY_CLI_PATH_SUFFIX}"|'[^']*${NETCATTY_CLI_PATH_SUFFIX}'))`,
"i",
);
return invocation.test(text);
}
function hasExecPayloadSubcommand(localPart) {
return /\b(?:exec|job-start)\b/i.test(String(localPart || ""));
}
function isLikelyNetcattyCliShellCommand(fullCommandText) {
const command = String(fullCommandText || "").trim();
if (!command) return false;
const splitAt = findExecPayloadSeparatorIndex(command);
const localPart = splitAt >= 0 ? command.slice(0, splitAt).trim() : command;
const remotePayload = splitAt >= 0 ? command.slice(splitAt + 4).trim() : "";
if (!localPart || LOCAL_SHELL_WRAPPER_PATTERN.test(localPart)) return false;
if (!isNetcattyCliInvocationPrefix(localPart)) return false;
if (remotePayload) {
if (!hasExecPayloadSubcommand(localPart)) return false;
if (containsUnsafeShellMetachar(localPart)) return false;
// The runtime executes fullCommandText in a local shell; scan all of it so
// tokens after `--` cannot chain additional local commands unless quoted.
if (containsUnsafeShellMetachar(command)) return false;
return true;
}
return !containsUnsafeShellMetachar(command);
}
function approveNetcattyMcpOnly(request) {
if (request?.kind === "mcp" && request?.toolName) {
return { kind: "approve-once" };
}
return {
kind: "reject",
feedback: "Only Netcatty MCP tools are allowed from this integration.",
};
}
function approveNetcattyCliShellOnly(request) {
if (request?.kind === "shell") {
const fullCommandText = request.fullCommandText || "";
if (isLikelyNetcattyCliShellCommand(fullCommandText)) {
return { kind: "approve-once" };
}
return {
kind: "reject",
feedback:
"Only Netcatty CLI shell commands are allowed. Invoke the netcatty-tool-cli launcher or script prefix provided in the host context, and include --chat-session on every call.",
};
}
return {
kind: "reject",
feedback: "Only Netcatty CLI shell commands are allowed from this integration.",
};
}
function buildCopilotPermissionHandler(toolIntegrationMode) {
return toolIntegrationMode === "skills" ? approveNetcattyCliShellOnly : approveNetcattyMcpOnly;
}
function extractCopilotContent(response) {
return (response && response.data && response.data.content) || "";
}
function buildCopilotMessageOptions({ prompt, attachments }) {
const options = { prompt: String(prompt || "") };
const nativeAttachments = [];
for (const attachment of Array.isArray(attachments) ? attachments : []) {
if (!attachment) continue;
const displayName = attachment.filename || undefined;
if (attachment.base64Data && attachment.mediaType) {
nativeAttachments.push({
type: "blob",
data: attachment.base64Data,
mimeType: attachment.mediaType,
displayName,
});
continue;
}
if (attachment.filePath) {
nativeAttachments.push({
type: "file",
path: attachment.filePath,
displayName,
});
}
}
if (nativeAttachments.length > 0) options.attachments = nativeAttachments;
return options;
}
/** Extract a display string from a tool.execution_complete event's data. */
function extractCopilotResultText(data) {
if (!data) return "";
if (data.error && data.error.message) return String(data.error.message);
const result = data.result;
if (result == null) return "";
if (typeof result === "string") return result;
const content = result.content;
if (Array.isArray(content)) {
return content
.map((b) => (b && typeof b.text === "string" ? b.text : (b == null ? "" : JSON.stringify(b))))
.join("");
}
return typeof result === "object" ? JSON.stringify(result) : String(result);
}
/**
* Translate one copilot SessionEvent into emitter calls — gives copilot the same
* live tool-card + thinking-panel UX as codex/claude (it previously showed only
* the final text). `state` ({ reasoningOpen, streamedText, streamedReasoning })
* threads the thinking block and records whether any delta streamed, so
* runCopilotTurn can fall back to final consolidated events when needed.
* Event shapes calibrated against @github/copilot-sdk generated session-events.
*/
function translateCopilotEvent(event, emitter, state) {
if (!event || typeof event !== "object") return;
const st = state || {};
const data = event.data || {};
const closeReasoning = () => {
if (st.reasoningOpen) { emitter.reasoningEnd(); st.reasoningOpen = false; }
};
switch (event.type) {
case "assistant.reasoning_delta":
if (data.deltaContent) {
emitter.reasoning(data.deltaContent);
st.reasoningOpen = true;
st.streamedReasoning = true;
}
return;
case "assistant.reasoning":
if (data.content && !st.streamedReasoning) {
emitter.reasoning(data.content);
st.reasoningOpen = true;
closeReasoning();
}
return;
case "assistant.message_delta":
if (data.deltaContent) { closeReasoning(); emitter.text(data.deltaContent); st.streamedText = true; }
return;
case "tool.execution_start":
closeReasoning();
emitter.toolCall(data.toolName || data.mcpToolName || "tool", data.arguments || {}, data.toolCallId);
return;
case "tool.execution_complete":
emitter.toolResult(data.toolCallId, extractCopilotResultText(data), undefined);
return;
default:
// assistant.message (final consolidated text) is intentionally ignored —
// text arrives via message_delta (or the runCopilotTurn fallback). Other
// events (turn start/end, usage, state changes) have no UI mapping.
return;
}
}
/**
* Run a Copilot turn (保底同步形态 via sendAndWait).
* @param {object} args
* @param {string} args.prompt
* @param {Array<object>} [args.attachments]
* @param {object} args.clientOptions buildCopilotClientOptions(...) (neutral: {cliPath, gitHubToken})
* @param {object} args.sessionOptions buildCopilotSessionOptions(...) ({model, mcpServers})
* @param {object} args.emitter
* @param {AbortSignal} [args.signal]
* @param {object} [args.sdkModule] inject the @github/copilot-sdk module (for tests)
*/
async function runCopilotTurn({
prompt,
attachments,
clientOptions,
sessionOptions,
resumeSessionId,
toolIntegrationMode,
runtimeEnv,
emitter,
signal,
sdkModule,
}) {
let resolvedModule = sdkModule;
if (!resolvedModule) {
try { resolvedModule = await import("@github/copilot-sdk"); } catch { emitter.emitError("GitHub Copilot SDK not installed. Run: npm install @github/copilot-sdk"); return { sessionId: null }; }
}
const sdk = resolvedModule;
const { CopilotClient, RuntimeConnection } = sdk;
// Assemble the real CopilotClient options: point at the user's system CLI
// (the bundled runtime is excluded from packaging) and authenticate as the
// logged-in user (gh CLI / stored OAuth).
const realClientOptions = { useLoggedInUser: true };
if (runtimeEnv && typeof runtimeEnv === "object") {
realClientOptions.env = runtimeEnv;
}
if (clientOptions?.cliPath && RuntimeConnection?.forStdio) {
realClientOptions.connection = RuntimeConnection.forStdio({ path: clientOptions.cliPath });
}
if (clientOptions?.gitHubToken) realClientOptions.gitHubToken = clientOptions.gitHubToken;
let client = null;
let sessionId = resumeSessionId || null;
try {
client = new CopilotClient(realClientOptions);
const sessionConfig = {
...sessionOptions,
streaming: true,
// MCP mode: only netcatty MCP. Skills mode: only Netcatty CLI shell commands.
onPermissionRequest: buildCopilotPermissionHandler(toolIntegrationMode),
};
// Resume the prior conversation so context carries ACROSS turns (incl. after
// a Stop). Always (re)apply sessionConfig so the FRESH netcatty MCP server
// config — its current port/token/chat-session id — is used, not the stale
// one from the resumed session. Fall back to a fresh session if there's no id
// yet or the resume fails (session expired/deleted).
let session;
if (resumeSessionId && typeof client.resumeSession === "function") {
try {
session = await client.resumeSession(resumeSessionId, sessionConfig);
} catch {
session = await client.createSession(sessionConfig);
}
} else {
session = await client.createSession(sessionConfig);
}
// Emit the resumable session id IMMEDIATELY — before the blocking sendAndWait
// — so a mid-turn Stop can't lose it; the next turn resumes this conversation.
sessionId = session.sessionId || sessionId;
if (sessionId) emitter.sessionId(sessionId);
if (signal?.aborted) return { sessionId };
// Stream tool calls + text/reasoning deltas in real time (parity with
// codex/claude — copilot previously showed only the final text). on() gets
// every SessionEvent; SessionConfig.streaming enables assistant.message_delta
// / assistant.reasoning_delta; tool.execution_* events arrive regardless.
const state = { reasoningOpen: false, streamedText: false, streamedReasoning: false };
let unsubscribe = () => {};
if (typeof session.on === "function") {
unsubscribe = session.on((ev) => translateCopilotEvent(ev, emitter, state));
}
let abortRequested = false;
let removeAbortListener = () => {};
if (signal) {
const onAbort = () => {
abortRequested = true;
if (typeof session.abort === "function") {
void session.abort().catch(() => {});
}
};
if (signal.aborted) {
onAbort();
} else {
signal.addEventListener("abort", onAbort, { once: true });
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
}
}
let final;
try {
final = await session.sendAndWait(buildCopilotMessageOptions({ prompt, attachments }));
} finally {
try { unsubscribe(); } catch { /* best effort */ }
removeAbortListener();
}
if (state.reasoningOpen) emitter.reasoningEnd();
if (abortRequested || signal?.aborted) {
return { sessionId };
}
// Fallback: if nothing streamed (older runtime / streamDeltas unsupported),
// emit the final consolidated text so the turn isn't silent.
if (!state.streamedText) {
const content = extractCopilotContent(final);
if (content) emitter.text(content);
if (!content && !signal?.aborted) {
emitter.emitError(
"Copilot returned an empty response. Run `copilot` once to log in, or `gh auth login`.",
);
return { sessionId };
}
}
emitter.emitDone();
return { sessionId };
} catch (error) {
if (signal?.aborted) {
return { sessionId };
}
const code = error && error.code;
const msg = String((error && error.message) || error || "");
if (code === "ENOENT" || /ENOENT/i.test(msg)) {
emitter.emitError(
"Copilot CLI not found. Install with `npm i -g @github/copilot` and run `gh auth login`.",
);
} else {
emitter.emitError(msg || "Copilot turn failed");
}
return { sessionId };
} finally {
try { await client?.stop?.(); } catch { /* best effort */ }
}
}
/** Map copilot-sdk ModelInfo[] -> renderer preset shape {id,name}. */
function mapCopilotModels(models) {
if (!Array.isArray(models)) return [];
return models
.filter((m) => m && m.id)
.map((m) => ({ id: m.id, name: m.name || m.id }));
}
/**
* Fetch available Copilot models via client.start() + client.listModels().
* Returns [] on failure (the caller falls back to the UI's curated presets).
* @param {object} args
* @param {string} [args.cliPath]
* @param {object} [args.sdkModule] inject the @github/copilot-sdk module (for tests)
*/
async function listCopilotModels({ cliPath, sdkModule, abortController, signal }) {
const externalSignal = signal || abortController?.signal;
if (externalSignal?.aborted) return [];
let resolvedModule = sdkModule;
if (!resolvedModule) {
try { resolvedModule = await import("@github/copilot-sdk"); } catch { return []; }
}
const sdk = resolvedModule;
const { CopilotClient, RuntimeConnection } = sdk;
const clientOptions = { useLoggedInUser: true };
if (cliPath && RuntimeConnection?.forStdio) {
clientOptions.connection = RuntimeConnection.forStdio({ path: cliPath });
}
const client = new CopilotClient(clientOptions);
let stopPromise;
const stopClient = () => {
if (!stopPromise) {
try { stopPromise = Promise.resolve(client.stop()).catch(() => {}); } catch { stopPromise = Promise.resolve(); }
}
return stopPromise;
};
let resolveAbort;
const aborted = new Promise((resolve) => { resolveAbort = resolve; });
const onAbort = () => {
resolveAbort({ type: "aborted" });
void stopClient();
};
externalSignal?.addEventListener("abort", onAbort, { once: true });
if (externalSignal?.aborted) onAbort();
try {
const started = await Promise.race([
Promise.resolve(client.start()).then(() => ({ type: "started" })),
aborted,
]);
if (started.type === "aborted") return [];
const result = await Promise.race([
Promise.resolve(client.listModels()).then((models) => ({ type: "models", models })),
aborted,
]);
return result.type === "models" ? mapCopilotModels(result.models) : [];
} catch {
return [];
} finally {
externalSignal?.removeEventListener("abort", onAbort);
void stopClient();
}
}
module.exports = {
buildCopilotClientOptions,
buildCopilotSessionOptions,
buildCopilotMessageOptions,
buildCopilotPermissionHandler,
approveNetcattyMcpOnly,
approveNetcattyCliShellOnly,
isLikelyNetcattyCliShellCommand,
getLocalNetcattyCliPrefix,
findExecPayloadSeparatorIndex,
containsUnsafeShellMetachar,
matchesShellMetacharAt,
hasExecPayloadSubcommand,
copilotBuiltinTools,
toCopilotMcpServers,
extractCopilotContent,
extractCopilotResultText,
translateCopilotEvent,
runCopilotTurn,
listCopilotModels,
mapCopilotModels,
};

View File

@@ -0,0 +1,357 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { approveNetcattyMcpOnly, approveNetcattyCliShellOnly, buildCopilotClientOptions, buildCopilotPermissionHandler, buildCopilotSessionOptions, buildCopilotMessageOptions, copilotBuiltinTools, extractCopilotContent, isLikelyNetcattyCliShellCommand, mapCopilotModels, runCopilotTurn, translateCopilotEvent } = require("./copilotDriver.cjs");
function collector() {
const events = [];
return {
events,
emitter: {
text: (t) => events.push({ k: "text", t }),
reasoning: (d) => events.push({ k: "reasoning", d }),
reasoningEnd: () => events.push({ k: "reasoningEnd" }),
toolCall: (n, a, id) => events.push({ k: "toolCall", n, a, id }),
toolResult: (id, o, n) => events.push({ k: "toolResult", id, o, n }),
sessionId: (s) => events.push({ k: "sessionId", s }),
emitError: (e) => events.push({ k: "error", e }),
emitDone: () => events.push({ k: "done" }),
},
};
}
/** Minimal @github/copilot-sdk mock; records create vs resume + returns a session. */
function makeSdk(captured) {
const makeSession = (sessionId) => ({
sessionId,
async sendAndWait({ prompt }) { captured.prompt = prompt; return { data: { content: "reply:" + sessionId } }; },
});
class CopilotClient {
constructor(options) { captured.clientOptions = options; }
async createSession(cfg) { captured.created = cfg; return makeSession("sess-new"); }
async resumeSession(id, cfg) { captured.resumed = { id, cfg }; return makeSession(id); }
async stop() {}
}
return { CopilotClient, RuntimeConnection: { forStdio: () => ({}) }, approveAll: () => {} };
}
test("buildCopilotClientOptions pins cliPath", () => {
const o = buildCopilotClientOptions({ cliPath: "/abs/copilot" });
assert.equal(o.cliPath, "/abs/copilot");
});
test("buildCopilotSessionOptions maps injected MCP to local stdio servers", () => {
const o = buildCopilotSessionOptions({
model: "claude-sonnet-4.5",
injectedMcpServers: [{
name: "netcatty-remote-hosts", command: "/abs/electron",
args: ["/abs/server.cjs"], env: [{ name: "NETCATTY_MCP_PORT", value: "1" }],
}],
});
assert.equal(o.model, "claude-sonnet-4.5");
assert.equal(o.streaming, true);
const srv = o.mcpServers["netcatty-remote-hosts"];
assert.equal(srv.type, "stdio");
assert.equal(srv.command, "/abs/electron");
assert.deepEqual(srv.env, { NETCATTY_MCP_PORT: "1" });
assert.deepEqual(srv.tools, ["*"]);
// onPermissionRequest is wired in runCopilotTurn via the SDK's approveAll,
// not in buildCopilotSessionOptions.
});
test("approveNetcattyMcpOnly approves MCP permission requests and rejects local tools", () => {
assert.deepEqual(
approveNetcattyMcpOnly({ kind: "mcp", toolName: "terminal_execute" }),
{ kind: "approve-once" },
);
assert.deepEqual(
approveNetcattyMcpOnly({ kind: "shell", fullCommandText: "rm -rf /tmp/x" }),
{ kind: "reject", feedback: "Only Netcatty MCP tools are allowed from this integration." },
);
assert.deepEqual(
approveNetcattyMcpOnly({ kind: "read", fileName: "/etc/passwd" }),
{ kind: "reject", feedback: "Only Netcatty MCP tools are allowed from this integration." },
);
});
test("extractCopilotContent reads response data.content", () => {
assert.equal(extractCopilotContent({ data: { content: "hi" } }), "hi");
assert.equal(extractCopilotContent(null), "");
assert.equal(extractCopilotContent({ data: {} }), "");
});
test("buildCopilotMessageOptions sends pasted images/files as native attachments", () => {
const opts = buildCopilotMessageOptions({
prompt: "inspect these",
attachments: [
{ filename: "shot.png", mediaType: "image/png", filePath: "/tmp/shot.png", base64Data: "abc" },
{ filename: "note.txt", mediaType: "text/plain", filePath: "/tmp/note.txt" },
],
});
assert.equal(opts.prompt, "inspect these");
assert.equal("streamDeltas" in opts, false);
assert.deepEqual(opts.attachments, [
{ type: "blob", data: "abc", mimeType: "image/png", displayName: "shot.png" },
{ type: "file", path: "/tmp/note.txt", displayName: "note.txt" },
]);
});
test("mapCopilotModels maps {id,name} and drops entries without id", () => {
const out = mapCopilotModels([
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
{ id: "gpt-5" },
{ name: "no id -> dropped" },
]);
assert.deepEqual(out, [
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
{ id: "gpt-5", name: "gpt-5" },
]);
assert.deepEqual(mapCopilotModels(undefined), []);
});
test("runCopilotTurn (fresh) creates a session, emits its id early, returns it for resume", async () => {
const { events, emitter } = collector();
const captured = {};
const result = await runCopilotTurn({
prompt: "hi", clientOptions: { cliPath: "/c" }, sessionOptions: { model: "m" },
emitter, sdkModule: makeSdk(captured),
});
assert.ok(captured.created, "used createSession when there's no resume id");
assert.equal(captured.created.model, "m");
assert.deepEqual(events.filter((e) => e.k === "sessionId"), [{ k: "sessionId", s: "sess-new" }]);
assert.equal(result.sessionId, "sess-new");
});
test("runCopilotTurn resumes the prior session (carry context) and re-applies fresh config", async () => {
const { events, emitter } = collector();
const captured = {};
const result = await runCopilotTurn({
prompt: "what did we say", clientOptions: {}, sessionOptions: { model: "m" },
resumeSessionId: "sess-existing", emitter, sdkModule: makeSdk(captured),
});
assert.equal(captured.resumed.id, "sess-existing", "used resumeSession, not createSession");
assert.equal(captured.created, undefined);
// fresh netcatty MCP/session config re-applied on resume (not the stale one)
assert.equal(captured.resumed.cfg.model, "m");
assert.equal(result.sessionId, "sess-existing");
assert.ok(events.some((e) => e.k === "sessionId" && e.s === "sess-existing"));
});
test("translateCopilotEvent: deltas -> text/reasoning, tool start/complete -> tool card", () => {
const { events, emitter } = collector();
const state = { reasoningOpen: false, streamedText: false };
translateCopilotEvent({ type: "assistant.reasoning_delta", data: { deltaContent: "thinking" } }, emitter, state);
translateCopilotEvent({ type: "assistant.message_delta", data: { deltaContent: "hello" } }, emitter, state);
translateCopilotEvent({ type: "tool.execution_start", data: { toolName: "shell", arguments: { command: "ls" }, toolCallId: "t1" } }, emitter, state);
translateCopilotEvent({ type: "tool.execution_complete", data: { toolCallId: "t1", result: { content: [{ type: "text", text: "files" }] } } }, emitter, state);
assert.deepEqual(events, [
{ k: "reasoning", d: "thinking" },
{ k: "reasoningEnd" }, // message_delta closes the thinking block
{ k: "text", t: "hello" },
{ k: "toolCall", n: "shell", a: { command: "ls" }, id: "t1" },
{ k: "toolResult", id: "t1", o: "files", n: undefined },
]);
assert.equal(state.streamedText, true);
});
test("translateCopilotEvent: final reasoning is shown when no reasoning deltas streamed", () => {
const { events, emitter } = collector();
const state = { reasoningOpen: false, streamedText: false, streamedReasoning: false };
translateCopilotEvent({ type: "assistant.reasoning", data: { content: "complete thinking" } }, emitter, state);
assert.deepEqual(events, [
{ k: "reasoning", d: "complete thinking" },
{ k: "reasoningEnd" },
]);
assert.equal(state.reasoningOpen, false);
});
test("translateCopilotEvent: final reasoning is ignored after streamed reasoning deltas", () => {
const { events, emitter } = collector();
const state = { reasoningOpen: false, streamedText: false, streamedReasoning: false };
translateCopilotEvent({ type: "assistant.reasoning_delta", data: { deltaContent: "thinking" } }, emitter, state);
translateCopilotEvent({ type: "assistant.reasoning", data: { content: "thinking" } }, emitter, state);
translateCopilotEvent({ type: "assistant.message_delta", data: { deltaContent: "hello" } }, emitter, state);
assert.deepEqual(events, [
{ k: "reasoning", d: "thinking" },
{ k: "reasoningEnd" },
{ k: "text", t: "hello" },
]);
});
test("runCopilotTurn streams tool calls + deltas via session.on (no final-text dup)", async () => {
const { events, emitter } = collector();
const captured = {};
let handler = null;
const sdkModule = {
RuntimeConnection: { forStdio: () => ({}) },
approveAll: () => {},
CopilotClient: class {
async createSession(cfg) {
captured.created = cfg;
return {
sessionId: "sess-x",
on(h) { handler = h; return () => { handler = null; }; },
async sendAndWait(opts) {
captured.opts = opts;
handler({ type: "assistant.message_delta", data: { deltaContent: "hi " } });
handler({ type: "tool.execution_start", data: { toolName: "shell", arguments: {}, toolCallId: "t1" } });
handler({ type: "tool.execution_complete", data: { toolCallId: "t1", result: { content: [{ type: "text", text: "ok" }] } } });
handler({ type: "assistant.message_delta", data: { deltaContent: "there" } });
return { data: { content: "hi there" } };
},
async stop() {},
};
}
async stop() {}
},
};
const result = await runCopilotTurn({
prompt: "go",
attachments: [{ filename: "shot.png", mediaType: "image/png", filePath: "/tmp/shot.png", base64Data: "abc" }],
clientOptions: {},
sessionOptions: {},
emitter,
sdkModule,
});
assert.equal(captured.created.streaming, true, "requested session streaming");
assert.equal("streamDeltas" in captured.opts, false, "does not send unsupported message streaming flag");
assert.deepEqual(captured.opts.attachments, [
{ type: "blob", data: "abc", mimeType: "image/png", displayName: "shot.png" },
]);
// streamed deltas shown, NOT the duplicated final consolidated text
assert.deepEqual(events.filter((e) => e.k === "text"), [{ k: "text", t: "hi " }, { k: "text", t: "there" }]);
assert.ok(events.some((e) => e.k === "toolCall" && e.id === "t1"), "tool card streamed");
assert.ok(events.some((e) => e.k === "toolResult" && e.o === "ok"), "tool result streamed");
assert.equal(result.sessionId, "sess-x");
});
test("runCopilotTurn aborts the active Copilot session when the signal aborts", async () => {
const { events, emitter } = collector();
const controller = new AbortController();
let abortCalled = false;
const sdkModule = {
RuntimeConnection: { forStdio: () => ({}) },
approveAll: () => {},
CopilotClient: class {
async createSession() {
return {
sessionId: "sess-abort",
on() { return () => {}; },
async sendAndWait() {
controller.abort();
await new Promise((resolve) => setTimeout(resolve, 0));
return { data: { content: "late text" } };
},
async abort() { abortCalled = true; },
};
}
async stop() {}
},
};
const result = await runCopilotTurn({
prompt: "stop me",
clientOptions: {},
sessionOptions: {},
emitter,
signal: controller.signal,
sdkModule,
});
assert.equal(abortCalled, true);
assert.equal(result.sessionId, "sess-abort");
assert.equal(events.some((event) => event.k === "text" && event.t === "late text"), false);
assert.equal(events.some((event) => event.k === "done"), false);
});
test("copilotBuiltinTools exposes bash only in skills mode", () => {
assert.equal(copilotBuiltinTools("mcp"), null);
assert.deepEqual(copilotBuiltinTools("skills"), ["builtin:bash"]);
});
test("buildCopilotSessionOptions whitelists bash in skills mode", () => {
const skills = buildCopilotSessionOptions({
model: "gpt-5",
injectedMcpServers: [],
toolIntegrationMode: "skills",
});
assert.deepEqual(skills.availableTools, ["builtin:bash"]);
assert.deepEqual(skills.mcpServers, {});
});
test("approveNetcattyCliShellOnly allows Netcatty CLI shell commands only", () => {
assert.deepEqual(
approveNetcattyCliShellOnly({
kind: "shell",
fullCommandText: 'node "/Applications/Netcatty.app/netcatty-tool-cli.cjs" env --chat-session abc --json',
}),
{ kind: "approve-once" },
);
assert.equal(
approveNetcattyCliShellOnly({ kind: "shell", fullCommandText: "pwd" }).kind,
"reject",
);
});
test("buildCopilotPermissionHandler selects MCP vs skills gate", () => {
assert.equal(buildCopilotPermissionHandler("mcp"), approveNetcattyMcpOnly);
assert.equal(buildCopilotPermissionHandler("skills"), approveNetcattyCliShellOnly);
});
test("isLikelyNetcattyCliShellCommand matches launcher and script invocations", () => {
assert.equal(isLikelyNetcattyCliShellCommand("netcatty-tool-cli status --json"), true);
assert.equal(isLikelyNetcattyCliShellCommand("node electron/cli/netcatty-tool-cli.cjs env --json"), true);
assert.equal(isLikelyNetcattyCliShellCommand("ls -la"), false);
});
test("isLikelyNetcattyCliShellCommand rejects chained or wrapped local commands", () => {
assert.equal(isLikelyNetcattyCliShellCommand("rm -rf /; netcatty-tool-cli status --json"), false);
assert.equal(isLikelyNetcattyCliShellCommand("netcatty-tool-cli status --json && curl evil"), false);
assert.equal(isLikelyNetcattyCliShellCommand('bash -c "netcatty-tool-cli status --json"'), false);
assert.equal(isLikelyNetcattyCliShellCommand("malicious netcatty-tool-cli status --json"), false);
assert.equal(isLikelyNetcattyCliShellCommand("netcatty-tool-cli status `id` --json"), false);
});
test("isLikelyNetcattyCliShellCommand allows quoted remote exec payloads after --", () => {
assert.equal(
isLikelyNetcattyCliShellCommand('netcatty-tool-cli exec --session s1 --chat-session c1 --json -- "hostname && whoami"'),
true,
);
assert.equal(
isLikelyNetcattyCliShellCommand("netcatty-tool-cli exec --session s1 --chat-session c1 --json -- hostname && whoami"),
false,
);
});
test("isLikelyNetcattyCliShellCommand rejects impostor binaries and quoted -- bypasses", () => {
assert.equal(isLikelyNetcattyCliShellCommand("netcatty-tool-cli-backup status --json"), false);
assert.equal(isLikelyNetcattyCliShellCommand("netcatty-tool-cli.evil status --json"), false);
assert.equal(
isLikelyNetcattyCliShellCommand('netcatty-tool-cli sftp read --remote-path "a -- b" ; rm -rf /'),
false,
);
assert.equal(
isLikelyNetcattyCliShellCommand('netcatty-tool-cli sftp read --remote-path "a -- b" --session s1 --json'),
true,
);
assert.equal(isLikelyNetcattyCliShellCommand("netcatty-tool-cli status --json -- ; rm -rf /"), false);
assert.equal(isLikelyNetcattyCliShellCommand("netcatty-tool-cli status --json > /tmp/out"), false);
assert.equal(isLikelyNetcattyCliShellCommand('"C:\\Apps\\Netcatty\\netcatty-tool-cli.cmd" status --json'), true);
assert.equal(isLikelyNetcattyCliShellCommand("attacker/netcatty-tool-cli status --json"), false);
assert.equal(isLikelyNetcattyCliShellCommand('netcatty-tool-cli status "$(id)" --json'), false);
});
test("runCopilotTurn passes runtime env and skills permission handler", async () => {
const { emitter } = collector();
const captured = {};
await runCopilotTurn({
prompt: "hi",
clientOptions: { cliPath: "/c" },
sessionOptions: { model: "m" },
toolIntegrationMode: "skills",
runtimeEnv: { NETCATTY_TOOL_CLI_DISCOVERY_FILE: "/tmp/discovery.json" },
emitter,
sdkModule: makeSdk(captured),
});
assert.deepEqual(captured.clientOptions.env, { NETCATTY_TOOL_CLI_DISCOVERY_FILE: "/tmp/discovery.json" });
assert.equal(captured.created.onPermissionRequest, approveNetcattyCliShellOnly);
});

View File

@@ -0,0 +1,789 @@
"use strict";
/**
* Cursor Agent CLI turn runner — subscription / login session path.
*
* Spawns `cursor-agent` in print/stream-json mode so Catty can use the local
* CLI login quota without CURSOR_API_KEY.
*/
const { spawn } = require("node:child_process");
const { StringDecoder } = require("node:string_decoder");
const fs = require("node:fs");
const path = require("node:path");
const { resolveCursorCliSpawnSpec } = require("../cursorCliSpawn.cjs");
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
const { encodeCursorCliModel } = require("./cursorDriver.cjs");
const DEFAULT_CURSOR_CLI_MODEL = "auto";
const NETCATTY_MCP_NAME = "netcatty-remote-hosts";
const CURSOR_CLI_ABORT_GRACE_MS = 1_500;
const MAX_CURSOR_CLI_STDERR_CHARS = 64 * 1024;
const MAX_CURSOR_CLI_MODEL_STDOUT_CHARS = 1024 * 1024;
const MAX_CURSOR_CLI_LINE_BYTES = 10 * 1024 * 1024;
function signalCursorCliProcessTree(child, signal, forceKillImpl) {
if (!child) return;
if (typeof forceKillImpl === "function") {
try { forceKillImpl(child, signal); } catch {}
return;
}
if (process.platform === "win32" && signal === "SIGKILL" && child.pid) {
try {
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
});
killer.on("error", () => {});
killer.unref?.();
return;
} catch {
// Fall through to ChildProcess.kill below.
}
}
if (process.platform !== "win32" && child.pid) {
try {
process.kill(-child.pid, signal);
return;
} catch {
// The child may not be a process-group leader (for injected tests or an
// older runtime). Fall back to killing the direct child.
}
}
try { child.kill(signal); } catch { /* ignore */ }
}
function stripCursorApiKeyFromEnv(env) {
const out = { ...(env || {}) };
delete out.CURSOR_API_KEY;
return out;
}
function spawnCursorCliProcess(spawnImpl, cliPath, args, options = {}) {
const spawnFn = spawnImpl || spawn;
const spawnSpec = resolveCursorCliSpawnSpec(cliPath, args);
return spawnFn(spawnSpec.command, spawnSpec.args, {
...options,
shell: spawnSpec.shell,
});
}
function resolveCursorCliModel(model) {
const encoded = encodeCursorCliModel(model);
return encoded || DEFAULT_CURSOR_CLI_MODEL;
}
/** Map Netcatty permission mode → Cursor CLI execution class. */
function resolveCursorCliExecMode(permissionMode) {
return String(permissionMode || "confirm").toLowerCase() === "observer" ? "ask" : "agent";
}
function buildCursorCliArgs({
model,
resumeSessionId,
permissionMode,
cwd,
prompt,
}) {
const args = [
"--print",
"--trust",
"--approve-mcps",
"--output-format",
"stream-json",
"--stream-partial-output",
"--model",
resolveCursorCliModel(model),
];
if (cwd) {
args.push("--workspace", cwd);
}
if (resumeSessionId) {
args.push("--resume", String(resumeSessionId));
}
if (resolveCursorCliExecMode(permissionMode) === "ask") {
// Read-only ask mode; no shell write approvals expected.
args.push("--mode", "ask");
} else {
// confirm/auto (and any other agent mode): stdin is ignored for the child, so
// interactive y/n command approval cannot work. Cursor docs require --force
// (--yolo) to auto-allow shell/tools in non-interactive runs.
args.push("--force");
}
args.push(String(prompt || ""));
return args;
}
function mcpConfigToCursorMcpJsonEntry(cfg) {
if (!cfg || !cfg.name || !cfg.command) return null;
const entry = {
type: "stdio",
command: cfg.command,
args: Array.isArray(cfg.args) ? cfg.args : [],
};
const env = mcpEnvPairsToObject(cfg.env);
if (env && Object.keys(env).length > 0) entry.env = env;
return { name: cfg.name, entry };
}
/**
* Cursor CLI discovers MCP via `{cwd}/.cursor/mcp.json`. Packaged Netcatty
* launched from Finder/Dock often has `process.cwd() === "/"`, which cannot
* host that file. Always prefer a writable Netcatty temp workspace.
*/
function resolveCursorCliWorkspaceCwd({
preferredCwd,
chatSessionId,
getTempDir,
mkdirSync,
} = {}) {
const mkdir = mkdirSync || fs.mkdirSync;
const resolveTempRoot = typeof getTempDir === "function"
? getTempDir
: () => {
try {
return require("../../tempDirBridge.cjs").getTempDir();
} catch {
return null;
}
};
const tempRoot = String(resolveTempRoot?.() || "").trim();
if (tempRoot) {
const safeId = String(chatSessionId || "default")
.replace(/[^a-zA-Z0-9._-]/g, "_")
.slice(0, 80) || "default";
const dir = path.join(tempRoot, "cursor-cli-mcp", safeId);
mkdir(dir, { recursive: true });
return dir;
}
const fallback = String(preferredCwd || process.cwd() || "").trim() || process.cwd();
try {
mkdir(path.join(fallback, ".cursor"), { recursive: true });
} catch {
/* caller / merge may still fail loudly */
}
return fallback;
}
// Per-path refcount so concurrent CLI turns share one original snapshot and only
// the last restorer writes the pre-merge file back (avoids last-writer-wins races).
const mcpMergeRefcounts = new Map();
function mergeWorkspaceMcpJson(cwd, injectedMcpServers, { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } = {}) {
const read = readFileSync || fs.readFileSync;
const write = writeFileSync || fs.writeFileSync;
const mkdir = mkdirSync || fs.mkdirSync;
const exists = existsSync || fs.existsSync;
const unlink = unlinkSync || ((p) => fs.unlinkSync(p));
const cursorDir = path.join(cwd || process.cwd(), ".cursor");
const mcpPath = path.join(cursorDir, "mcp.json");
let state = mcpMergeRefcounts.get(mcpPath);
if (!state) {
let previousRaw = null;
let previousExisted = false;
if (exists(mcpPath)) {
previousExisted = true;
previousRaw = read(mcpPath, "utf8");
}
state = { refCount: 0, previousRaw, previousExisted };
mcpMergeRefcounts.set(mcpPath, state);
}
state.refCount += 1;
let doc = { mcpServers: {} };
if (exists(mcpPath)) {
try {
const parsed = JSON.parse(read(mcpPath, "utf8"));
if (parsed && typeof parsed === "object") {
doc = parsed;
if (!doc.mcpServers || typeof doc.mcpServers !== "object") doc.mcpServers = {};
}
} catch {
doc = { mcpServers: {} };
}
} else if (state.previousExisted && state.previousRaw) {
try {
const parsed = JSON.parse(state.previousRaw);
if (parsed && typeof parsed === "object") {
doc = parsed;
if (!doc.mcpServers || typeof doc.mcpServers !== "object") doc.mcpServers = {};
}
} catch {
doc = { mcpServers: {} };
}
}
for (const cfg of injectedMcpServers || []) {
const mapped = mcpConfigToCursorMcpJsonEntry(cfg);
if (!mapped) continue;
doc.mcpServers[mapped.name] = mapped.entry;
}
try {
if (!exists(cursorDir)) {
mkdir(cursorDir, { recursive: true });
}
write(mcpPath, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
} catch (err) {
// Roll back refcount so a failed write does not pin the lock forever.
state.refCount = Math.max(0, state.refCount - 1);
if (state.refCount === 0) mcpMergeRefcounts.delete(mcpPath);
throw err;
}
let restored = false;
return {
mcpPath,
restore() {
if (restored) return;
restored = true;
const current = mcpMergeRefcounts.get(mcpPath);
if (!current) return;
current.refCount = Math.max(0, current.refCount - 1);
if (current.refCount > 0) return;
mcpMergeRefcounts.delete(mcpPath);
try {
if (current.previousExisted) write(mcpPath, current.previousRaw, "utf8");
else if (exists(mcpPath)) unlink(mcpPath);
} catch {
/* best effort */
}
},
};
}
/** Test helper: clear MCP merge refcount state between unit tests. */
function resetMcpMergeRefcountsForTests() {
mcpMergeRefcounts.clear();
}
function resultToText(result) {
if (result == null) return "";
if (typeof result === "string") return result;
if (typeof result === "number" || typeof result === "boolean") return String(result);
if (typeof result === "object") {
if (typeof result.content === "string") return result.content;
if (result.success && typeof result.success.content === "string") return result.success.content;
try { return JSON.stringify(result); } catch { return String(result); }
}
return String(result);
}
function extractCliToolCall(event) {
const callId = event?.call_id || event?.toolCallId || null;
const toolCall = event?.tool_call || event?.toolCall || null;
if (!toolCall || typeof toolCall !== "object") {
return { id: callId, name: event?.name || "tool", args: event?.args || {}, result: event?.result };
}
for (const [key, value] of Object.entries(toolCall)) {
if (!key.endsWith("ToolCall") || !value || typeof value !== "object") continue;
const name = key.replace(/ToolCall$/, "");
const args = value.args && typeof value.args === "object" ? value.args : {};
const result = value.result != null ? value.result : undefined;
return { id: callId || value.toolCallId || null, name, args, result };
}
return {
id: callId,
name: event?.name || "tool",
args: toolCall.args || {},
result: toolCall.result,
};
}
function closeReasoning(state, emitter) {
if (state?.reasoningOpen) {
emitter.reasoningEnd();
state.reasoningOpen = false;
}
}
function translateCursorCliEvent(event, emitter, state = {}) {
if (!event || typeof event !== "object") return false;
switch (event.type) {
case "system":
if (event.session_id) {
state.sessionId = event.session_id;
emitter.sessionId?.(event.session_id);
}
return false;
case "thinking":
if (event.subtype === "completed") {
closeReasoning(state, emitter);
return false;
}
if (event.text) {
emitter.reasoning(String(event.text));
state.reasoningOpen = true;
}
return false;
case "assistant": {
closeReasoning(state, emitter);
// With --stream-partial-output, Cursor emits three assistant shapes:
// timestamp_ms only → streaming delta (use)
// timestamp_ms + model_call_id → buffered flush before tool (skip)
// neither → final flush (skip if already streamed)
// See https://cursor.com/docs/cli/reference/output-format.md#stream-json-format
if (event.model_call_id) return false;
const isPartial = Boolean(event.timestamp_ms);
const content = event.message?.content;
if (!Array.isArray(content)) return false;
let text = "";
for (const block of content) {
if (block?.type === "text" && block.text) text += String(block.text);
}
if (!text) return false;
if (!isPartial) {
if (state.streamedAssistantText) return false;
emitter.text(text);
state.streamedAssistantText = true;
return false;
}
emitter.text(text);
state.streamedAssistantText = true;
return false;
}
case "tool_call": {
closeReasoning(state, emitter);
const { id, name, args, result } = extractCliToolCall(event);
if (!id) return false;
if (!state.emittedToolCalls) state.emittedToolCalls = new Set();
if (!state.emittedToolResults) state.emittedToolResults = new Set();
const subtype = String(event.subtype || "");
if (subtype === "started" || subtype === "running" || !subtype) {
if (!state.emittedToolCalls.has(id)) {
state.emittedToolCalls.add(id);
emitter.toolCall(name || "tool", args && typeof args === "object" ? args : {}, id);
}
}
if (subtype === "completed" || subtype === "error") {
if (!state.emittedToolCalls.has(id)) {
state.emittedToolCalls.add(id);
emitter.toolCall(name || "tool", args && typeof args === "object" ? args : {}, id);
}
if (!state.emittedToolResults.has(id)) {
state.emittedToolResults.add(id);
emitter.toolResult(id, resultToText(result || event.error || ""), name || "tool");
}
}
return false;
}
case "result":
closeReasoning(state, emitter);
if (event.session_id) {
state.sessionId = event.session_id;
emitter.sessionId?.(event.session_id);
}
if (event.is_error || event.subtype === "error") {
state.failed = true;
const message = String(event.result || event.error || event.message || "Cursor CLI turn failed");
emitter.emitError(formatCursorCliErrorForUser(message));
return true;
}
return false;
case "error":
closeReasoning(state, emitter);
state.failed = true;
emitter.emitError(formatCursorCliErrorForUser(event.message || event.error || "Cursor CLI turn failed"));
return true;
default:
return false;
}
}
function formatCursorCliErrorForUser(message) {
const text = String(message || "").trim();
if (
/not authenticated|not logged in|please run .*login|unauthenticated|unauthorized/i.test(text)
|| /(?:^|\b)(?:agent|cursor-agent)\s+login\b/i.test(text)
) {
return "Cursor CLI is not logged in. Run `cursor-agent login` in a terminal, then retry.";
}
if (/\bapi[_\s-]?key\b/i.test(text) && /invalid|missing|required|auth/i.test(text)) {
return "Cursor CLI authentication failed. Run `cursor-agent login` or switch Cursor to API Key mode in Settings → AI.";
}
return text || "Cursor CLI turn failed";
}
function createLineBuffer(onLine, maxBufferBytes = MAX_CURSOR_CLI_LINE_BYTES) {
let buffer = "";
let bufferedBytes = 0;
let overflowed = false;
const decoder = new StringDecoder("utf8");
return {
push(chunk) {
if (overflowed) return;
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk || ""));
bufferedBytes += bytes.length;
buffer += decoder.write(bytes);
let idx;
let consumedLine = false;
while ((idx = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, idx).trim();
buffer = buffer.slice(idx + 1);
consumedLine = true;
if (line) onLine(line);
}
if (consumedLine) bufferedBytes = Buffer.byteLength(buffer, "utf8") + decoder.lastNeed;
if (bufferedBytes > maxBufferBytes) {
overflowed = true;
buffer = "";
const error = new Error(`Cursor CLI message exceeded ${maxBufferBytes} bytes`);
error.code = "CURSOR_CLI_LINE_LIMIT";
throw error;
}
},
flush() {
if (overflowed) return;
buffer += decoder.end();
const line = buffer.trim();
buffer = "";
if (line) onLine(line);
},
};
}
async function runCursorCliTurn({
prompt,
binPath,
cwd,
chatSessionId,
getTempDir,
model,
env,
permissionMode,
resumeSessionId,
injectedMcpServers,
emitter,
signal,
spawnImpl,
mergeMcp,
workspaceCwd,
abortGraceMs = CURSOR_CLI_ABORT_GRACE_MS,
forceKillImpl,
}) {
const cliPath = String(binPath || "").trim();
if (!cliPath) {
emitter.emitError("Cursor Agent CLI not found. Install the Cursor CLI (`cursor-agent`) and ensure it is on PATH.");
return { sessionId: resumeSessionId || null };
}
let effectiveCwd;
try {
effectiveCwd = workspaceCwd || resolveCursorCliWorkspaceCwd({
preferredCwd: cwd,
chatSessionId,
getTempDir,
});
} catch (err) {
emitter.emitError(
"Failed to prepare Netcatty MCP for Cursor CLI "
+ `(cannot create workspace: ${err?.message || err}). `
+ "Terminal tools will be unavailable.",
);
return { sessionId: resumeSessionId || null };
}
const childEnv = stripCursorApiKeyFromEnv(env || process.env);
const args = buildCursorCliArgs({
model,
resumeSessionId,
permissionMode,
cwd: effectiveCwd,
prompt,
});
const doMerge = mergeMcp || mergeWorkspaceMcpJson;
let mcpHandle = null;
if (Array.isArray(injectedMcpServers) && injectedMcpServers.length > 0) {
try {
mcpHandle = doMerge(effectiveCwd, injectedMcpServers);
} catch (err) {
emitter.emitError(
"Failed to prepare Netcatty MCP for Cursor CLI "
+ `(cannot write workspace MCP config: ${err?.message || err}). `
+ "Terminal tools will be unavailable.",
);
return { sessionId: resumeSessionId || null };
}
}
const state = {
sessionId: resumeSessionId || null,
reasoningOpen: false,
streamedAssistantText: false,
failed: false,
};
let child = null;
let settled = false;
const cleanup = () => {
try { mcpHandle?.restore?.(); } catch { /* ignore */ }
};
try {
child = spawnCursorCliProcess(spawnImpl, cliPath, args, {
cwd: effectiveCwd,
env: childEnv,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
detached: process.platform !== "win32",
});
} catch (err) {
cleanup();
emitter.emitError(formatCursorCliErrorForUser(err?.message || String(err)));
return { sessionId: state.sessionId };
}
const handleLine = (line) => {
// Soft-cancel: ignore late stream-json after Stop (result/error would emitError).
if (signal?.aborted) return;
let event;
try {
event = JSON.parse(line);
} catch {
return;
}
const stop = translateCursorCliEvent(event, emitter, state);
if (stop && !signal?.aborted) state.failed = true;
};
const stdoutBuffer = createLineBuffer(handleLine);
let stderrText = "";
let stderrBytes = 0;
let stderrTruncated = false;
let stderrEnded = false;
const stderrDecoder = new StringDecoder("utf8");
child.stdout?.on("data", (chunk) => {
if (signal?.aborted) return;
try {
stdoutBuffer.push(chunk);
} catch (error) {
if (!state.failed) {
state.failed = true;
emitter.emitError(formatCursorCliErrorForUser(error?.message || String(error)));
}
signalCursorCliProcessTree(child, "SIGKILL", forceKillImpl);
}
});
child.stderr?.on("data", (chunk) => {
if (signal?.aborted) return;
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
const remaining = Math.max(0, MAX_CURSOR_CLI_STDERR_CHARS - stderrBytes);
const accepted = buffer.length <= remaining ? buffer : buffer.subarray(0, remaining);
if (accepted.length > 0) stderrText += stderrDecoder.write(accepted);
stderrBytes += accepted.length;
if (accepted.length < buffer.length) stderrTruncated = true;
});
let abortHandler = null;
let forceKillTimer = null;
await new Promise((resolve) => {
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(forceKillTimer);
// Only flush remaining lines if not aborted — late error/result after
// Stop must not surface as a failed turn.
if (!signal?.aborted) stdoutBuffer.flush();
resolve();
};
child.on("error", (err) => {
// Soft-cancel: do not surface spawn errors after user Stop.
if (!state.failed && !signal?.aborted) {
state.failed = true;
emitter.emitError(formatCursorCliErrorForUser(err?.message || String(err)));
}
finish();
});
child.on("close", (code) => {
if (!stderrEnded) {
stderrEnded = true;
if (!stderrTruncated || stderrDecoder.lastNeed === 0) stderrText += stderrDecoder.end();
}
// Soft-cancel: SIGTERM/kill after abort is not a turn failure.
if (!state.failed && !signal?.aborted && code && code !== 0 && !state.streamedAssistantText) {
const stderr = stderrText.trim();
const message = stderr || `Cursor CLI exited with code ${code}`;
state.failed = true;
emitter.emitError(formatCursorCliErrorForUser(message));
}
finish();
});
let terminationStarted = false;
abortHandler = () => {
if (settled || terminationStarted) return;
terminationStarted = true;
forceKillTimer = setTimeout(() => {
if (settled) return;
signalCursorCliProcessTree(child, "SIGKILL", forceKillImpl);
// Process APIs do not guarantee a close event when process-tree
// termination itself fails. Stop must still release MCP config and the
// renderer request within a fixed deadline.
finish();
}, Math.max(0, abortGraceMs));
forceKillTimer.unref?.();
signalCursorCliProcessTree(child, "SIGTERM");
};
if (signal) {
if (signal.aborted) abortHandler();
else signal.addEventListener("abort", abortHandler, { once: true });
}
});
if (signal) signal.removeEventListener("abort", abortHandler);
cleanup();
closeReasoning(state, emitter);
// Match cursorDriver: aborted turns must not report as successful done.
if (!state.failed && !signal?.aborted) {
emitter.emitDone();
}
return { sessionId: state.sessionId };
}
async function listCursorCliModels({
binPath,
env,
spawnImpl,
abortController,
signal,
abortGraceMs = CURSOR_CLI_ABORT_GRACE_MS,
forceKillImpl,
} = {}) {
const cliPath = String(binPath || "").trim();
if (!cliPath) return { currentModelId: null, models: [] };
const abortSignal = signal || abortController?.signal;
if (abortSignal?.aborted) return { currentModelId: null, models: [] };
const childEnv = stripCursorApiKeyFromEnv(env || process.env);
return await new Promise((resolve) => {
let stdout = "";
let stdoutBytes = 0;
let stdoutTruncated = false;
let stdoutEnded = false;
const stdoutDecoder = new StringDecoder("utf8");
let settled = false;
let abortHandler = null;
let forceKillTimer = null;
const finish = (value) => {
if (settled) return;
settled = true;
clearTimeout(forceKillTimer);
if (abortSignal && abortHandler) {
abortSignal.removeEventListener("abort", abortHandler);
}
resolve(value);
};
let child;
try {
child = spawnCursorCliProcess(spawnImpl, cliPath, ["models"], {
env: childEnv,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
detached: process.platform !== "win32",
});
} catch {
finish({ currentModelId: null, models: [] });
return;
}
child.stdout?.on("data", (chunk) => {
if (abortSignal?.aborted) return;
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
const remaining = Math.max(0, MAX_CURSOR_CLI_MODEL_STDOUT_CHARS - stdoutBytes);
const accepted = buffer.length <= remaining ? buffer : buffer.subarray(0, remaining);
if (accepted.length > 0) stdout += stdoutDecoder.write(accepted);
stdoutBytes += accepted.length;
if (accepted.length < buffer.length) stdoutTruncated = true;
});
child.on("error", () => finish({ currentModelId: null, models: [] }));
child.on("close", () => {
if (!stdoutEnded) {
stdoutEnded = true;
if (!stdoutTruncated || stdoutDecoder.lastNeed === 0) stdout += stdoutDecoder.end();
}
const models = [];
const seen = new Set();
let currentModelId = null;
for (const line of String(stdout).split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || /^available models$/i.test(trimmed)) continue;
const match = trimmed.match(/^([a-z0-9][a-z0-9._-]*)\s+-\s+(.+)$/i);
if (!match) continue;
const id = match[1];
if (seen.has(id)) continue;
seen.add(id);
const rawName = match[2].trim();
const isCurrent = /\(\s*current(?:\s*,\s*default)?\s*\)/i.test(rawName);
if (isCurrent) currentModelId = id;
const name = rawName
.replace(/\s*\(\s*current(?:\s*,\s*default)?\s*\)\s*/ig, " ")
.replace(/\s{2,}/g, " ")
.trim() || id;
models.push({ id, name });
}
if (!currentModelId && models.some((model) => model.id === "auto")) {
currentModelId = "auto";
}
finish({ currentModelId, models });
});
abortHandler = () => {
if (settled) return;
forceKillTimer = setTimeout(() => {
if (settled) return;
signalCursorCliProcessTree(child, "SIGKILL", forceKillImpl);
finish({ currentModelId: null, models: [] });
}, Math.max(0, abortGraceMs));
forceKillTimer.unref?.();
signalCursorCliProcessTree(child, "SIGTERM", forceKillImpl);
};
if (abortSignal) {
if (abortSignal.aborted) abortHandler();
else abortSignal.addEventListener("abort", abortHandler, { once: true });
}
});
}
module.exports = {
DEFAULT_CURSOR_CLI_MODEL,
MAX_CURSOR_CLI_LINE_BYTES,
NETCATTY_MCP_NAME,
buildCursorCliArgs,
createLineBuffer,
formatCursorCliErrorForUser,
listCursorCliModels,
mergeWorkspaceMcpJson,
resetMcpMergeRefcountsForTests,
resolveCursorCliExecMode,
resolveCursorCliModel,
resolveCursorCliSpawnSpec,
resolveCursorCliWorkspaceCwd,
runCursorCliTurn,
spawnCursorCliProcess,
stripCursorApiKeyFromEnv,
translateCursorCliEvent,
};

View File

@@ -0,0 +1,868 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
buildCursorCliArgs,
createLineBuffer,
formatCursorCliErrorForUser,
listCursorCliModels,
mergeWorkspaceMcpJson,
resetMcpMergeRefcountsForTests,
resolveCursorCliExecMode,
resolveCursorCliModel,
resolveCursorCliSpawnSpec,
resolveCursorCliWorkspaceCwd,
runCursorCliTurn,
spawnCursorCliProcess,
stripCursorApiKeyFromEnv,
translateCursorCliEvent,
} = require("./cursorCliDriver.cjs");
function makeEmitter() {
const calls = [];
return {
calls,
text: (value) => calls.push(["text", value]),
reasoning: (value) => calls.push(["reasoning", value]),
reasoningEnd: () => calls.push(["reasoningEnd"]),
toolCall: (name, args, id) => calls.push(["toolCall", name, args, id]),
toolResult: (id, result, name) => calls.push(["toolResult", id, result, name]),
sessionId: (id) => calls.push(["sessionId", id]),
emitDone: () => calls.push(["done"]),
emitError: (message) => calls.push(["error", message]),
};
}
test("resolveCursorCliModel defaults to auto", () => {
assert.equal(resolveCursorCliModel(undefined), "auto");
assert.equal(resolveCursorCliModel(""), "auto");
assert.equal(resolveCursorCliModel("composer-2.5"), "composer-2.5");
assert.equal(resolveCursorCliModel("gpt-5/high"), "gpt-5?effort=high");
});
test("stripCursorApiKeyFromEnv removes CURSOR_API_KEY", () => {
assert.deepEqual(
stripCursorApiKeyFromEnv({ CURSOR_API_KEY: "secret", PATH: "/bin" }),
{ PATH: "/bin" },
);
});
test("createLineBuffer rejects and releases an unterminated oversized message", () => {
const lines = [];
const lineBuffer = createLineBuffer((line) => lines.push(line), 8);
lineBuffer.push(Buffer.from("12345678"));
assert.throws(
() => lineBuffer.push(Buffer.from("9")),
(error) => error?.code === "CURSOR_CLI_LINE_LIMIT",
);
lineBuffer.flush();
assert.deepEqual(lines, []);
});
test("buildCursorCliArgs maps permission modes and resume", () => {
assert.deepEqual(
buildCursorCliArgs({
model: "",
permissionMode: "observer",
resumeSessionId: "sess-1",
cwd: "/repo",
prompt: "hi",
}),
[
"--print",
"--trust",
"--approve-mcps",
"--output-format",
"stream-json",
"--stream-partial-output",
"--model",
"auto",
"--workspace",
"/repo",
"--resume",
"sess-1",
"--mode",
"ask",
"hi",
],
);
const autoArgs = buildCursorCliArgs({
model: "auto",
permissionMode: "auto",
cwd: "/repo",
prompt: "go",
});
assert.ok(autoArgs.includes("--force"));
assert.ok(!autoArgs.includes("--mode"));
// confirm must pass --force: stdin is ignored and Cursor asks y/n for shell tools.
const confirmArgs = buildCursorCliArgs({
model: "auto",
permissionMode: "confirm",
cwd: "/repo",
prompt: "go",
});
assert.ok(confirmArgs.includes("--force"));
assert.ok(!confirmArgs.includes("--mode"));
});
test("formatCursorCliErrorForUser does not over-match bare login strings", () => {
assert.match(
formatCursorCliErrorForUser("Not authenticated"),
/not logged in/i,
);
assert.equal(
formatCursorCliErrorForUser("Failed to run login form validation"),
"Failed to run login form validation",
);
});
test("translateCursorCliEvent streams thinking, text, and tools", () => {
const emitter = makeEmitter();
const state = {};
translateCursorCliEvent({ type: "system", subtype: "init", session_id: "s1" }, emitter, state);
translateCursorCliEvent({ type: "thinking", subtype: "delta", text: "plan" }, emitter, state);
translateCursorCliEvent({ type: "thinking", subtype: "completed" }, emitter, state);
translateCursorCliEvent({
type: "assistant",
timestamp_ms: 1,
message: { content: [{ type: "text", text: "Hi" }] },
}, emitter, state);
translateCursorCliEvent({
type: "assistant",
timestamp_ms: 2,
model_call_id: "call-dup",
message: { content: [{ type: "text", text: "Hi" }] },
}, emitter, state);
translateCursorCliEvent({
type: "assistant",
message: { content: [{ type: "text", text: "Hi" }] },
}, emitter, state);
translateCursorCliEvent({
type: "tool_call",
subtype: "started",
call_id: "c1",
tool_call: { getMcpToolsToolCall: { args: { a: 1 } } },
}, emitter, state);
translateCursorCliEvent({
type: "tool_call",
subtype: "completed",
call_id: "c1",
tool_call: { getMcpToolsToolCall: { args: { a: 1 }, result: { success: { content: "ok" } } } },
}, emitter, state);
assert.deepEqual(emitter.calls, [
["sessionId", "s1"],
["reasoning", "plan"],
["reasoningEnd"],
["text", "Hi"],
["toolCall", "getMcpTools", { a: 1 }, "c1"],
["toolResult", "c1", "ok", "getMcpTools"],
]);
assert.equal(state.sessionId, "s1");
});
test("resolveCursorCliExecMode maps observer to ask and others to agent", () => {
assert.equal(resolveCursorCliExecMode("observer"), "ask");
assert.equal(resolveCursorCliExecMode("confirm"), "agent");
assert.equal(resolveCursorCliExecMode("auto"), "agent");
});
test("mergeWorkspaceMcpJson upserts netcatty without dropping others", () => {
resetMcpMergeRefcountsForTests();
const files = new Map();
files.set("/repo/.cursor/mcp.json", JSON.stringify({
mcpServers: { other: { command: "echo" } },
}, null, 2));
const handle = mergeWorkspaceMcpJson("/repo", [{
name: "netcatty-remote-hosts",
command: "node",
args: ["mcp.cjs"],
env: [{ name: "TOKEN", value: "x" }],
}], {
existsSync: (p) => files.has(p) || p === "/repo/.cursor",
readFileSync: (p) => files.get(p),
writeFileSync: (p, data) => { files.set(p, data); },
mkdirSync: () => {},
});
const written = JSON.parse(files.get("/repo/.cursor/mcp.json"));
assert.equal(written.mcpServers.other.command, "echo");
assert.equal(written.mcpServers["netcatty-remote-hosts"].command, "node");
assert.equal(written.mcpServers["netcatty-remote-hosts"].type, "stdio");
assert.equal(written.mcpServers["netcatty-remote-hosts"].env.TOKEN, "x");
handle.restore();
assert.ok(files.get("/repo/.cursor/mcp.json").includes('"other"'));
});
test("mergeWorkspaceMcpJson concurrent turns restore original only after last", () => {
resetMcpMergeRefcountsForTests();
const files = new Map();
const original = JSON.stringify({ mcpServers: { other: { command: "echo" } } }, null, 2);
files.set("/repo/.cursor/mcp.json", original);
const fsApi = {
existsSync: (p) => files.has(p) || p === "/repo/.cursor",
readFileSync: (p) => files.get(p),
writeFileSync: (p, data) => { files.set(p, data); },
mkdirSync: () => {},
};
const a = mergeWorkspaceMcpJson("/repo", [{
name: "netcatty-remote-hosts",
command: "node",
args: ["a.cjs"],
}], fsApi);
const b = mergeWorkspaceMcpJson("/repo", [{
name: "netcatty-remote-hosts",
command: "node",
args: ["b.cjs"],
}], fsApi);
a.restore();
// First restore must keep the merged file while another turn is in flight.
assert.ok(files.get("/repo/.cursor/mcp.json").includes("netcatty-remote-hosts"));
b.restore();
assert.equal(files.get("/repo/.cursor/mcp.json"), original);
});
test("runCursorCliTurn strips API key, parses stream, emits done", async () => {
const emitter = makeEmitter();
const observed = { env: null, args: null };
const fakeChild = new EventEmitter();
fakeChild.stdout = new EventEmitter();
fakeChild.stderr = new EventEmitter();
fakeChild.killed = false;
fakeChild.kill = () => { fakeChild.killed = true; };
const result = await new Promise((resolve, reject) => {
runCursorCliTurn({
prompt: "hi",
binPath: "/bin/agent",
cwd: "/repo",
model: "",
env: { CURSOR_API_KEY: "secret", PATH: "/bin" },
permissionMode: "confirm",
injectedMcpServers: [],
emitter,
spawnImpl: (cmd, args, opts) => {
observed.env = opts.env;
observed.args = args;
queueMicrotask(() => {
fakeChild.stdout.emit("data", `${JSON.stringify({
type: "system", subtype: "init", session_id: "sess-cli", apiKeySource: "login",
})}\n`);
fakeChild.stdout.emit("data", `${JSON.stringify({
type: "assistant", timestamp_ms: 1, message: { content: [{ type: "text", text: "PONG" }] },
})}\n`);
fakeChild.stdout.emit("data", `${JSON.stringify({
type: "result", subtype: "success", session_id: "sess-cli", result: "PONG",
})}\n`);
fakeChild.emit("close", 0);
});
return fakeChild;
},
mergeMcp: () => ({ restore() {} }),
}).then(resolve, reject);
});
assert.equal(observed.env.CURSOR_API_KEY, undefined);
assert.equal(observed.env.PATH, "/bin");
assert.ok(observed.args.includes("auto"));
assert.ok(observed.args.includes("--force"));
assert.equal(result.sessionId, "sess-cli");
assert.deepEqual(emitter.calls, [
["sessionId", "sess-cli"],
["text", "PONG"],
["sessionId", "sess-cli"],
["done"],
]);
});
test("runCursorCliTurn preserves a Chinese JSON event split across UTF-8 chunks", async () => {
const emitter = makeEmitter();
const fakeChild = new EventEmitter();
fakeChild.stdout = new EventEmitter();
fakeChild.stderr = new EventEmitter();
fakeChild.kill = () => {};
await runCursorCliTurn({
prompt: "hi",
binPath: "/bin/agent",
cwd: "/repo",
env: {},
permissionMode: "confirm",
injectedMcpServers: [],
emitter,
spawnImpl: () => {
queueMicrotask(() => {
const line = Buffer.from(`${JSON.stringify({
type: "assistant",
timestamp_ms: 1,
message: { content: [{ type: "text", text: "中文回复" }] },
})}\n`, "utf8");
const split = line.indexOf(Buffer.from("中", "utf8")) + 2;
fakeChild.stdout.emit("data", line.subarray(0, split));
fakeChild.stdout.emit("data", line.subarray(split));
fakeChild.emit("close", 0);
});
return fakeChild;
},
mergeMcp: () => ({ restore() {} }),
});
assert.ok(emitter.calls.some((call) => call[0] === "text" && call[1] === "中文回复"));
});
test("runCursorCliTurn preserves Chinese stderr split across UTF-8 chunks", async () => {
const emitter = makeEmitter();
const fakeChild = new EventEmitter();
fakeChild.stdout = new EventEmitter();
fakeChild.stderr = new EventEmitter();
fakeChild.kill = () => {};
await runCursorCliTurn({
prompt: "hi",
binPath: "/bin/agent",
cwd: "/repo",
env: {},
permissionMode: "confirm",
injectedMcpServers: [],
emitter,
spawnImpl: () => {
queueMicrotask(() => {
const bytes = Buffer.from("中文错误", "utf8");
fakeChild.stderr.emit("data", bytes.subarray(0, 2));
fakeChild.stderr.emit("data", bytes.subarray(2));
fakeChild.emit("close", 1);
});
return fakeChild;
},
mergeMcp: () => ({ restore() {} }),
});
assert.ok(emitter.calls.some((call) => call[0] === "error" && call[1] === "中文错误"));
});
test("runCursorCliTurn abort after text does not emit done", async () => {
const emitter = makeEmitter();
const ac = new AbortController();
const fakeChild = new EventEmitter();
fakeChild.stdout = new EventEmitter();
fakeChild.stderr = new EventEmitter();
fakeChild.killed = false;
fakeChild.kill = () => {
fakeChild.killed = true;
queueMicrotask(() => fakeChild.emit("close", 143));
};
const turnPromise = runCursorCliTurn({
prompt: "hi",
binPath: "/bin/agent",
cwd: "/repo",
model: "auto",
env: {},
permissionMode: "confirm",
injectedMcpServers: [],
emitter,
signal: ac.signal,
spawnImpl: () => {
queueMicrotask(() => {
fakeChild.stdout.emit("data", `${JSON.stringify({
type: "assistant", timestamp_ms: 1, message: { content: [{ type: "text", text: "partial" }] },
})}\n`);
ac.abort();
});
return fakeChild;
},
mergeMcp: () => ({ restore() {} }),
});
await turnPromise;
assert.ok(fakeChild.killed);
assert.deepEqual(emitter.calls, [
["text", "partial"],
]);
assert.ok(!emitter.calls.some((c) => c[0] === "done"));
assert.ok(!emitter.calls.some((c) => c[0] === "error"));
});
test("runCursorCliTurn abort before any text is soft cancel (no error/done)", async () => {
const emitter = makeEmitter();
const ac = new AbortController();
const fakeChild = new EventEmitter();
fakeChild.stdout = new EventEmitter();
fakeChild.stderr = new EventEmitter();
fakeChild.killed = false;
fakeChild.kill = () => {
fakeChild.killed = true;
queueMicrotask(() => fakeChild.emit("close", 143));
};
await runCursorCliTurn({
prompt: "hi",
binPath: "/bin/agent",
cwd: "/repo",
model: "auto",
env: {},
permissionMode: "confirm",
injectedMcpServers: [],
emitter,
signal: ac.signal,
spawnImpl: () => {
queueMicrotask(() => ac.abort());
return fakeChild;
},
mergeMcp: () => ({ restore() {} }),
});
assert.ok(fakeChild.killed);
assert.deepEqual(emitter.calls, []);
});
test("runCursorCliTurn force-kills and settles when the CLI ignores SIGTERM", async () => {
const emitter = makeEmitter();
const ac = new AbortController();
const fakeChild = new EventEmitter();
fakeChild.stdout = new EventEmitter();
fakeChild.stderr = new EventEmitter();
fakeChild.killed = false;
const signals = [];
fakeChild.kill = (signal) => {
signals.push(signal);
return true;
};
let restored = false;
const turn = runCursorCliTurn({
prompt: "hi",
binPath: "/bin/agent",
cwd: "/repo",
model: "auto",
env: {},
permissionMode: "confirm",
injectedMcpServers: [{ name: "netcatty", command: "node", args: [] }],
emitter,
signal: ac.signal,
abortGraceMs: 5,
forceKillImpl: (child) => child.kill("SIGKILL"),
spawnImpl: () => fakeChild,
mergeMcp: () => ({ restore() { restored = true; } }),
});
ac.abort();
await Promise.race([
turn,
new Promise((_, reject) => setTimeout(() => reject(new Error("aborted Cursor CLI did not settle")), 50)),
]);
assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]);
assert.equal(restored, true);
assert.deepEqual(emitter.calls, []);
});
test("runCursorCliTurn ignores late error events after abort (before text)", async () => {
const emitter = makeEmitter();
const ac = new AbortController();
const fakeChild = new EventEmitter();
fakeChild.stdout = new EventEmitter();
fakeChild.stderr = new EventEmitter();
fakeChild.killed = false;
fakeChild.kill = () => {
fakeChild.killed = true;
};
await runCursorCliTurn({
prompt: "hi",
binPath: "/bin/agent",
cwd: "/repo",
model: "auto",
env: {},
permissionMode: "confirm",
injectedMcpServers: [],
emitter,
signal: ac.signal,
spawnImpl: () => {
queueMicrotask(() => {
ac.abort();
// Late stream after Stop — must not surface as emitError.
fakeChild.stdout.emit("data", `${JSON.stringify({
type: "error", message: "not authenticated",
})}\n`);
fakeChild.stdout.emit("data", `${JSON.stringify({
type: "result", subtype: "error", is_error: true, result: "boom",
})}\n`);
fakeChild.emit("close", 1);
});
return fakeChild;
},
mergeMcp: () => ({ restore() {} }),
});
assert.deepEqual(emitter.calls, []);
assert.ok(!emitter.calls.some((c) => c[0] === "error"));
assert.ok(!emitter.calls.some((c) => c[0] === "done"));
});
test("runCursorCliTurn ignores late error after abort following partial text", async () => {
const emitter = makeEmitter();
const ac = new AbortController();
const fakeChild = new EventEmitter();
fakeChild.stdout = new EventEmitter();
fakeChild.stderr = new EventEmitter();
fakeChild.killed = false;
fakeChild.kill = () => {
fakeChild.killed = true;
};
await runCursorCliTurn({
prompt: "hi",
binPath: "/bin/agent",
cwd: "/repo",
model: "auto",
env: {},
permissionMode: "auto",
injectedMcpServers: [],
emitter,
signal: ac.signal,
spawnImpl: () => {
queueMicrotask(() => {
fakeChild.stdout.emit("data", `${JSON.stringify({
type: "assistant", timestamp_ms: 1, message: { content: [{ type: "text", text: "hi" }] },
})}\n`);
ac.abort();
fakeChild.stdout.emit("data", `${JSON.stringify({
type: "result", subtype: "error", is_error: true, result: "killed",
})}\n`);
fakeChild.emit("close", 143);
});
return fakeChild;
},
mergeMcp: () => ({ restore() {} }),
});
assert.deepEqual(emitter.calls, [
["text", "hi"],
]);
assert.ok(!emitter.calls.some((c) => c[0] === "error"));
assert.ok(!emitter.calls.some((c) => c[0] === "done"));
});
test("runCursorCliTurn closes open reasoning before done", async () => {
const emitter = makeEmitter();
const fakeChild = new EventEmitter();
fakeChild.stdout = new EventEmitter();
fakeChild.stderr = new EventEmitter();
fakeChild.killed = false;
fakeChild.kill = () => { fakeChild.killed = true; };
await runCursorCliTurn({
prompt: "hi",
binPath: "/bin/agent",
cwd: "/repo",
model: "auto",
env: {},
permissionMode: "auto",
injectedMcpServers: [],
emitter,
spawnImpl: () => {
queueMicrotask(() => {
fakeChild.stdout.emit("data", `${JSON.stringify({
type: "thinking", subtype: "delta", text: "hmm",
})}\n`);
fakeChild.emit("close", 0);
});
return fakeChild;
},
mergeMcp: () => ({ restore() {} }),
});
assert.deepEqual(emitter.calls, [
["reasoning", "hmm"],
["reasoningEnd"],
["done"],
]);
});
test("resolveCursorCliSpawnSpec keeps a native exe on argv without a shell", () => {
const exePath = process.platform === "win32"
? "C:\\Users\\me\\AppData\\Local\\cursor-agent\\cursor-agent.exe"
: "/usr/local/bin/cursor-agent";
const args = ["--print", "--trust"];
const exe = resolveCursorCliSpawnSpec(exePath, args);
assert.equal(exe.shell, false);
assert.equal(exe.command, exePath);
assert.deepEqual(exe.args, args);
});
test("spawnCursorCliProcess launches the installer node+script with the prompt on argv", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-turn-spawn-"));
try {
const versionDir = path.join(tmp, "versions", "2026.06.01-abc");
fs.mkdirSync(versionDir, { recursive: true });
const nodeExe = path.join(versionDir, "node.exe");
const script = path.join(versionDir, "index.js");
fs.writeFileSync(nodeExe, "", "utf8");
fs.writeFileSync(script, "", "utf8");
const shimPath = path.join(tmp, "cursor-agent.cmd");
fs.writeFileSync(
shimPath,
`@ECHO off\r\n"%~dp0\\versions\\2026.06.01-abc\\node.exe" "%~dp0\\versions\\2026.06.01-abc\\index.js" %*\r\n`,
"utf8",
);
const prompt = 'review "%TEMP%" then run whoami';
const calls = [];
spawnCursorCliProcess(
(command, args, options) => {
calls.push({ command, args, options });
return { stdout: { on() {} }, stderr: { on() {} }, on() {}, kill() {} };
},
shimPath,
["--print", "--trust", prompt],
{ windowsHide: true },
);
assert.equal(calls.length, 1);
assert.equal(calls[0].command, nodeExe);
assert.deepEqual(calls[0].args, [script, "--print", "--trust", prompt]);
assert.equal(calls[0].options.shell, false);
assert.equal(String(calls[0].command).includes("cmd.exe"), false);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("spawnCursorCliProcess forwards shell from resolveCursorCliSpawnSpec", () => {
const calls = [];
const fakeChild = {
stdout: { on() {} },
stderr: { on() {} },
stdin: null,
on() {},
kill() {},
};
const cliPath = "/usr/local/bin/cursor-agent";
const child = spawnCursorCliProcess(
(command, args, options) => {
calls.push({ command, args, options });
return fakeChild;
},
cliPath,
["models"],
{ cwd: "/repo", windowsHide: true },
);
assert.equal(child, fakeChild);
assert.equal(calls.length, 1);
assert.equal(calls[0].command, cliPath);
assert.deepEqual(calls[0].args, ["models"]);
assert.equal(calls[0].options.cwd, "/repo");
assert.equal(calls[0].options.windowsHide, true);
assert.equal(calls[0].options.shell, false);
});
test("listCursorCliModels parses agent models output and prefers auto", async () => {
const fakeChild = new EventEmitter();
fakeChild.stdout = new EventEmitter();
fakeChild.stderr = new EventEmitter();
const catalog = await listCursorCliModels({
binPath: "/bin/agent",
env: { CURSOR_API_KEY: "secret" },
spawnImpl: (cmd, args, opts) => {
assert.equal(cmd, "/bin/agent");
assert.deepEqual(args, ["models"]);
assert.equal(opts.env.CURSOR_API_KEY, undefined);
queueMicrotask(() => {
fakeChild.stdout.emit("data", [
"Available models",
"",
"auto - Auto (current, default)",
"composer-2.5 - Composer 2.5",
"gpt-5.2 - GPT-5.2",
"",
].join("\n"));
fakeChild.emit("close", 0);
});
return fakeChild;
},
});
assert.deepEqual(catalog, {
currentModelId: "auto",
models: [
{ id: "auto", name: "Auto" },
{ id: "composer-2.5", name: "Composer 2.5" },
{ id: "gpt-5.2", name: "GPT-5.2" },
],
});
});
test("listCursorCliModels preserves Chinese model names split across UTF-8 chunks", async () => {
const fakeChild = new EventEmitter();
fakeChild.stdout = new EventEmitter();
fakeChild.stderr = new EventEmitter();
fakeChild.kill = () => {};
const catalogPromise = listCursorCliModels({
binPath: "/bin/agent",
env: {},
spawnImpl: () => {
queueMicrotask(() => {
const bytes = Buffer.from("model-cn - 中文模型\n", "utf8");
const split = bytes.indexOf(Buffer.from("中", "utf8")) + 1;
fakeChild.stdout.emit("data", bytes.subarray(0, split));
fakeChild.stdout.emit("data", bytes.subarray(split));
fakeChild.emit("close", 0);
});
return fakeChild;
},
});
assert.deepEqual(await catalogPromise, {
currentModelId: null,
models: [{ id: "model-cn", name: "中文模型" }],
});
});
test("listCursorCliModels aborts a hung CLI and settles after forced cleanup", async () => {
const fakeChild = new EventEmitter();
fakeChild.stdout = new EventEmitter();
fakeChild.stderr = new EventEmitter();
fakeChild.pid = 4242;
const signals = [];
const abortController = new AbortController();
const catalogPromise = listCursorCliModels({
binPath: "/bin/agent",
env: {},
abortController,
abortGraceMs: 0,
forceKillImpl: (_child, signal) => signals.push(signal),
spawnImpl: () => fakeChild,
});
abortController.abort();
const outcome = await Promise.race([
catalogPromise.then(() => "settled"),
new Promise((resolve) => setTimeout(() => resolve("hung"), 20)),
]);
if (outcome === "hung") fakeChild.emit("close", 0);
assert.equal(outcome, "settled");
assert.deepEqual(await catalogPromise, { currentModelId: null, models: [] });
assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]);
});
test("resolveCursorCliWorkspaceCwd prefers Netcatty temp over unwritable preferred cwd", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-ws-"));
const resolved = resolveCursorCliWorkspaceCwd({
preferredCwd: "/",
chatSessionId: "ai_chat_1",
getTempDir: () => tempRoot,
});
assert.equal(resolved, path.join(tempRoot, "cursor-cli-mcp", "ai_chat_1"));
assert.ok(fs.statSync(resolved).isDirectory());
fs.rmSync(tempRoot, { recursive: true, force: true });
});
test("runCursorCliTurn uses temp workspace for MCP merge and --workspace when cwd is /", async () => {
const emitter = makeEmitter();
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-ws-"));
const observed = { spawnCwd: null, args: null, mergeCwd: null };
const fakeChild = new EventEmitter();
fakeChild.stdout = new EventEmitter();
fakeChild.stderr = new EventEmitter();
fakeChild.killed = false;
fakeChild.kill = () => { fakeChild.killed = true; };
await runCursorCliTurn({
prompt: "hi",
binPath: "/bin/agent",
cwd: "/",
chatSessionId: "chat-packaged",
getTempDir: () => tempRoot,
model: "auto",
env: {},
permissionMode: "confirm",
injectedMcpServers: [{
name: "netcatty-remote-hosts",
command: "node",
args: ["server.cjs"],
env: [{ name: "NETCATTY_MCP_PORT", value: "1" }],
}],
emitter,
spawnImpl: (_cmd, args, opts) => {
observed.spawnCwd = opts.cwd;
observed.args = args;
queueMicrotask(() => {
fakeChild.stdout.emit("data", `${JSON.stringify({
type: "assistant", timestamp_ms: 1, message: { content: [{ type: "text", text: "ok" }] },
})}\n`);
fakeChild.stdout.emit("data", `${JSON.stringify({
type: "result", subtype: "success", result: "ok",
})}\n`);
fakeChild.emit("close", 0);
});
return fakeChild;
},
mergeMcp: (mergeCwd) => {
observed.mergeCwd = mergeCwd;
return { restore() {} };
},
});
const expected = path.join(tempRoot, "cursor-cli-mcp", "chat-packaged");
assert.equal(observed.mergeCwd, expected);
assert.equal(observed.spawnCwd, expected);
assert.ok(observed.args.includes("--workspace"));
assert.equal(observed.args[observed.args.indexOf("--workspace") + 1], expected);
assert.ok(!emitter.calls.some((c) => c[0] === "error"));
fs.rmSync(tempRoot, { recursive: true, force: true });
});
test("runCursorCliTurn surfaces MCP merge failure instead of continuing without tools", async () => {
const emitter = makeEmitter();
let spawned = false;
await runCursorCliTurn({
prompt: "hi",
binPath: "/bin/agent",
cwd: "/",
chatSessionId: "chat-fail",
getTempDir: () => "/definitely-not-writable-root-only",
model: "auto",
env: {},
permissionMode: "confirm",
injectedMcpServers: [{
name: "netcatty-remote-hosts",
command: "node",
args: ["server.cjs"],
}],
emitter,
spawnImpl: () => {
spawned = true;
const fakeChild = new EventEmitter();
fakeChild.stdout = new EventEmitter();
fakeChild.stderr = new EventEmitter();
fakeChild.killed = false;
fakeChild.kill = () => {};
return fakeChild;
},
mergeMcp: () => {
const err = new Error("ENOENT: mkdir '/.cursor'");
err.code = "ENOENT";
throw err;
},
});
assert.equal(spawned, false);
assert.equal(emitter.calls.length, 1);
assert.equal(emitter.calls[0][0], "error");
assert.match(emitter.calls[0][1], /Failed to prepare Netcatty MCP for Cursor CLI/i);
});

View File

@@ -0,0 +1,559 @@
"use strict";
/**
* Cursor backend driver — wraps @cursor/sdk.
*
* Cursor SDK local agents use Agent.create({ apiKey, model, local:{cwd},
* mcpServers }) and stream SDKMessage events from run.stream().
*/
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
const DEFAULT_CURSOR_MODEL = "composer-2.5";
function toCursorMcpServers(injectedMcpServers) {
const servers = {};
for (const cfg of injectedMcpServers || []) {
if (!cfg || !cfg.name || !cfg.command) continue;
servers[cfg.name] = {
type: "stdio",
command: cfg.command,
args: cfg.args || [],
env: mcpEnvPairsToObject(cfg.env),
};
}
return servers;
}
const CURSOR_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh"]);
const CURSOR_FALLBACK_THINKING = {
"gpt-5.5": ["low", "medium", "high"],
"gpt-5.2": ["low", "medium", "high"],
"gpt-5.1": ["low", "medium", "high"],
"gpt-5": ["low", "medium", "high"],
"claude-opus-4.6": ["low", "medium", "high"],
"claude-sonnet-4.6": ["low", "medium", "high"],
};
function parseCursorModelSelection(model) {
const raw = String(model || DEFAULT_CURSOR_MODEL).trim() || DEFAULT_CURSOR_MODEL;
const queryIndex = raw.indexOf("?");
if (queryIndex >= 0) {
const id = raw.slice(0, queryIndex);
const search = new URLSearchParams(raw.slice(queryIndex + 1));
const params = [];
for (const [paramId, value] of search.entries()) {
if (paramId && value) params.push({ id: paramId, value });
}
return params.length > 0 ? { id, params } : { id };
}
const slash = raw.lastIndexOf("/");
if (slash > 0) {
const effort = raw.slice(slash + 1).toLowerCase();
if (CURSOR_REASONING_EFFORTS.has(effort)) {
return { id: raw.slice(0, slash), params: [{ id: "effort", value: effort }] };
}
}
return { id: raw };
}
function encodeCursorCliModel(model) {
const raw = String(model || "").trim();
if (!raw) return "";
const selection = parseCursorModelSelection(raw);
if (!selection.params?.length) return selection.id || "";
const search = new URLSearchParams();
for (const param of selection.params) {
if (param?.id && param?.value) search.set(param.id, param.value);
}
const qs = search.toString();
return qs ? `${selection.id}?${qs}` : (selection.id || "");
}
function buildCursorAgentOptions({ apiKey, env, model, cwd, injectedMcpServers }) {
const effectiveApiKey = apiKey || env?.CURSOR_API_KEY || process.env.CURSOR_API_KEY;
const options = {
apiKey: effectiveApiKey,
model: parseCursorModelSelection(model),
local: {
cwd: cwd || process.cwd(),
autoReview: false,
},
};
const mcpServers = toCursorMcpServers(injectedMcpServers);
if (Object.keys(mcpServers).length > 0) options.mcpServers = mcpServers;
return options;
}
function applyTemporaryProcessEnv(env) {
if (!env || typeof env !== "object") return () => {};
const previous = new Map();
for (const [key, value] of Object.entries(env)) {
if (typeof value !== "string") continue;
previous.set(key, Object.prototype.hasOwnProperty.call(process.env, key) ? process.env[key] : undefined);
process.env[key] = value;
}
return () => {
for (const [key, value] of previous.entries()) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
};
}
async function withTemporaryProcessEnv(env, fn) {
const restore = applyTemporaryProcessEnv(env);
try {
return await fn();
} finally {
restore();
}
}
function buildCursorSendMessage(prompt, attachments) {
const images = [];
for (const attachment of Array.isArray(attachments) ? attachments : []) {
if (!attachment?.base64Data || !attachment?.mediaType) continue;
if (!String(attachment.mediaType).toLowerCase().startsWith("image/")) continue;
images.push({ data: attachment.base64Data, mimeType: attachment.mediaType });
}
if (images.length === 0) return String(prompt || "");
return { text: String(prompt || ""), images };
}
function resultToText(result) {
if (result == null) return "";
if (typeof result === "string") return result;
if (typeof result === "number" || typeof result === "boolean") return String(result);
const content = result.content;
if (Array.isArray(content)) {
return content
.map((block) => {
if (!block) return "";
if (typeof block.text === "string") return block.text;
if (block.type === "image") return "[image]";
return JSON.stringify(block);
})
.join("");
}
return JSON.stringify(result);
}
function redactCursorSecret(value) {
return String(value || "")
.replace(/crsr[_-]?[A-Za-z0-9_-]{8,}/g, "[redacted-cursor-key]")
.replace(/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, "Bearer [redacted-token]");
}
function cursorErrorDiagnostics(error) {
if (!error || typeof error !== "object") {
return { message: redactCursorSecret(error) };
}
return {
name: error.name || null,
message: redactCursorSecret(error.message || String(error)),
code: error.code || null,
status: error.status || null,
operation: error.operation || null,
endpoint: error.endpoint || null,
requestId: error.requestId || null,
isRetryable: typeof error.isRetryable === "boolean" ? error.isRetryable : null,
cause: error.cause && typeof error.cause === "object"
? {
name: error.cause.name || null,
message: redactCursorSecret(error.cause.message || String(error.cause)),
}
: null,
};
}
function isCursorAuthMessage(message) {
return /api.?key|auth|unauthorized|unauthenticated/i.test(String(message || ""));
}
async function logCursorApiKeyValidation(resolvedModule, apiKey) {
if (!apiKey || typeof resolvedModule?.Cursor?.me !== "function") return;
try {
const user = await resolvedModule.Cursor.me({ apiKey });
console.info("[Cursor SDK] API key validation ok", {
hasUserId: user?.userId != null,
hasEmail: Boolean(user?.email),
createdAt: user?.createdAt || null,
});
} catch (error) {
console.warn("[Cursor SDK] API key validation failed", cursorErrorDiagnostics(error));
}
}
function closeReasoning(state, emitter) {
if (state?.reasoningOpen) {
emitter.reasoningEnd();
state.reasoningOpen = false;
}
}
function emitCursorToolCallOnce(event, emitter, state, toolName, args, id) {
if (!id) return false;
if (!state.emittedToolCalls) state.emittedToolCalls = new Set();
if (state.emittedToolCalls.has(id)) return false;
state.emittedToolCalls.add(id);
emitter.toolCall(toolName || "tool", args && typeof args === "object" ? args : {}, id);
return true;
}
function emitCursorToolResultOnce(event, emitter, state, id, result, toolName) {
if (!id) return false;
if (!state.emittedToolResults) state.emittedToolResults = new Set();
if (state.emittedToolResults.has(id)) return false;
state.emittedToolResults.add(id);
emitter.toolResult(id, resultToText(result), toolName);
return true;
}
function getCursorDisplayToolName(rawName, args) {
const name = String(rawName || "").trim();
const input = args && typeof args === "object" ? args : {};
const nestedToolName = typeof input.toolName === "string" ? input.toolName.trim() : "";
if ((name === "mcp" || name === "tool" || !name) && nestedToolName) {
return nestedToolName;
}
return name || nestedToolName || "tool";
}
function formatCursorErrorForUser(message) {
const text = String(message || "").trim();
if (/api.?key|auth|unauthorized/i.test(text)) {
return "Cursor authentication failed. Update the Cursor API Key in Settings -> AI.";
}
return text || "Cursor turn failed";
}
function isCursorAgentNotFoundError(error) {
const message = String(error?.message || error || "");
return /\bAgent\b.+\bnot found\b/i.test(message);
}
function translateCursorEvent(event, emitter, state = {}) {
if (!event || typeof event !== "object") return;
switch (event.type) {
case "thinking":
if (event.text) {
emitter.reasoning(String(event.text));
state.reasoningOpen = true;
}
return;
case "assistant": {
closeReasoning(state, emitter);
const content = event.message?.content;
if (!Array.isArray(content)) return;
for (const block of content) {
if (!block) continue;
if (block.type === "text" && block.text) {
emitter.text(String(block.text));
} else if (block.type === "tool_use") {
emitCursorToolCallOnce(
event,
emitter,
state,
getCursorDisplayToolName(block.name, block.input),
block.input,
block.id,
);
}
}
return;
}
case "tool_call": {
closeReasoning(state, emitter);
const id = event.call_id;
const name = getCursorDisplayToolName(event.name, event.args);
if (event.status === "running") {
emitCursorToolCallOnce(event, emitter, state, name, event.args, id);
} else if (event.status === "completed" || event.status === "error") {
emitCursorToolCallOnce(event, emitter, state, name, event.args, id);
emitCursorToolResultOnce(event, emitter, state, id, event.result || event.error || "", name);
}
return;
}
case "status":
if (event.status === "ERROR") {
closeReasoning(state, emitter);
state.failed = true;
state.errorMessage = String(event.message || "");
console.warn("[Cursor SDK] status error", {
message: redactCursorSecret(event.message || ""),
});
emitter.emitError(formatCursorErrorForUser(event.message));
return true;
}
return false;
default:
return false;
}
}
class CursorTurnAbortError extends Error {
constructor() {
super("Cursor turn aborted");
this.name = "CursorTurnAbortError";
}
}
function isCursorTurnAbortError(error) {
return error instanceof CursorTurnAbortError || error?.name === "CursorTurnAbortError";
}
async function abortable(promise, signal, onLateResolve) {
if (!signal) return promise;
if (signal.aborted) {
promise.then((value) => onLateResolve?.(value)).catch(() => {});
throw new CursorTurnAbortError();
}
let aborted = false;
let removeAbortListener = () => {};
const abortPromise = new Promise((_, reject) => {
const onAbort = () => {
aborted = true;
reject(new CursorTurnAbortError());
};
signal.addEventListener("abort", onAbort, { once: true });
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
});
try {
return await Promise.race([promise, abortPromise]);
} finally {
removeAbortListener();
if (aborted) {
promise.then((value) => onLateResolve?.(value)).catch(() => {});
}
}
}
async function runCursorTurn({
prompt, attachments, agentOptions, runtimeEnv, resumeSessionId, emitter, signal, sdkModule,
}) {
let resolvedModule = sdkModule;
if (!resolvedModule) {
try {
resolvedModule = await import("@cursor/sdk");
} catch {
emitter.emitError("Cursor SDK not installed. Run: npm install @cursor/sdk");
return { sessionId: resumeSessionId || null };
}
}
const { Agent } = resolvedModule;
let agent = null;
let run = null;
let sessionId = resumeSessionId || null;
try {
const restoreCreateEnv = applyTemporaryProcessEnv(runtimeEnv);
try {
const createAgent = () => Agent.create(agentOptions);
let agentPromise;
if (resumeSessionId && typeof Agent.resume === "function") {
agentPromise = Agent.resume(resumeSessionId, agentOptions).catch((error) => {
// Stale Cursor agent IDs (expired local store, or a CLI session UUID
// resumed on the SDK path) should start a fresh agent instead of
// failing the whole turn with "Agent … not found".
if (!isCursorAgentNotFoundError(error)) throw error;
console.warn("[Cursor SDK] resume missed; creating a new agent", {
resumeSessionId,
message: error?.message || String(error),
});
sessionId = null;
return createAgent();
});
} else {
agentPromise = createAgent();
}
agent = await abortable(agentPromise, signal, (lateAgent) => {
try { lateAgent?.close?.(); } catch { /* best effort */ }
});
} finally {
restoreCreateEnv();
}
sessionId = agent.agentId || sessionId;
if (sessionId) emitter.sessionId(sessionId);
if (signal?.aborted) return { sessionId };
const sendMessage = buildCursorSendMessage(prompt, attachments);
const restoreSendEnv = applyTemporaryProcessEnv(runtimeEnv);
try {
run = await abortable(agent.send(sendMessage), signal, (lateRun) => {
if (lateRun && typeof lateRun.cancel === "function") {
void lateRun.cancel().catch(() => {});
}
});
} finally {
restoreSendEnv();
}
const state = { reasoningOpen: false };
let hasContent = false;
let failed = false;
const onAbort = () => {
if (run && typeof run.cancel === "function") {
void run.cancel().catch(() => {});
}
};
if (signal) {
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
}
try {
for await (const event of run.stream()) {
if (signal?.aborted) break;
if (event?.type === "assistant" || event?.type === "tool_call") hasContent = true;
const streamFailed = translateCursorEvent(event, emitter, state);
if (streamFailed || state.failed) {
failed = true;
break;
}
}
} finally {
if (signal) signal.removeEventListener("abort", onAbort);
}
closeReasoning(state, emitter);
if (failed) {
if (isCursorAuthMessage(state.errorMessage)) {
await logCursorApiKeyValidation(resolvedModule, agentOptions?.apiKey);
}
return { sessionId };
}
if (!hasContent && !signal?.aborted) {
emitter.emitError("Cursor returned an empty response. Check the Cursor API Key in Settings -> AI.");
return { sessionId };
}
if (!signal?.aborted) emitter.emitDone();
return { sessionId };
} catch (error) {
if (isCursorTurnAbortError(error) || signal?.aborted) {
return { sessionId };
}
{
const message = error?.message || String(error);
console.warn("[Cursor SDK] run error", cursorErrorDiagnostics(error));
if (isCursorAuthMessage(message)) {
await logCursorApiKeyValidation(resolvedModule, agentOptions?.apiKey);
}
emitter.emitError(formatCursorErrorForUser(message));
}
return { sessionId };
} finally {
try { await agent?.close?.(); } catch { /* best effort */ }
}
}
function modelVariantId(modelId, params) {
const search = new URLSearchParams();
for (const param of params || []) {
if (param?.id && param?.value) search.set(param.id, param.value);
}
const qs = search.toString();
return qs ? `${modelId}?${qs}` : modelId;
}
function collectCursorEffortLevels(model) {
const levels = [];
const add = (raw) => {
const level = String(raw || "").toLowerCase();
if (CURSOR_REASONING_EFFORTS.has(level) && !levels.includes(level)) levels.push(level);
};
const effortParam = (model.parameters || []).find((param) => param?.id === "effort");
if (effortParam && Array.isArray(effortParam.values) && effortParam.values.length > 0) {
for (const item of effortParam.values) add(item?.value);
return levels;
}
for (const level of CURSOR_FALLBACK_THINKING[model.id] || []) add(level);
if (levels.length > 0) return levels;
for (const variant of model.variants || []) {
const params = Array.isArray(variant.params) ? variant.params : [];
const effortOnly = params.length === 1 && params[0]?.id === "effort" && params[0]?.value;
if (effortOnly) add(params[0].value);
}
return levels;
}
function mapCursorModels(models) {
const out = [];
if (!Array.isArray(models)) return out;
for (const model of models) {
if (!model?.id) continue;
const name = model.displayName || model.name || model.id;
const extraVariants = [];
for (const variant of model.variants || []) {
const params = Array.isArray(variant.params) ? variant.params : [];
const effortOnly = params.length === 1 && params[0]?.id === "effort" && params[0]?.value;
if (!effortOnly) extraVariants.push(variant);
}
const thinkingLevels = collectCursorEffortLevels(model);
out.push({
id: model.id,
name,
...(model.description ? { description: model.description } : {}),
...(thinkingLevels.length > 0 ? {
thinkingLevels,
defaultThinkingLevel: thinkingLevels.includes("medium") ? "medium" : thinkingLevels[0],
} : {}),
});
for (const variant of extraVariants) {
const id = modelVariantId(model.id, variant.params || []);
if (id === model.id) continue;
out.push({
id,
name: `${name} - ${variant.displayName || id}`,
...(variant.description ? { description: variant.description } : {}),
});
}
}
return out;
}
async function listCursorModels({ apiKey, env, sdkModule, abortController, signal } = {}) {
const externalSignal = signal || abortController?.signal;
if (externalSignal?.aborted) return [];
let resolvedModule = sdkModule;
if (!resolvedModule) {
try { resolvedModule = await import("@cursor/sdk"); } catch { return []; }
}
const effectiveApiKey = apiKey || env?.CURSOR_API_KEY || process.env.CURSOR_API_KEY;
if (!effectiveApiKey) return [];
let abortHandler;
try {
const result = await Promise.race([
Promise.resolve(resolvedModule.Cursor.models.list({
apiKey: effectiveApiKey,
signal: externalSignal,
})).then((models) => ({ type: "models", models })),
new Promise((resolve) => {
if (externalSignal?.aborted) return resolve({ type: "aborted" });
abortHandler = () => resolve({ type: "aborted" });
externalSignal?.addEventListener("abort", abortHandler, { once: true });
}),
]);
return result.type === "models" ? mapCursorModels(result.models) : [];
} finally {
if (abortHandler) externalSignal?.removeEventListener("abort", abortHandler);
}
}
module.exports = {
DEFAULT_CURSOR_MODEL,
abortable,
applyTemporaryProcessEnv,
buildCursorAgentOptions,
buildCursorSendMessage,
formatCursorErrorForUser,
isCursorAgentNotFoundError,
listCursorModels,
mapCursorModels,
parseCursorModelSelection,
encodeCursorCliModel,
runCursorTurn,
toCursorMcpServers,
translateCursorEvent,
withTemporaryProcessEnv,
};

View File

@@ -0,0 +1,548 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildCursorAgentOptions,
buildCursorSendMessage,
formatCursorErrorForUser,
isCursorAgentNotFoundError,
mapCursorModels,
runCursorTurn,
toCursorMcpServers,
translateCursorEvent,
withTemporaryProcessEnv,
} = require("./cursorDriver.cjs");
function makeEmitter() {
const calls = [];
return {
calls,
text: (value) => calls.push(["text", value]),
reasoning: (value) => calls.push(["reasoning", value]),
reasoningEnd: () => calls.push(["reasoningEnd"]),
toolCall: (name, args, id) => calls.push(["toolCall", name, args, id]),
toolResult: (id, result, name) => calls.push(["toolResult", id, result, name]),
sessionId: (id) => calls.push(["sessionId", id]),
emitDone: () => calls.push(["done"]),
emitError: (message) => calls.push(["error", message]),
};
}
test("buildCursorAgentOptions uses api key, model, cwd, and injected MCP servers", () => {
const options = buildCursorAgentOptions({
apiKey: "cur-key",
model: "composer-2",
cwd: "/repo",
injectedMcpServers: [
{
name: "netcatty",
command: "node",
args: ["server.cjs"],
env: [{ name: "TOKEN", value: "abc" }],
},
],
});
assert.deepEqual(options, {
apiKey: "cur-key",
model: { id: "composer-2" },
local: { cwd: "/repo", autoReview: false },
mcpServers: {
netcatty: {
type: "stdio",
command: "node",
args: ["server.cjs"],
env: { TOKEN: "abc" },
},
},
});
});
test("buildCursorAgentOptions falls back to CURSOR_API_KEY and composer-2.5", () => {
const options = buildCursorAgentOptions({
env: { CURSOR_API_KEY: "env-key" },
cwd: "/repo",
});
assert.equal(options.apiKey, "env-key");
assert.deepEqual(options.model, { id: "composer-2.5" });
});
test("toCursorMcpServers drops invalid server configs", () => {
assert.deepEqual(
toCursorMcpServers([
null,
{ name: "", command: "node" },
{ name: "ok", command: "node", args: [] },
]),
{ ok: { type: "stdio", command: "node", args: [], env: {} } },
);
});
test("withTemporaryProcessEnv restores env after async work", async () => {
const original = process.env.NETCATTY_CURSOR_TEST_ENV;
delete process.env.NETCATTY_CURSOR_TEST_ENV;
const value = await withTemporaryProcessEnv(
{ NETCATTY_CURSOR_TEST_ENV: "present" },
async () => process.env.NETCATTY_CURSOR_TEST_ENV,
);
assert.equal(value, "present");
assert.equal(process.env.NETCATTY_CURSOR_TEST_ENV, undefined);
if (original !== undefined) process.env.NETCATTY_CURSOR_TEST_ENV = original;
});
test("runCursorTurn exposes runtime env while creating and sending", async () => {
const emitter = makeEmitter();
const observed = [];
const sdkModule = {
Agent: {
async create() {
observed.push(["create", process.env.NETCATTY_TOOL_CLI_DISCOVERY_FILE]);
return {
agentId: "agent-env",
async send() {
observed.push(["send", process.env.NETCATTY_TOOL_CLI_DISCOVERY_FILE]);
return {
async *stream() {
yield { type: "assistant", message: { content: [{ type: "text", text: "ok" }] } };
},
};
},
close() {},
};
},
},
};
await runCursorTurn({
prompt: "hi",
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
runtimeEnv: { NETCATTY_TOOL_CLI_DISCOVERY_FILE: "/tmp/discovery.json" },
emitter,
sdkModule,
});
assert.deepEqual(observed, [
["create", "/tmp/discovery.json"],
["send", "/tmp/discovery.json"],
]);
});
test("translateCursorEvent maps assistant, thinking, and tool events", () => {
const emitter = makeEmitter();
const state = {};
translateCursorEvent({ type: "thinking", text: "checking" }, emitter, state);
translateCursorEvent({
type: "assistant",
message: {
content: [
{ type: "text", text: "hello" },
{ type: "tool_use", id: "tool-1", name: "read_file", input: { path: "README.md" } },
],
},
}, emitter, state);
translateCursorEvent({
type: "tool_call",
call_id: "tool-1",
name: "read_file",
status: "completed",
result: { content: [{ type: "text", text: "contents" }] },
}, emitter, state);
assert.deepEqual(emitter.calls, [
["reasoning", "checking"],
["reasoningEnd"],
["text", "hello"],
["toolCall", "read_file", { path: "README.md" }, "tool-1"],
["toolResult", "tool-1", "contents", "read_file"],
]);
});
test("translateCursorEvent uses nested Cursor MCP toolName for display", () => {
const emitter = makeEmitter();
const state = {};
const args = {
providerIdentifier: "netcatty-remote-hosts",
toolName: "terminal_execute",
args: { command: "uname -a" },
};
translateCursorEvent({
type: "tool_call",
call_id: "mcp-1",
name: "mcp",
status: "completed",
args,
result: { content: [{ type: "text", text: "Linux" }] },
}, emitter, state);
assert.deepEqual(emitter.calls, [
["toolCall", "terminal_execute", args, "mcp-1"],
["toolResult", "mcp-1", "Linux", "terminal_execute"],
]);
});
test("translateCursorEvent marks error status as failed", () => {
const emitter = makeEmitter();
const state = {};
const failed = translateCursorEvent({ type: "status", status: "ERROR", message: "bad key" }, emitter, state);
assert.equal(failed, true);
assert.equal(state.failed, true);
assert.deepEqual(emitter.calls, [["error", "bad key"]]);
});
test("translateCursorEvent rewrites Cursor authentication errors", () => {
const emitter = makeEmitter();
const state = {};
const failed = translateCursorEvent({ type: "status", status: "ERROR", message: "bad API key" }, emitter, state);
assert.equal(failed, true);
assert.equal(state.failed, true);
assert.deepEqual(emitter.calls, [[
"error",
"Cursor authentication failed. Update the Cursor API Key in Settings -> AI.",
]]);
});
test("formatCursorErrorForUser points users to the settings API key", () => {
assert.equal(
formatCursorErrorForUser("unauthorized"),
"Cursor authentication failed. Update the Cursor API Key in Settings -> AI.",
);
});
test("isCursorAgentNotFoundError detects stale resume ids", () => {
assert.equal(isCursorAgentNotFoundError(new Error("Agent 61668441-bfcb-4795-a575-c46d70ad01fe not found")), true);
assert.equal(isCursorAgentNotFoundError(new Error("unauthorized")), false);
});
test("runCursorTurn falls back to create when resume agent is missing", async () => {
const emitter = makeEmitter();
const observed = [];
const sdkModule = {
Agent: {
async resume(id) {
observed.push(["resume", id]);
throw new Error(`Agent ${id} not found`);
},
async create() {
observed.push(["create"]);
return {
agentId: "agent-fresh",
async send() {
return {
async *stream() {
yield { type: "assistant", message: { content: [{ type: "text", text: "ok" }] } };
},
};
},
close() {},
};
},
},
};
const result = await runCursorTurn({
prompt: "hi",
resumeSessionId: "61668441-bfcb-4795-a575-c46d70ad01fe",
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
emitter,
sdkModule,
});
assert.deepEqual(observed, [
["resume", "61668441-bfcb-4795-a575-c46d70ad01fe"],
["create"],
]);
assert.equal(result.sessionId, "agent-fresh");
assert.deepEqual(emitter.calls, [
["sessionId", "agent-fresh"],
["text", "ok"],
["done"],
]);
});
test("runCursorTurn creates or resumes an agent, streams events, and emits done", async () => {
const emitter = makeEmitter();
const captured = {};
const sdkModule = {
Agent: {
async create(options) {
captured.createOptions = options;
return {
agentId: "agent-new",
async send(message) {
captured.message = message;
return {
id: "run-1",
agentId: "agent-new",
async *stream() {
yield { type: "assistant", message: { content: [{ type: "text", text: "done" }] } };
},
};
},
async close() {
captured.closed = true;
},
};
},
},
};
const result = await runCursorTurn({
prompt: "hi",
attachments: [{ mediaType: "image/png", base64Data: "abc", filename: "a.png" }],
agentOptions: { apiKey: "key", model: { id: "composer-2" }, local: { cwd: "/repo" } },
emitter,
sdkModule,
});
assert.equal(result.sessionId, "agent-new");
assert.deepEqual(captured.message, {
text: "hi",
images: [{ data: "abc", mimeType: "image/png" }],
});
assert.deepEqual(emitter.calls, [
["sessionId", "agent-new"],
["text", "done"],
["done"],
]);
assert.equal(captured.closed, true);
});
test("runCursorTurn does not emit done after a Cursor error status", async () => {
const emitter = makeEmitter();
const sdkModule = {
Agent: {
async create() {
return {
agentId: "agent-error",
async send() {
return {
async *stream() {
yield { type: "status", status: "ERROR", message: "bad key" };
yield { type: "assistant", message: { content: [{ type: "text", text: "late" }] } };
},
};
},
close() {},
};
},
},
};
const result = await runCursorTurn({
prompt: "hi",
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
emitter,
sdkModule,
});
assert.equal(result.sessionId, "agent-error");
assert.deepEqual(emitter.calls, [
["sessionId", "agent-error"],
["error", "bad key"],
]);
});
test("runCursorTurn returns when aborted while creating an agent", async () => {
const emitter = makeEmitter();
let resolveCreate;
const createPromise = new Promise((resolve) => {
resolveCreate = resolve;
});
const sdkModule = {
Agent: {
create() {
return createPromise;
},
},
};
const controller = new AbortController();
const turnPromise = runCursorTurn({
prompt: "hi",
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
emitter,
signal: controller.signal,
sdkModule,
});
controller.abort();
const result = await turnPromise;
assert.deepEqual(result, { sessionId: null });
assert.deepEqual(emitter.calls, []);
let closed = false;
resolveCreate({ agentId: "late", close: () => { closed = true; } });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(closed, true);
});
test("runCursorTurn restores runtime env when aborted while creating an agent", async () => {
const emitter = makeEmitter();
const original = process.env.NETCATTY_CURSOR_ABORT_ENV;
delete process.env.NETCATTY_CURSOR_ABORT_ENV;
const sdkModule = {
Agent: {
create() {
return new Promise(() => {});
},
},
};
const controller = new AbortController();
const turnPromise = runCursorTurn({
prompt: "hi",
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
runtimeEnv: { NETCATTY_CURSOR_ABORT_ENV: "present" },
emitter,
signal: controller.signal,
sdkModule,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(process.env.NETCATTY_CURSOR_ABORT_ENV, "present");
controller.abort();
await turnPromise;
assert.equal(process.env.NETCATTY_CURSOR_ABORT_ENV, undefined);
if (original !== undefined) process.env.NETCATTY_CURSOR_ABORT_ENV = original;
});
test("runCursorTurn cancels a late Cursor run when aborted while sending", async () => {
const emitter = makeEmitter();
let resolveSend;
let cancelled = false;
const sendPromise = new Promise((resolve) => {
resolveSend = resolve;
});
const sdkModule = {
Agent: {
async create() {
return {
agentId: "agent-send-abort",
send() {
return sendPromise;
},
close() {},
};
},
},
};
const controller = new AbortController();
const turnPromise = runCursorTurn({
prompt: "hi",
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
emitter,
signal: controller.signal,
sdkModule,
});
await new Promise((resolve) => setTimeout(resolve, 0));
controller.abort();
const result = await turnPromise;
assert.deepEqual(result, { sessionId: "agent-send-abort" });
assert.deepEqual(emitter.calls, [["sessionId", "agent-send-abort"]]);
resolveSend({ cancel: async () => { cancelled = true; }, stream: async function* stream() {} });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(cancelled, true);
});
test("mapCursorModels prefers advertised effort parameter values over fallbacks", () => {
assert.deepEqual(
mapCursorModels([
{
id: "custom-reasoner",
displayName: "Custom Reasoner",
parameters: [
{ id: "effort", values: [{ value: "low" }, { value: "xhigh" }] },
],
},
{
id: "gpt-5",
displayName: "GPT-5",
parameters: [
{ id: "effort", values: [{ value: "low" }, { value: "high" }] },
],
},
]),
[
{
id: "custom-reasoner",
name: "Custom Reasoner",
thinkingLevels: ["low", "xhigh"],
defaultThinkingLevel: "low",
},
{
id: "gpt-5",
name: "GPT-5",
thinkingLevels: ["low", "high"],
defaultThinkingLevel: "low",
},
],
);
});
test("mapCursorModels maps display names and effort variants into thinkingLevels", () => {
assert.deepEqual(
mapCursorModels([
{ id: "composer-2.5", displayName: "Composer 2.5", description: "Default" },
{ id: "gpt-5", displayName: "GPT-5", variants: [{ displayName: "Fast", params: [{ id: "effort", value: "low" }] }] },
]),
[
{ id: "composer-2.5", name: "Composer 2.5", description: "Default" },
{
id: "gpt-5",
name: "GPT-5",
thinkingLevels: ["low", "medium", "high"],
defaultThinkingLevel: "medium",
},
],
);
});
test("mapCursorModels keeps extra-param variants as separate models", () => {
const mapped = mapCursorModels([
{
id: "gpt-5",
displayName: "GPT-5",
variants: [
{ displayName: "Fast", params: [{ id: "effort", value: "low" }] },
{
displayName: "Fast custom",
params: [{ id: "effort", value: "low" }, { id: "mode", value: "fast" }],
},
],
},
]);
assert.deepEqual(mapped, [
{
id: "gpt-5",
name: "GPT-5",
thinkingLevels: ["low", "medium", "high"],
defaultThinkingLevel: "medium",
},
{
id: "gpt-5?effort=low&mode=fast",
name: "GPT-5 - Fast custom",
},
]);
});
test("parseCursorModelSelection accepts query and slash effort encodings", () => {
const { parseCursorModelSelection, encodeCursorCliModel } = require("./cursorDriver.cjs");
assert.deepEqual(parseCursorModelSelection("gpt-5/high"), {
id: "gpt-5",
params: [{ id: "effort", value: "high" }],
});
assert.deepEqual(parseCursorModelSelection("gpt-5?effort=low"), {
id: "gpt-5",
params: [{ id: "effort", value: "low" }],
});
assert.equal(encodeCursorCliModel("gpt-5/high"), "gpt-5?effort=high");
});

View File

@@ -0,0 +1,73 @@
"use strict";
/**
* Stream emitter: forwards translated SDK events to the renderer over the
* SDK agent IPC channels consumed by sdkAgentAdapter.ts.
*
* Canonical event shapes consumed by sdkAgentAdapter.handleStreamEvent:
* { type: 'text-delta', textDelta }
* { type: 'reasoning-delta', delta }
* { type: 'reasoning-end' }
* { type: 'tool-call', toolName, args, toolCallId }
* { type: 'tool-result', toolCallId, output, toolName }
* { type: 'file-change', itemId, changes, status }
* { type: 'web-search', itemId, query, status }
* { type: 'plan-update', itemId, items, status }
* { type: 'warning', itemId, message }
* { type: 'usage', inputTokens, cachedInputTokens, outputTokens, reasoningTokens, totalTokens }
* { type: 'status', message }
* { type: 'session-id', sessionId }
* { type: 'error', error }
*/
function createStreamEmitter({ safeSend, sender, requestId }) {
const emitEvent = (event) => {
safeSend(sender, "netcatty:ai:sdk-agent:event", { requestId, event });
};
return {
emitEvent,
emitDone() {
safeSend(sender, "netcatty:ai:sdk-agent:done", { requestId });
},
emitError(error) {
safeSend(sender, "netcatty:ai:sdk-agent:error", { requestId, error });
},
text(textDelta) {
if (textDelta) emitEvent({ type: "text-delta", textDelta });
},
reasoning(delta) {
if (delta) emitEvent({ type: "reasoning-delta", delta });
},
reasoningEnd() {
emitEvent({ type: "reasoning-end" });
},
toolCall(toolName, args, toolCallId) {
emitEvent({ type: "tool-call", toolName: toolName || "unknown", args: args || {}, toolCallId });
},
toolResult(toolCallId, output, toolName) {
emitEvent({ type: "tool-result", toolCallId: toolCallId || "", output, toolName });
},
fileChange(itemId, changes, status) {
emitEvent({ type: "file-change", itemId: itemId || "", changes: changes || [], status });
},
webSearch(itemId, query, status) {
emitEvent({ type: "web-search", itemId: itemId || "", query: query || "", status });
},
planUpdate(itemId, items, status) {
emitEvent({ type: "plan-update", itemId: itemId || "", items: items || [], status });
},
warning(itemId, message) {
if (message) emitEvent({ type: "warning", itemId: itemId || "", message });
},
usage(usage) {
if (usage) emitEvent({ type: "usage", ...usage });
},
status(message) {
if (message) emitEvent({ type: "status", message });
},
sessionId(sessionId) {
if (sessionId) emitEvent({ type: "session-id", sessionId });
},
};
}
module.exports = { createStreamEmitter };

View File

@@ -0,0 +1,60 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { createStreamEmitter } = require("./emit.cjs");
function recordingSend() {
const calls = [];
const safeSend = (sender, channel, payload) => calls.push({ channel, payload });
return { calls, safeSend };
}
test("emitEvent sends on netcatty:ai:sdk-agent:event with requestId+event", () => {
const { calls, safeSend } = recordingSend();
const e = createStreamEmitter({ safeSend, sender: {}, requestId: "req-1" });
e.emitEvent({ type: "text-delta", textDelta: "hi" });
assert.deepEqual(calls[0], {
channel: "netcatty:ai:sdk-agent:event",
payload: { requestId: "req-1", event: { type: "text-delta", textDelta: "hi" } },
});
});
test("emitDone sends on netcatty:ai:sdk-agent:done", () => {
const { calls, safeSend } = recordingSend();
const e = createStreamEmitter({ safeSend, sender: {}, requestId: "req-2" });
e.emitDone();
assert.deepEqual(calls[0], { channel: "netcatty:ai:sdk-agent:done", payload: { requestId: "req-2" } });
});
test("emitError sends on netcatty:ai:sdk-agent:error with message", () => {
const { calls, safeSend } = recordingSend();
const e = createStreamEmitter({ safeSend, sender: {}, requestId: "req-3" });
e.emitError("boom");
assert.deepEqual(calls[0], { channel: "netcatty:ai:sdk-agent:error", payload: { requestId: "req-3", error: "boom" } });
});
test("convenience helpers emit the canonical event shapes", () => {
const { calls, safeSend } = recordingSend();
const e = createStreamEmitter({ safeSend, sender: {}, requestId: "r" });
e.text("abc");
e.toolCall("terminal_execute", { command: "ls" }, "tc-1");
e.toolResult("tc-1", "out", "terminal_execute");
e.fileChange("patch-1", [{ path: "src/app.ts", kind: "update" }], "completed");
e.webSearch("search-1", "Codex events", "running");
e.planUpdate("plan-1", [{ text: "Map events", completed: false }], "running");
e.warning("warning-1", "Search unavailable");
e.usage({ inputTokens: 10, outputTokens: 5, totalTokens: 15 });
e.status("Working...");
e.sessionId("sess-9");
assert.deepEqual(calls.map((c) => c.payload.event.type),
[
"text-delta", "tool-call", "tool-result", "file-change", "web-search",
"plan-update", "warning", "usage", "status", "session-id",
]);
assert.equal(calls[1].payload.event.toolName, "terminal_execute");
assert.equal(calls[1].payload.event.toolCallId, "tc-1");
assert.deepEqual(calls[1].payload.event.args, { command: "ls" });
assert.equal(calls[2].payload.event.output, "out");
assert.equal(calls[3].payload.event.itemId, "patch-1");
assert.equal(calls[7].payload.event.totalTokens, 15);
assert.equal(calls[9].payload.event.sessionId, "sess-9");
});

View File

@@ -0,0 +1,73 @@
"use strict";
/**
* Env construction for SDK agent subprocesses.
*
* Consolidates the env hardening that previously lived in
* the removed raw-process handler (DANGEROUS_ENV_KEYS) and the per-spawn merge
* helpers used by SDK agent launches.
* Callers inject the netcatty helpers so this module stays pure/testable.
*/
// Env var names that can be used for code injection into a child process.
// Mirror of the set in the (now-removed) raw agent spawn handler.
const DANGEROUS_ENV_KEYS = new Set([
"LD_PRELOAD", "LD_LIBRARY_PATH",
"DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH",
"NODE_OPTIONS", "ELECTRON_RUN_AS_NODE",
"PYTHONPATH", "RUBYLIB", "PERL5LIB",
"BASH_ENV", "ENV", "CDPATH", "PROMPT_COMMAND",
]);
function isDangerousEnvKey(key) {
const normalized = String(key || "").toUpperCase();
return DANGEROUS_ENV_KEYS.has(normalized) || normalized.startsWith("BASH_FUNC_");
}
/**
* Build the env handed to an SDK agent subprocess.
*
* @param {object} args
* @param {Record<string,string>} args.shellEnv Resolved shell env (PATH-augmented).
* @param {Record<string,string>} [args.requestedAgentEnv] Per-agent env from the UI (filtered).
* @param {(e:Record<string,string>)=>Record<string,string>} [args.withCliDiscoveryEnv]
* netcatty helper that injects the tool-CLI discovery file path.
* @param {(e:Record<string,string>)=>Record<string,string>} [args.normalizeClaudeCodeExecutableEnv]
* netcatty helper that rewrites CLAUDE_CODE_EXECUTABLE to a runnable path (claude only).
* @returns {Record<string,string>}
*/
function buildSdkAgentEnv({
shellEnv,
requestedAgentEnv,
withCliDiscoveryEnv,
normalizeClaudeCodeExecutableEnv,
}) {
const filteredShellEnv = {};
if (shellEnv && typeof shellEnv === "object") {
for (const [k, v] of Object.entries(shellEnv)) {
if (typeof v === "string" && !isDangerousEnvKey(k)) {
filteredShellEnv[k] = v;
}
}
}
const filteredRequested = {};
if (requestedAgentEnv && typeof requestedAgentEnv === "object") {
for (const [k, v] of Object.entries(requestedAgentEnv)) {
if (typeof v === "string" && !isDangerousEnvKey(k)) {
filteredRequested[k] = v;
}
}
}
let env = { ...filteredShellEnv, ...filteredRequested };
if (typeof withCliDiscoveryEnv === "function") {
env = withCliDiscoveryEnv(env);
}
if (typeof normalizeClaudeCodeExecutableEnv === "function") {
env = normalizeClaudeCodeExecutableEnv(env);
}
return env;
}
module.exports = { buildSdkAgentEnv, DANGEROUS_ENV_KEYS, isDangerousEnvKey };

View File

@@ -0,0 +1,62 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { buildSdkAgentEnv, DANGEROUS_ENV_KEYS, isDangerousEnvKey } = require("./env.cjs");
test("merges shellEnv + requestedAgentEnv (requested wins)", () => {
const env = buildSdkAgentEnv({
shellEnv: { PATH: "/usr/bin", FOO: "shell" },
requestedAgentEnv: { FOO: "req", BAR: "req" },
});
assert.equal(env.PATH, "/usr/bin");
assert.equal(env.FOO, "req");
assert.equal(env.BAR, "req");
});
test("filters dangerous env keys from requestedAgentEnv", () => {
const env = buildSdkAgentEnv({
shellEnv: { PATH: "/usr/bin" },
requestedAgentEnv: { LD_PRELOAD: "/evil.so", NODE_OPTIONS: "--x", BASH_FUNC_foo: "y", SAFE: "ok" },
});
assert.equal(env.LD_PRELOAD, undefined);
assert.equal(env.NODE_OPTIONS, undefined);
assert.equal(env.BASH_FUNC_foo, undefined);
assert.equal(env.SAFE, "ok");
});
test("filters dangerous env keys from shellEnv", () => {
const env = buildSdkAgentEnv({
shellEnv: { PATH: "/usr/bin", NODE_OPTIONS: "--require /evil.js", BASH_FUNC_x: "() { :; }", SAFE: "ok" },
requestedAgentEnv: {},
});
assert.equal(env.PATH, "/usr/bin");
assert.equal(env.NODE_OPTIONS, undefined);
assert.equal(env.BASH_FUNC_x, undefined);
assert.equal(env.SAFE, "ok");
});
test("isDangerousEnvKey flags blocklist and BASH_FUNC_ prefix", () => {
assert.equal(isDangerousEnvKey("DYLD_INSERT_LIBRARIES"), true);
assert.equal(isDangerousEnvKey("dyld_insert_libraries"), true);
assert.equal(isDangerousEnvKey("node_options"), true);
assert.equal(isDangerousEnvKey("BASH_FUNC_x%%"), true);
assert.equal(isDangerousEnvKey("bash_func_x%%"), true);
assert.equal(isDangerousEnvKey("PATH"), false);
});
test("applies withCliDiscoveryEnv hook", () => {
const env = buildSdkAgentEnv({
shellEnv: { PATH: "/usr/bin" },
requestedAgentEnv: {},
withCliDiscoveryEnv: (e) => ({ ...e, NETCATTY_TOOL_CLI_DISCOVERY: "/tmp/x.json" }),
});
assert.equal(env.NETCATTY_TOOL_CLI_DISCOVERY, "/tmp/x.json");
});
test("normalizes CLAUDE_CODE_EXECUTABLE via injected normalizer", () => {
const env = buildSdkAgentEnv({
shellEnv: { PATH: "/usr/bin" },
requestedAgentEnv: { CLAUDE_CODE_EXECUTABLE: "/old/claude" },
normalizeClaudeCodeExecutableEnv: (e) => ({ ...e, CLAUDE_CODE_EXECUTABLE: "/new/claude" }),
});
assert.equal(env.CLAUDE_CODE_EXECUTABLE, "/new/claude");
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,766 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const {
GROK_MCP_MODE_DISALLOWED_LOCAL_TOOLS,
buildGrokCliArgs,
buildGrokMcpServerTomlSection,
createLineBuffer,
formatGrokErrorForUser,
listGrokModels,
mergeWorkspaceGrokMcpToml,
parseGrokModelsOutput,
resetGrokMcpMergeRefcountsForTests,
resolveGrokPermissionFlags,
resolveGrokSpawnSpec,
resolveGrokToolIntegrationFlags,
resolveGrokTurnPrompt,
extractGrokAcpPromptUsage,
emitGrokUsage,
normalizeGrokPlanUpdate,
parseGrokModelSelection,
shouldReportGrokProcessExitFailure,
runGrokTurn,
spawnGrokProcess,
stripGrokMcpServerSection,
translateGrokStreamEvent,
} = require("./grokDriver.cjs");
function makeEmitter() {
const calls = [];
return {
calls,
text: (value) => calls.push(["text", value]),
reasoning: (value) => calls.push(["reasoning", value]),
reasoningEnd: () => calls.push(["reasoningEnd"]),
toolCall: (name, args, id) => calls.push(["toolCall", name, args, id]),
toolResult: (id, result, name) => calls.push(["toolResult", id, result, name]),
sessionId: (id) => calls.push(["sessionId", id]),
planUpdate: (itemId, items, status) => calls.push(["planUpdate", itemId, items, status]),
usage: (usage) => calls.push(["usage", usage]),
emitDone: () => calls.push(["done"]),
emitError: (message) => calls.push(["error", message]),
};
}
test("resolveGrokPermissionFlags maps observer to plan and others to always-approve", () => {
assert.deepEqual(resolveGrokPermissionFlags("observer"), ["--permission-mode", "plan"]);
assert.deepEqual(resolveGrokPermissionFlags("confirm"), ["--always-approve"]);
assert.deepEqual(resolveGrokPermissionFlags("auto"), ["--always-approve"]);
});
test("buildGrokCliArgs uses streaming-json and optional model/resume/cwd", () => {
assert.deepEqual(
buildGrokCliArgs({
prompt: "hi",
model: "grok-4.5",
cwd: "/repo",
resumeSessionId: "sess-1",
permissionMode: "observer",
toolIntegrationMode: "skills",
}),
[
"--no-auto-update",
"-p",
"hi",
"--output-format",
"streaming-json",
"-m",
"grok-4.5",
"--cwd",
"/repo",
"-r",
"sess-1",
"--permission-mode",
"plan",
],
);
const autoArgs = buildGrokCliArgs({
prompt: "go",
permissionMode: "auto",
toolIntegrationMode: "skills",
});
assert.ok(autoArgs.includes("--always-approve"));
assert.ok(autoArgs.includes("--no-auto-update"));
assert.ok(!autoArgs.includes("-m"));
});
test("buildGrokCliArgs passes a selected reasoning effort separately from the model", () => {
assert.deepEqual(parseGrokModelSelection("grok-4.6/xhigh"), {
model: "grok-4.6",
effort: "xhigh",
});
assert.deepEqual(parseGrokModelSelection("provider/model"), {
model: "provider/model",
effort: undefined,
});
const args = buildGrokCliArgs({
prompt: "hi",
model: "grok-4.6/xhigh",
permissionMode: "auto",
toolIntegrationMode: "skills",
});
const modelIdx = args.indexOf("-m");
const effortIdx = args.indexOf("--reasoning-effort");
assert.equal(args[modelIdx + 1], "grok-4.6");
assert.equal(args[effortIdx + 1], "xhigh");
});
test("resolveGrokToolIntegrationFlags locks local side-effect tools only in MCP mode", () => {
assert.deepEqual(resolveGrokToolIntegrationFlags("skills"), []);
assert.deepEqual(resolveGrokToolIntegrationFlags("mcp"), [
"--disallowed-tools",
GROK_MCP_MODE_DISALLOWED_LOCAL_TOOLS.join(","),
]);
// Default/unknown → MCP lockdown (align with Claude MCP-mode empty local tools).
assert.deepEqual(resolveGrokToolIntegrationFlags(undefined), [
"--disallowed-tools",
GROK_MCP_MODE_DISALLOWED_LOCAL_TOOLS.join(","),
]);
assert.ok(GROK_MCP_MODE_DISALLOWED_LOCAL_TOOLS.includes("run_terminal_command"));
assert.ok(GROK_MCP_MODE_DISALLOWED_LOCAL_TOOLS.includes("search_replace"));
assert.ok(GROK_MCP_MODE_DISALLOWED_LOCAL_TOOLS.includes("write"));
});
test("buildGrokCliArgs applies MCP-mode local-tool lockdown via real builder", () => {
const mcpArgs = buildGrokCliArgs({
prompt: "list sessions",
permissionMode: "auto",
toolIntegrationMode: "mcp",
});
const denyIdx = mcpArgs.indexOf("--disallowed-tools");
assert.ok(denyIdx >= 0, "MCP mode must pass --disallowed-tools");
const denied = String(mcpArgs[denyIdx + 1] || "");
assert.match(denied, /run_terminal_command/);
assert.match(denied, /search_replace/);
assert.match(denied, /write/);
// MCP meta-tools must not appear in the deny list (Netcatty remote path).
assert.doesNotMatch(denied, /mcp|netcatty/i);
const skillsArgs = buildGrokCliArgs({
prompt: "list sessions",
permissionMode: "auto",
toolIntegrationMode: "skills",
});
assert.ok(!skillsArgs.includes("--disallowed-tools"), "skills mode must not apply MCP lockdown");
});
test("createLineBuffer rejects and releases an unterminated oversized message", () => {
const lines = [];
const lineBuffer = createLineBuffer((line) => lines.push(line), 8);
lineBuffer.push(Buffer.from("12345678"));
assert.throws(
() => lineBuffer.push(Buffer.from("9")),
(error) => error?.code === "GROK_LINE_LIMIT",
);
lineBuffer.flush();
assert.deepEqual(lines, []);
});
test("formatGrokErrorForUser maps auth failures without over-matching bare login strings", () => {
assert.match(
formatGrokErrorForUser("Not authenticated"),
/not logged in/i,
);
assert.equal(
formatGrokErrorForUser("Failed to run login form validation"),
"Failed to run login form validation",
);
});
test("resolveGrokSpawnSpec matches prepareCommandForSpawn for cmd shims and exes", () => {
const { prepareCommandForSpawn } = require("../../ai/shellUtils.cjs");
// On win32, .cmd needs shell (or native-exe rewrite). Elsewhere shell stays false.
const shim = "C:\\Users\\me\\AppData\\Roaming\\npm\\grok.cmd";
const expected = prepareCommandForSpawn(shim, ["agent", "stdio"]);
const actual = resolveGrokSpawnSpec(shim, ["agent", "stdio"]);
assert.deepEqual(actual, expected);
if (process.platform === "win32") {
assert.equal(actual.shell, true);
assert.equal(actual.args.length, 0);
} else {
assert.equal(actual.shell, false);
}
const exePath = process.platform === "win32" ? "C:\\Tools\\grok.exe" : "/usr/bin/grok";
const exe = resolveGrokSpawnSpec(exePath, ["-p", "hi"]);
assert.equal(exe.shell, false);
assert.equal(exe.command, exePath);
assert.deepEqual(exe.args, ["-p", "hi"]);
});
test("spawnGrokProcess forwards shell from prepareCommandForSpawn into spawnImpl", () => {
const calls = [];
const fakeChild = {
stdout: { on() {} },
stderr: { on() {} },
stdin: null,
on() {},
kill() {},
};
const shim = "C:\\Users\\me\\AppData\\Roaming\\npm\\grok.cmd";
const child = spawnGrokProcess(
(command, args, options) => {
calls.push({ command, args, options });
return fakeChild;
},
shim,
["agent", "stdio"],
{ cwd: "D:\\repo", windowsHide: true },
);
assert.equal(child, fakeChild);
assert.equal(calls.length, 1);
assert.equal(calls[0].options.cwd, "D:\\repo");
assert.equal(calls[0].options.windowsHide, true);
assert.equal(calls[0].options.shell, process.platform === "win32");
if (process.platform === "win32") {
assert.match(String(calls[0].command), /grok\.cmd/i);
assert.deepEqual(calls[0].args, []);
} else {
assert.equal(calls[0].command, shim);
assert.deepEqual(calls[0].args, ["agent", "stdio"]);
}
});
test("extractGrokAcpPromptUsage maps live Grok _meta.usage and cachedReadTokens", () => {
const promptResult = {
stopReason: "end_turn",
_meta: {
inputTokens: 27144,
outputTokens: 29,
totalTokens: 27174,
cachedReadTokens: 2560,
reasoningTokens: 24,
usage: {
inputTokens: 27144,
outputTokens: 29,
totalTokens: 27173,
cachedReadTokens: 2560,
reasoningTokens: 24,
},
},
};
const extracted = extractGrokAcpPromptUsage(promptResult);
assert.equal(extracted.cachedReadTokens, 2560);
const calls = [];
emitGrokUsage({ usage: (u) => calls.push(u) }, extracted);
assert.deepEqual(calls[0], {
inputTokens: 27144,
cachedInputTokens: 2560,
outputTokens: 29,
reasoningTokens: 24,
totalTokens: 27173,
});
});
test("resolveGrokTurnPrompt seeds history only when resume falls back to session/new", () => {
const seed = "[Conversation context replay]\nUSER: earlier";
const turn = "latest question";
assert.equal(
resolveGrokTurnPrompt({
turnPrompt: turn,
historySeed: seed,
resumeSessionId: "old-sess",
establishMethod: "new",
}),
`${seed}\n\n${turn}`,
);
// Successful resume/load must not inject seed (avoids stacked prior replies).
assert.equal(
resolveGrokTurnPrompt({
turnPrompt: turn,
historySeed: seed,
resumeSessionId: "old-sess",
establishMethod: "resume",
}),
turn,
);
assert.equal(
resolveGrokTurnPrompt({
turnPrompt: turn,
historySeed: seed,
resumeSessionId: "old-sess",
establishMethod: "load",
}),
turn,
);
// No resume attempt → never seed (first-turn replay is handled upstream).
assert.equal(
resolveGrokTurnPrompt({
turnPrompt: turn,
historySeed: seed,
resumeSessionId: undefined,
establishMethod: "new",
}),
turn,
);
assert.equal(
resolveGrokTurnPrompt({
turnPrompt: turn,
historySeed: "",
resumeSessionId: "old-sess",
establishMethod: "new",
}),
turn,
);
});
test("translateGrokStreamEvent maps thought, text, tools, usage, end", () => {
const emitter = makeEmitter();
const state = {};
translateGrokStreamEvent({ type: "thought", data: "plan" }, emitter, state);
translateGrokStreamEvent({ type: "text", data: "Hi" }, emitter, state);
translateGrokStreamEvent({
type: "tool_call",
toolCallId: "c1",
toolName: "read_file",
status: "in_progress",
rawInput: { path: "a.ts" },
}, emitter, state);
translateGrokStreamEvent({
type: "tool_call_update",
toolCallId: "c1",
status: "completed",
rawOutput: { lines: 2 },
}, emitter, state);
translateGrokStreamEvent({
type: "usage",
usage: {
input_tokens: 10,
output_tokens: 3,
cache_read_input_tokens: 1,
reasoning_tokens: 2,
total_tokens: 16,
},
}, emitter, state);
translateGrokStreamEvent({
type: "end",
stopReason: "end_turn",
sessionId: "s1",
usage: { input_tokens: 10, output_tokens: 3, total_tokens: 13 },
}, emitter, state);
assert.deepEqual(emitter.calls, [
["reasoning", "plan"],
["reasoningEnd"],
["text", "Hi"],
["toolCall", "read_file", { path: "a.ts" }, "c1"],
["toolResult", "c1", "{\"lines\":2}", "read_file"],
["usage", {
inputTokens: 10,
cachedInputTokens: 1,
outputTokens: 3,
reasoningTokens: 2,
totalTokens: 16,
}],
["sessionId", "s1"],
["usage", {
inputTokens: 10,
cachedInputTokens: 0,
outputTokens: 3,
reasoningTokens: 0,
totalTokens: 13,
}],
]);
assert.equal(state.sessionId, "s1");
assert.equal(state.streamedAssistantText, true);
});
test("translateGrokStreamEvent maps error events to emitError and stop", () => {
const emitter = makeEmitter();
const state = {};
const stop = translateGrokStreamEvent(
{ type: "error", message: "Couldn't start session" },
emitter,
state,
);
assert.equal(stop, true);
assert.equal(state.failed, true);
assert.deepEqual(emitter.calls, [["error", "Couldn't start session"]]);
});
test("buildGrokMcpServerTomlSection escapes paths and env", () => {
const section = buildGrokMcpServerTomlSection({
name: "netcatty-remote-hosts",
command: "C:\\Program Files\\node.exe",
args: ["mcp.cjs", "--flag"],
env: [{ name: "TOKEN", value: 'a"b' }],
});
assert.match(section, /\[mcp_servers\.netcatty-remote-hosts\]/);
assert.match(section, /command = "C:\\\\Program Files\\\\node\.exe"/);
assert.match(section, /args = \["mcp\.cjs", "--flag"\]/);
assert.match(section, /TOKEN = "a\\"b"/);
assert.match(section, /enabled = true/);
});
test("stripGrokMcpServerSection removes only the named server block", () => {
const input = [
"[ui]",
"compact_mode = true",
"",
"[mcp_servers.other]",
'command = "echo"',
"",
"[mcp_servers.netcatty-remote-hosts]",
'command = "node"',
"enabled = true",
"",
"[mcp_servers.other.nested]",
"x = 1",
].join("\n");
const stripped = stripGrokMcpServerSection(input, "netcatty-remote-hosts");
assert.match(stripped, /\[mcp_servers\.other\]/);
assert.match(stripped, /\[ui\]/);
assert.doesNotMatch(stripped, /netcatty-remote-hosts/);
});
test("mergeWorkspaceGrokMcpToml upserts netcatty without dropping other servers", () => {
resetGrokMcpMergeRefcountsForTests();
const path = require("node:path");
const repo = path.join("repo-fixture");
const grokDir = path.join(repo, ".grok");
const configPath = path.join(grokDir, "config.toml");
const original = [
"[mcp_servers.other]",
'command = "echo"',
"enabled = true",
"",
].join("\n");
const files = new Map();
files.set(configPath, original);
const handle = mergeWorkspaceGrokMcpToml(repo, [{
name: "netcatty-remote-hosts",
command: "node",
args: ["mcp.cjs"],
env: [{ name: "TOKEN", value: "x" }],
}], {
existsSync: (p) => files.has(p) || p === grokDir,
readFileSync: (p) => files.get(p),
writeFileSync: (p, data) => { files.set(p, data); },
mkdirSync: () => {},
unlinkSync: (p) => { files.delete(p); },
});
const written = files.get(configPath);
assert.match(written, /\[mcp_servers\.other\]/);
assert.match(written, /\[mcp_servers\.netcatty-remote-hosts\]/);
assert.match(written, /TOKEN = "x"/);
handle.restore();
assert.equal(files.get(configPath), original);
});
test("parseGrokModelsOutput reads default and bullet list", () => {
const parsed = parseGrokModelsOutput([
"You are logged in with grok.com.",
"",
"Default model: grok-4.5",
"",
"Available models:",
" * grok-4.5 (default)",
" * grok-code-fast",
].join("\n"));
assert.equal(parsed.currentModelId, "grok-4.5");
assert.deepEqual(parsed.models, [
{
id: "grok-4.5",
name: "grok-4.5",
thinkingLevels: ["high", "medium", "low"],
defaultThinkingLevel: "high",
},
{ id: "grok-code-fast", name: "grok-code-fast" },
]);
});
test("runGrokTurn streams fixture lines and emits done", async () => {
const emitter = makeEmitter();
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 4242;
child.kill = () => {};
const spawnImpl = (bin, args) => {
assert.equal(bin, "/usr/bin/grok");
assert.ok(args.includes("streaming-json"));
assert.ok(args.includes("--always-approve"));
queueMicrotask(() => {
child.stdout.emit("data", Buffer.from(
[
'{"type":"thought","data":"thinking"}',
'{"type":"text","data":"hello"}',
'{"type":"end","sessionId":"sess-xyz","stopReason":"end_turn"}',
"",
].join("\n"),
));
child.emit("close", 0);
});
return child;
};
const result = await runGrokTurn({
prompt: "hi",
binPath: "/usr/bin/grok",
cwd: "/repo",
permissionMode: "auto",
injectedMcpServers: [],
emitter,
spawnImpl,
mergeMcp: () => ({ restore() {} }),
});
assert.equal(result.sessionId, "sess-xyz");
assert.ok(emitter.calls.some((c) => c[0] === "text" && c[1] === "hello"));
assert.ok(emitter.calls.some((c) => c[0] === "done"));
assert.ok(emitter.calls.some((c) => c[0] === "sessionId" && c[1] === "sess-xyz"));
});
test("runGrokTurn reports error when process dies after partial text without end", async () => {
// Mid-response crash: text already streamed, no end → must not emitDone.
const emitter = makeEmitter();
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 99;
child.kill = () => {};
const spawnImpl = () => {
queueMicrotask(() => {
child.stdout.emit("data", Buffer.from('{"type":"text","data":"partial…"}\n'));
child.emit("close", 1);
});
return child;
};
await runGrokTurn({
prompt: "write a lot",
binPath: "/usr/bin/grok",
permissionMode: "auto",
injectedMcpServers: [],
emitter,
spawnImpl,
mergeMcp: () => ({ restore() {} }),
});
assert.ok(emitter.calls.some((c) => c[0] === "text" && c[1] === "partial…"));
assert.ok(emitter.calls.some((c) => c[0] === "error"), "partial stream + exit 1 must emitError");
assert.ok(!emitter.calls.some((c) => c[0] === "done"), "must not emitDone on mid-turn crash");
});
test("runGrokTurn fails when process is signal-killed mid-turn (code=null)", async () => {
// Node close(null, "SIGTERM") — previously skipped because code was not a nonzero number.
const emitter = makeEmitter();
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 98;
child.kill = () => {};
const spawnImpl = () => {
queueMicrotask(() => {
child.stdout.emit("data", Buffer.from('{"type":"text","data":"partial…"}\n'));
child.emit("close", null, "SIGTERM");
});
return child;
};
await runGrokTurn({
prompt: "write a lot",
binPath: "/usr/bin/grok",
permissionMode: "auto",
injectedMcpServers: [],
emitter,
spawnImpl,
mergeMcp: () => ({ restore() {} }),
});
assert.ok(emitter.calls.some((c) => c[0] === "text" && c[1] === "partial…"));
const err = emitter.calls.find((c) => c[0] === "error");
assert.ok(err, "signal kill mid-turn must emitError");
assert.match(String(err[1]), /SIGTERM|signal/i);
assert.ok(!emitter.calls.some((c) => c[0] === "done"));
});
test("runGrokTurn fails when process exits 0 after partial text without end", async () => {
// Quiet CLI death must not look like a successful turn.
const emitter = makeEmitter();
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 97;
child.kill = () => {};
const spawnImpl = () => {
queueMicrotask(() => {
child.stdout.emit("data", Buffer.from('{"type":"text","data":"partial…"}\n'));
child.emit("close", 0);
});
return child;
};
await runGrokTurn({
prompt: "write a lot",
binPath: "/usr/bin/grok",
permissionMode: "auto",
injectedMcpServers: [],
emitter,
spawnImpl,
mergeMcp: () => ({ restore() {} }),
});
assert.ok(emitter.calls.some((c) => c[0] === "text" && c[1] === "partial…"));
assert.ok(emitter.calls.some((c) => c[0] === "error"), "exit 0 without end must emitError");
assert.ok(!emitter.calls.some((c) => c[0] === "done"));
});
test("translateGrokStreamEvent emits toolResult when rawOutput present without status", () => {
const emitter = makeEmitter();
const state = {};
translateGrokStreamEvent({
type: "tool_call_update",
toolCallId: "t1",
toolName: "read",
rawOutput: { content: "file body" },
}, emitter, state);
assert.ok(emitter.calls.some((c) => c[0] === "toolCall" && c[3] === "t1"));
assert.ok(emitter.calls.some((c) => c[0] === "toolResult" && c[1] === "t1"));
});
test("normalizeGrokPlanUpdate maps to shared { text, completed } activity shape", () => {
assert.deepEqual(
normalizeGrokPlanUpdate([
{ content: "Explore", status: "completed" },
{ text: "Edit", status: "pending" },
"Ship it",
]),
{
items: [
{ text: "Explore", completed: true },
{ text: "Edit", completed: false },
{ text: "Ship it", completed: false },
],
status: "running",
},
);
assert.deepEqual(
normalizeGrokPlanUpdate([
{ content: "A", status: "done" },
{ content: "B", completed: true },
]),
{
items: [
{ text: "A", completed: true },
{ text: "B", completed: true },
],
status: "completed",
},
);
assert.equal(normalizeGrokPlanUpdate([]), null);
});
test("translateGrokStreamEvent plan uses text/completed and running|completed status", () => {
const emitter = makeEmitter();
translateGrokStreamEvent({
type: "plan",
entries: [
{ content: "Step one", status: "completed" },
{ content: "Step two", status: "in_progress" },
],
}, emitter, {});
const planCall = emitter.calls.find((c) => c[0] === "planUpdate");
assert.ok(planCall);
assert.equal(planCall[1], "grok-plan");
assert.deepEqual(planCall[2], [
{ text: "Step one", completed: true },
{ text: "Step two", completed: false },
]);
assert.equal(planCall[3], "running");
assert.notEqual(planCall[3], "updated");
});
test("shouldReportGrokProcessExitFailure fails any incomplete close (incl exit 0)", () => {
assert.equal(shouldReportGrokProcessExitFailure({ turnCompleted: false }, null, null, "SIGTERM"), true);
assert.equal(shouldReportGrokProcessExitFailure({ turnCompleted: false }, null, 143, null), true);
// Exit 0 without protocol completion is still a failure (CLI can die quietly).
assert.equal(shouldReportGrokProcessExitFailure({ turnCompleted: false }, null, 0, null), true);
assert.equal(shouldReportGrokProcessExitFailure({ turnCompleted: true }, null, null, "SIGTERM"), false);
assert.equal(shouldReportGrokProcessExitFailure({ turnCompleted: true }, null, 1, null), false);
assert.equal(shouldReportGrokProcessExitFailure({ turnCompleted: false }, { aborted: true }, null, "SIGKILL"), false);
assert.equal(shouldReportGrokProcessExitFailure({ turnCompleted: false, failed: true }, null, 1, null), false);
});
test("runGrokTurn ignores exit code 1 after end event (Windows teardown)", async () => {
const emitter = makeEmitter();
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 100;
child.kill = () => {};
const spawnImpl = () => {
queueMicrotask(() => {
child.stdout.emit("data", Buffer.from(
[
'{"type":"text","data":"done"}',
'{"type":"end","sessionId":"s-end","stopReason":"end_turn"}',
"",
].join("\n"),
));
child.emit("close", 1);
});
return child;
};
await runGrokTurn({
prompt: "hi",
binPath: "/usr/bin/grok",
permissionMode: "auto",
injectedMcpServers: [],
emitter,
spawnImpl,
mergeMcp: () => ({ restore() {} }),
});
assert.ok(emitter.calls.some((c) => c[0] === "done"));
assert.ok(!emitter.calls.some((c) => c[0] === "error"));
});
test("runGrokTurn reports missing CLI clearly", async () => {
const emitter = makeEmitter();
const result = await runGrokTurn({
prompt: "hi",
binPath: "",
emitter,
});
assert.equal(result.sessionId, null);
assert.match(String(emitter.calls[0]?.[1] || ""), /not found/i);
});
test("listGrokModels parses spawn stdout", async () => {
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 1;
child.kill = () => {};
const spawnImpl = (_bin, args) => {
assert.deepEqual(args, ["--no-auto-update", "models"]);
queueMicrotask(() => {
child.stdout.emit("data", Buffer.from("Default model: grok-4.5\n* grok-4.5 (default)\n"));
child.emit("close", 0);
});
return child;
};
const result = await listGrokModels({
binPath: "/usr/bin/grok",
spawnImpl,
});
assert.equal(result.currentModelId, "grok-4.5");
assert.equal(result.models[0].id, "grok-4.5");
});

View File

@@ -0,0 +1,404 @@
"use strict";
/**
* SDK driver registry. Mirrors craft backend/factory.ts DRIVER_REGISTRY.
* Each driver exposes a uniform runTurn(ctx) that builds its SDK options from
* the neutral context and streams events through ctx.emitter.
*
* ctx shape (built by sdkStreamHandlers.cjs):
* { prompt, attachments, cwd, model, env, binPath, injectedMcpServers, emitter,
* signal, resumeSessionId, apiKey, baseUrl }
*/
const claude = require("./claudeDriver.cjs");
const codex = require("./codexDriver.cjs");
const copilot = require("./copilotDriver.cjs");
const cursor = require("./cursorDriver.cjs");
const cursorCli = require("./cursorCliDriver.cjs");
const codebuddy = require("./codebuddyDriver.cjs");
const opencode = require("./opencodeDriver.cjs");
const grok = require("./grokDriver.cjs");
const grokAcp = require("./grokAcpDriver.cjs");
const { codebuddySessionManager } = require("./codebuddySessionManager.cjs");
function hasCodebuddyQueryOnlyOptions(options) {
return Boolean(
options.maxBudgetUsd ||
options.sandbox?.enabled === true ||
options.fallbackModel ||
options.enableFileCheckpointing === true ||
options.outputFormat,
);
}
const DRIVER_REGISTRY = {
claude: {
async runTurn(ctx) {
const options = claude.buildClaudeQueryOptions({
cwd: ctx.cwd,
model: ctx.model,
env: ctx.env,
pathToClaudeCodeExecutable: ctx.binPath,
abortController: ctx.abortController,
injectedMcpServers: ctx.injectedMcpServers,
settings: ctx.claudeSettings,
resume: ctx.resumeSessionId,
toolIntegrationMode: ctx.toolIntegrationMode,
});
return claude.runClaudeTurn({ prompt: ctx.prompt, attachments: ctx.attachments, options, emitter: ctx.emitter });
},
async listModels(ctx) {
return claude.listClaudeModels({
pathToClaudeCodeExecutable: ctx.binPath,
env: ctx.env,
abortController: ctx.abortController,
signal: ctx.signal,
});
},
},
codex: {
async runTurn(ctx) {
const constructorOptions = codex.buildCodexConstructorOptions({
codexPath: ctx.binPath,
env: ctx.env,
apiKey: ctx.apiKey,
baseUrl: ctx.baseUrl,
injectedMcpServers: ctx.injectedMcpServers,
});
const threadOptions = codex.buildCodexThreadOptions({ cwd: ctx.cwd, model: ctx.model });
return codex.runCodexTurn({
prompt: ctx.prompt,
attachments: ctx.attachments,
constructorOptions,
threadOptions,
resumeThreadId: ctx.resumeSessionId,
emitter: ctx.emitter,
signal: ctx.signal,
});
},
// codex-sdk exposes no model catalog; the UI falls back to curated presets.
async listModels() { return []; },
},
copilot: {
async runTurn(ctx) {
const clientOptions = copilot.buildCopilotClientOptions({ cliPath: ctx.binPath });
const sessionOptions = copilot.buildCopilotSessionOptions({
model: ctx.model,
injectedMcpServers: ctx.injectedMcpServers,
toolIntegrationMode: ctx.toolIntegrationMode,
});
return copilot.runCopilotTurn({
prompt: ctx.prompt,
attachments: ctx.attachments,
clientOptions,
sessionOptions,
resumeSessionId: ctx.resumeSessionId,
toolIntegrationMode: ctx.toolIntegrationMode,
runtimeEnv: ctx.env,
emitter: ctx.emitter,
signal: ctx.signal,
});
},
async listModels(ctx) {
return copilot.listCopilotModels({
cliPath: ctx.binPath,
abortController: ctx.abortController,
signal: ctx.signal,
});
},
},
cursor: {
async runTurn(ctx) {
const authMode = ctx.cursorAuthMode === "cli-login" ? "cli-login" : "api-key";
if (authMode === "cli-login") {
return cursorCli.runCursorCliTurn({
prompt: ctx.prompt,
binPath: ctx.cursorCliBinPath || ctx.binPath,
cwd: ctx.cwd,
chatSessionId: ctx.chatSessionId,
getTempDir: ctx.getTempDir,
model: ctx.model,
env: ctx.env,
permissionMode: ctx.permissionMode,
resumeSessionId: ctx.resumeSessionId,
injectedMcpServers: ctx.injectedMcpServers,
emitter: ctx.emitter,
signal: ctx.signal,
});
}
const agentOptions = cursor.buildCursorAgentOptions({
apiKey: ctx.apiKey,
env: ctx.env,
model: ctx.model,
cwd: ctx.cwd,
injectedMcpServers: ctx.injectedMcpServers,
});
return cursor.runCursorTurn({
prompt: ctx.prompt,
attachments: ctx.attachments,
agentOptions,
runtimeEnv: ctx.env,
resumeSessionId: ctx.resumeSessionId,
emitter: ctx.emitter,
signal: ctx.signal,
});
},
async listModels(ctx) {
if (ctx.cursorAuthMode === "cli-login") {
return cursorCli.listCursorCliModels({
binPath: ctx.cursorCliBinPath || ctx.binPath,
env: ctx.env,
abortController: ctx.abortController,
signal: ctx.signal,
});
}
return cursor.listCursorModels({
env: ctx.env,
abortController: ctx.abortController,
signal: ctx.signal,
});
},
},
codebuddy: {
async runTurn(ctx) {
// Build the permission handler: when the CLI hits a security restriction,
// auto-confirm (auto mode) or prompt the user (confirm mode) instead of
// throwing an error.
const canUseTool = codebuddy.buildCodebuddyCanUseTool({
permissionMode: ctx.permissionMode,
chatSessionId: ctx.chatSessionId,
requestApproval: ctx.requestApprovalFromRenderer,
});
// Build the elicitation handler: forwards create/complete events to the
// renderer and waits for the user's decision via the session manager's
// pending-response map (resolved by the elicitation-response IPC).
// chatSessionId lets closeForChat cancel pendings when the chat closes.
const elicitation = codebuddy.buildCodebuddyElicitation(
ctx.emitter,
codebuddySessionManager.elicitationPending,
{ chatSessionId: ctx.chatSessionId },
);
const options = codebuddy.buildCodebuddyQueryOptions({
cwd: ctx.cwd,
model: ctx.model,
env: ctx.env,
injectedMcpServers: ctx.injectedMcpServers,
abortController: ctx.abortController,
resume: ctx.resumeSessionId,
pathToCodebuddyCode: ctx.binPath,
toolIntegrationMode: ctx.toolIntegrationMode,
// SDK 0.3.230 options
systemPrompt: ctx.systemPrompt,
effort: ctx.effort,
maxTurns: ctx.maxTurns,
maxBudgetUsd: ctx.maxBudgetUsd,
fallbackModel: ctx.fallbackModel,
sandbox: ctx.sandbox,
agents: ctx.agents,
outputFormat: ctx.outputFormat,
enableFileCheckpointing: ctx.enableFileCheckpointing,
traceId: ctx.traceId,
parentSpanId: ctx.parentSpanId,
hooks: codebuddy.buildCodebuddyHooks(ctx.emitter, {
toolIntegrationMode: ctx.toolIntegrationMode,
additionalHooks: ctx.hooks,
allowedCliCommandPrefix: ctx.skillsCliCommandPrefix,
}),
elicitation,
canUseTool,
});
const sessionKey = [
String(ctx.chatSessionId || ""),
"codebuddy",
String(ctx.binPath || ""),
"sdk",
].join("\u0000");
// Try V2 Session API first (persistent multi-turn), falling back to
// query() only for fields that SessionOptions does not support.
const hasQueryOnlyOptions = hasCodebuddyQueryOnlyOptions(options);
if (!hasQueryOnlyOptions) {
const sessionOptions = {
cwd: options.cwd,
model: options.model,
env: options.env,
pathToCodebuddyCode: options.pathToCodebuddyCode,
mcpServers: options.mcpServers,
permissionMode: options.permissionMode,
extraArgs: options.extraArgs,
systemPrompt: options.systemPrompt,
hooks: options.hooks,
elicitation: options.elicitation,
canUseTool: options.canUseTool,
includePartialMessages: true,
tools: options.tools,
disallowedTools: options.disallowedTools,
settingSources: options.settingSources,
maxTurns: options.maxTurns,
agents: options.agents,
thinking: options.thinking,
effort: options.effort,
};
const v2Result = await codebuddySessionManager.runTurn({
sessionKey,
prompt: ctx.prompt,
attachments: ctx.attachments,
options,
emitter: ctx.emitter,
sessionOptions,
resumeSessionId: ctx.resumeSessionId,
});
if (v2Result) return v2Result;
} else {
// Do not leave a warm V2 process with stale context while query() is
// resuming and advancing the same persisted conversation.
codebuddySessionManager.closeSession(sessionKey);
}
// Fallback: legacy query() per-turn (supports all Options fields).
return codebuddy.runCodebuddyTurn({
prompt: ctx.prompt,
attachments: ctx.attachments,
options,
emitter: ctx.emitter,
});
},
async steerTurn(ctx) {
const sessionKey = [
String(ctx.chatSessionId || ""),
"codebuddy",
String(ctx.binPath || ""),
"sdk",
].join("\u0000");
return codebuddySessionManager.steer({
sessionKey,
prompt: ctx.prompt,
attachments: ctx.attachments,
});
},
async listModels(ctx) {
return codebuddy.listCodebuddyModels({
pathToCodebuddyCode: ctx.binPath,
env: ctx.env,
abortController: ctx.abortController,
signal: ctx.signal,
});
},
},
opencode: {
async runTurn(ctx) {
return opencode.runOpenCodeTurn({
prompt: ctx.prompt,
systemPrompt: ctx.systemPrompt,
attachments: ctx.attachments,
cwd: ctx.cwd,
model: ctx.model,
env: ctx.env,
binPath: ctx.binPath,
injectedMcpServers: ctx.injectedMcpServers,
toolIntegrationMode: ctx.toolIntegrationMode,
skillsPathAllowlist: ctx.skillsPathAllowlist,
resumeSessionId: ctx.resumeSessionId,
emitter: ctx.emitter,
abortController: ctx.abortController,
});
},
async listModels(ctx) {
return opencode.listOpenCodeModels({
env: ctx.env,
binPath: ctx.binPath,
abortController: ctx.abortController,
signal: ctx.abortController?.signal || ctx.signal,
});
},
},
grok: {
async runTurn(ctx) {
// Default: ACP (`grok agent stdio`) with session-level mcpServers.
// Explicit fallback: NETCATTY_GROK_RUNTIME=streaming-json or ctx.grokRuntime.
const runtime = String(
ctx.grokRuntime
|| ctx.env?.NETCATTY_GROK_RUNTIME
|| process.env.NETCATTY_GROK_RUNTIME
|| "acp",
).toLowerCase();
if (runtime === "streaming-json" || runtime === "cli" || runtime === "headless") {
// Headless cannot know if -r restored history before the prompt is sent.
// Prefer native -r without seed (common success path). Stale-id fallback
// is handled on ACP (default runtime) via historySeed + session/new.
return grok.runGrokTurn({
prompt: ctx.prompt,
binPath: ctx.binPath,
cwd: ctx.cwd,
model: ctx.model,
env: ctx.env,
permissionMode: ctx.permissionMode,
toolIntegrationMode: ctx.toolIntegrationMode,
resumeSessionId: ctx.resumeSessionId,
injectedMcpServers: ctx.injectedMcpServers,
emitter: ctx.emitter,
signal: ctx.signal || ctx.abortController?.signal,
});
}
return grokAcp.runGrokAcpTurn({
prompt: ctx.prompt,
systemPrompt: ctx.systemPrompt,
binPath: ctx.binPath,
cwd: ctx.cwd,
model: ctx.model,
env: ctx.env,
permissionMode: ctx.permissionMode,
toolIntegrationMode: ctx.toolIntegrationMode,
resumeSessionId: ctx.resumeSessionId,
historySeed: ctx.historySeed,
injectedMcpServers: ctx.injectedMcpServers,
emitter: ctx.emitter,
signal: ctx.signal || ctx.abortController?.signal,
});
},
async listModels(ctx) {
const acpCatalog = await grokAcp.listGrokAcpModels({
binPath: ctx.binPath,
env: ctx.env,
abortController: ctx.abortController,
signal: ctx.signal || ctx.abortController?.signal,
});
if (acpCatalog.models.length > 0) {
return acpCatalog;
}
const fallbackCatalog = await grok.listGrokModels({
binPath: ctx.binPath,
env: ctx.env,
abortController: ctx.abortController,
signal: ctx.signal || ctx.abortController?.signal,
});
const currentModelId = acpCatalog.currentModelId || fallbackCatalog.currentModelId;
const models = fallbackCatalog.models.length > 0
? fallbackCatalog.models
: (currentModelId
? [grok.applyGrokReasoningFallback({ id: currentModelId, name: currentModelId })]
: []);
return {
currentModelId: grok.resolveGrokCatalogCurrentModelId(models, currentModelId),
models,
};
},
},
};
function getDriver(backend) {
const driver = DRIVER_REGISTRY[backend];
if (!driver) throw new Error(`No SDK driver registered for backend: ${backend}`);
return driver;
}
function listBackends() {
return Object.keys(DRIVER_REGISTRY);
}
module.exports = {
DRIVER_REGISTRY,
getDriver,
listBackends,
hasCodebuddyQueryOnlyOptions,
};

View File

@@ -0,0 +1,76 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
getDriver,
listBackends,
hasCodebuddyQueryOnlyOptions,
} = require("./index.cjs");
const { codebuddySessionManager } = require("./codebuddySessionManager.cjs");
test("registry exposes SDK backends", () => {
assert.deepEqual(listBackends().sort(), ["claude", "codebuddy", "codex", "copilot", "cursor", "grok", "opencode"]);
});
test("getDriver returns a driver with runTurn", () => {
for (const key of ["claude", "codebuddy", "codex", "copilot", "cursor", "grok", "opencode"]) {
const d = getDriver(key);
assert.equal(typeof d.runTurn, "function", `${key} must expose runTurn`);
}
});
test("getDriver throws on unknown backend", () => {
assert.throws(() => getDriver("gemini"), /No SDK driver registered for backend: gemini/);
});
test("SDK drivers expose listModels; codex returns [] (no catalog)", async () => {
for (const key of ["claude", "codebuddy", "codex", "copilot", "cursor", "grok", "opencode"]) {
assert.equal(typeof getDriver(key).listModels, "function", `${key} must expose listModels`);
}
assert.deepEqual(await getDriver("codex").listModels({}), []);
});
test("CodeBuddy keeps V2 for SessionOptions fields and falls back for query-only fields", () => {
assert.equal(hasCodebuddyQueryOnlyOptions({
agents: { reviewer: { description: "Reviews changes", prompt: "Review" } },
thinking: { type: "adaptive" },
effort: "high",
}), false);
assert.equal(hasCodebuddyQueryOnlyOptions({ maxBudgetUsd: 1 }), true);
assert.equal(hasCodebuddyQueryOnlyOptions({ sandbox: { enabled: true } }), true);
assert.equal(hasCodebuddyQueryOnlyOptions({ sandbox: { enabled: false } }), false);
assert.equal(hasCodebuddyQueryOnlyOptions({ fallbackModel: "fallback" }), true);
assert.equal(hasCodebuddyQueryOnlyOptions({ enableFileCheckpointing: false }), false);
assert.equal(hasCodebuddyQueryOnlyOptions({ outputFormat: { type: "json_schema" } }), true);
});
test("CodeBuddy forwards the explicit bypass opt-in to V2 sessions", async () => {
const originalRunTurn = codebuddySessionManager.runTurn;
let capturedSessionOptions;
codebuddySessionManager.runTurn = async ({ sessionOptions }) => {
capturedSessionOptions = sessionOptions;
return { sessionId: "v2-session", usedV2: true };
};
try {
const result = await getDriver("codebuddy").runTurn({
chatSessionId: "chat-1",
prompt: "hello",
attachments: [],
cwd: "/tmp",
env: {},
injectedMcpServers: [],
permissionMode: "auto",
toolIntegrationMode: "mcp",
emitter: {},
});
assert.deepEqual(capturedSessionOptions.extraArgs, {
"dangerously-skip-permissions": null,
});
assert.equal(capturedSessionOptions.permissionMode, "bypassPermissions");
assert.deepEqual(capturedSessionOptions.settingSources, []);
assert.deepEqual(result, { sessionId: "v2-session", usedV2: true });
} finally {
codebuddySessionManager.runTurn = originalRunTurn;
}
});

View File

@@ -0,0 +1,57 @@
"use strict";
/**
* Build the netcatty-mcp-server config to inject into an SDK agent as an
* EXTERNAL MCP server. Reuses mcpServerBridge.buildMcpServerConfig (unchanged)
* so the approval/scope/blocklist layer is identical across integrations.
*
* Returns an array of netcatty MCP server configs (0 or 1 entry):
* { name, type:'stdio', command, args, env:[{name,value}, ...] }
* Each driver converts this neutral shape into its SDK's MCP format.
*/
async function buildInjectedMcpServers({
mcpServerBridge,
chatSessionId,
toolIntegrationMode,
}) {
try {
// Start the netcatty control host for BOTH modes. getOrCreateHost binds the
// TCP server and writes the netcatty-tool-cli discovery file on bind:
// - mcp mode: the host is injected below as an MCP server.
// - skills mode: the agent reaches the host through that discovery file via
// the netcatty CLI. Skipping this in skills mode left no host for the CLI
// to find, so every `netcatty-tool-cli` call failed with APP_NOT_RUNNING.
const mcpPort = await mcpServerBridge.getOrCreateHost();
// Skills mode drives the netcatty CLI, not an injected MCP server.
if (toolIntegrationMode !== "mcp") return [];
const scopedIds = mcpServerBridge.getScopedSessionIds(chatSessionId);
const netcattyMcpConfig = mcpServerBridge.buildMcpServerConfig(
mcpPort,
scopedIds,
chatSessionId,
);
return [netcattyMcpConfig];
} catch (err) {
console.error("[sdk] Failed to ensure netcatty host / inject MCP server:", err?.message || err);
return [];
}
}
/**
* Convert the neutral env-pair array ([{name,value}]) used by
* buildMcpServerConfig into a plain {KEY:VALUE} object, which is what the
* claude/codex/copilot SDKs expect for an MCP server's env field.
*/
function mcpEnvPairsToObject(envPairs) {
const out = {};
if (Array.isArray(envPairs)) {
for (const pair of envPairs) {
if (pair && typeof pair.name === "string" && typeof pair.value === "string") {
out[pair.name] = pair.value;
}
}
}
return out;
}
module.exports = { buildInjectedMcpServers, mcpEnvPairsToObject };

View File

@@ -0,0 +1,61 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { buildInjectedMcpServers } = require("./injectMcp.cjs");
function fakeMcpBridge() {
let hostStartCount = 0;
return {
get hostStartCount() { return hostStartCount; },
getOrCreateHost: async () => {
hostStartCount += 1;
return 54321;
},
getScopedSessionIds: (chatId) => (chatId === "chat-1" ? ["s1", "s2"] : []),
buildMcpServerConfig: (port, ids, chatId) => ({
name: "netcatty-remote-hosts",
type: "stdio",
command: "/path/electron",
args: ["/path/netcatty-mcp-server.cjs"],
env: [
{ name: "NETCATTY_MCP_PORT", value: String(port) },
{ name: "NETCATTY_MCP_CHAT_SESSION_ID", value: chatId },
],
}),
};
}
test("mcp mode returns netcatty MCP stdio config", async () => {
const res = await buildInjectedMcpServers({
mcpServerBridge: fakeMcpBridge(),
chatSessionId: "chat-1",
toolIntegrationMode: "mcp",
});
assert.equal(res.length, 1);
assert.equal(res[0].name, "netcatty-remote-hosts");
assert.equal(res[0].type, "stdio");
assert.equal(res[0].command, "/path/electron");
const portPair = res[0].env.find((p) => p.name === "NETCATTY_MCP_PORT");
assert.equal(portPair.value, "54321");
});
test("skills mode starts the CLI host and returns no injected MCP config", async () => {
const bridge = fakeMcpBridge();
const res = await buildInjectedMcpServers({
mcpServerBridge: bridge,
chatSessionId: "chat-1",
toolIntegrationMode: "skills",
});
assert.deepEqual(res, []);
assert.equal(bridge.hostStartCount, 1);
});
test("getOrCreateHost failure degrades to empty, not throw", async () => {
const bridge = fakeMcpBridge();
bridge.getOrCreateHost = async () => { throw new Error("port boom"); };
const res = await buildInjectedMcpServers({
mcpServerBridge: bridge,
chatSessionId: "chat-1",
toolIntegrationMode: "mcp",
});
assert.deepEqual(res, []);
});

View File

@@ -0,0 +1,189 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
function normalizeOpenCodePath(targetPath, platform = process.platform) {
return platform === "win32"
? targetPath.replace(/\\/g, "/")
: targetPath;
}
function appendOpenCodePathPattern(baseDir, suffix) {
const trimmedSuffix = suffix.replace(/^\//, "");
return baseDir.endsWith("/")
? `${baseDir}${trimmedSuffix}`
: `${baseDir}/${trimmedSuffix}`;
}
function toOpenCodeDirectoryBase(dirPath, options = {}) {
if (!dirPath || typeof dirPath !== "string") return null;
const pathModule = options.pathModule || path;
const platform = options.platform || process.platform;
try {
const resolved = pathModule.resolve(dirPath);
let baseDir = resolved;
if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
baseDir = pathModule.dirname(resolved);
}
return normalizeOpenCodePath(baseDir, platform);
} catch {
return null;
}
}
function toOpenCodeDirectoryGlob(dirPath, options = {}) {
const baseDir = toOpenCodeDirectoryBase(dirPath, options);
return baseDir ? appendOpenCodePathPattern(baseDir, "**") : null;
}
function toOpenCodeDirectoryPermissionPatterns(dirPath, options = {}) {
const baseDir = toOpenCodeDirectoryBase(dirPath, options);
return baseDir
? [
baseDir,
appendOpenCodePathPattern(baseDir, "*"),
appendOpenCodePathPattern(baseDir, "**"),
]
: [];
}
function toOpenCodeFileParentGlob(filePath, options = {}) {
if (!filePath || typeof filePath !== "string") return null;
const pathModule = options.pathModule || path;
try {
return toOpenCodeDirectoryGlob(pathModule.dirname(pathModule.resolve(filePath)), options);
} catch {
return null;
}
}
function toOpenCodeFileParentPermissionPatterns(filePath, options = {}) {
if (!filePath || typeof filePath !== "string") return [];
const pathModule = options.pathModule || path;
try {
return toOpenCodeDirectoryPermissionPatterns(pathModule.dirname(pathModule.resolve(filePath)), options);
} catch {
return [];
}
}
function dedupePatterns(patterns) {
return [...new Set(patterns.filter(Boolean))];
}
// OpenCode discovers native agent skills from these well-known directories:
// its global config dirs (~/.opencode and ~/.config/opencode, both "skill"
// and "skills" spellings), Claude/agents-compatible dirs, project-level
// .opencode/.claude/.agents dirs, and the remote-skill download cache.
// Reads inside them must stay allowed even though Netcatty otherwise locks
// external directory access down, or loading a skill's reference files fails
// with an OpenCode permission error (issue #1939).
const OPENCODE_NATIVE_SKILL_DIR_SUFFIXES = [
".opencode/skill",
".opencode/skills",
".config/opencode/skill",
".config/opencode/skills",
".claude/skills",
".agents/skills",
".cache/opencode/skills",
];
// OpenCode's `read` permission checks match worktree-relative paths (e.g.
// "../../.opencode/skills/foo/references/doc.md") while `external_directory`
// checks match absolute directory globs ("C:/Users/me/.opencode/skills/foo/*").
// Anchoring each well-known suffix behind a leading wildcard covers both
// forms on every platform (OpenCode normalizes "\\" to "/" before matching).
function buildOpenCodeNativeSkillPermissionPatterns() {
return OPENCODE_NATIVE_SKILL_DIR_SUFFIXES.flatMap((suffix) => [
`*${suffix}`,
`*${suffix}/*`,
`*${suffix}/**`,
]);
}
// OpenCode's default rules gate `.env` secret files behind approval. The
// broad skill-directory read allows above would win over those defaults
// (last matching rule wins), so re-deny dot-env files inside skill dirs
// after the allow entries to keep secret-file protection intact.
function buildOpenCodeNativeSkillEnvDenyPatterns() {
return OPENCODE_NATIVE_SKILL_DIR_SUFFIXES.flatMap((suffix) => [
`*${suffix}/**.env`,
`*${suffix}/**.env.*`,
]);
}
// Base rules shared by every tool-integration mode so OpenCode's native
// skills keep working: allow loading skills and reading their files while
// still denying all other external directory access.
function buildOpenCodeNativeSkillsPermissionRules() {
const external_directory = { "*": "deny" };
const read = {};
for (const pattern of buildOpenCodeNativeSkillPermissionPatterns()) {
external_directory[pattern] = "allow";
read[pattern] = "allow";
}
for (const pattern of buildOpenCodeNativeSkillEnvDenyPatterns()) {
read[pattern] = "deny";
}
return {
skill: "allow",
read,
external_directory,
};
}
function buildNetcattySkillsOpenCodePathAllowlist({
launcherPath,
cliScriptPath,
skillPath,
discoveryFilePath,
cliStateDir,
runtimeBinaryPath,
tempDir,
extraFilePaths,
} = {}, options = {}) {
const filePaths = [
launcherPath,
cliScriptPath,
skillPath,
discoveryFilePath,
runtimeBinaryPath,
...(Array.isArray(extraFilePaths) ? extraFilePaths : []),
];
return dedupePatterns([
...filePaths.flatMap((filePath) => toOpenCodeFileParentPermissionPatterns(filePath, options)),
...(cliStateDir ? toOpenCodeDirectoryPermissionPatterns(cliStateDir, options) : []),
...(tempDir ? toOpenCodeDirectoryPermissionPatterns(tempDir, options) : []),
]);
}
function buildOpenCodeSkillsPermissionRules(pathAllowlist = []) {
const { read, external_directory } = buildOpenCodeNativeSkillsPermissionRules();
for (const pattern of pathAllowlist) {
external_directory[pattern] = "allow";
read[pattern] = "allow";
}
return {
bash: "allow",
read,
list: "deny",
glob: "deny",
grep: "deny",
skill: "allow",
external_directory,
};
}
module.exports = {
buildNetcattySkillsOpenCodePathAllowlist,
buildOpenCodeNativeSkillEnvDenyPatterns,
buildOpenCodeNativeSkillPermissionPatterns,
buildOpenCodeNativeSkillsPermissionRules,
buildOpenCodeSkillsPermissionRules,
toOpenCodeDirectoryPermissionPatterns,
toOpenCodeDirectoryGlob,
toOpenCodeFileParentPermissionPatterns,
toOpenCodeFileParentGlob,
};

View File

@@ -0,0 +1,216 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const {
buildNetcattySkillsOpenCodePathAllowlist,
buildOpenCodeNativeSkillEnvDenyPatterns,
buildOpenCodeNativeSkillPermissionPatterns,
buildOpenCodeNativeSkillsPermissionRules,
buildOpenCodeSkillsPermissionRules,
toOpenCodeDirectoryPermissionPatterns,
toOpenCodeDirectoryGlob,
toOpenCodeFileParentPermissionPatterns,
toOpenCodeFileParentGlob,
} = require("./netcattySkillsOpenCodePermissions.cjs");
// Mirrors OpenCode's Wildcard.match (packages/core/src/util/wildcard.ts):
// inputs and patterns are normalized to forward slashes, "*" matches any
// run of characters, and matching is anchored to the whole string.
function openCodeWildcardMatch(input, pattern) {
const normalized = input.replaceAll("\\", "/");
const escaped = pattern
.replaceAll("\\", "/")
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".");
return new RegExp(`^${escaped}$`, "s").test(normalized);
}
function matchesAnyPattern(input, patterns) {
return patterns.some((pattern) => openCodeWildcardMatch(input, pattern));
}
// Mirrors OpenCode's Permission.evaluate: rules come from Object.entries of
// the config map in insertion order, and the last matching rule wins.
function evaluateOpenCodeRuleMap(input, ruleMap) {
let action;
for (const [pattern, ruleAction] of Object.entries(ruleMap)) {
if (openCodeWildcardMatch(input, pattern)) action = ruleAction;
}
return action;
}
test("toOpenCodeFileParentGlob maps files to parent directory globs", () => {
assert.equal(
toOpenCodeFileParentGlob("/Applications/Netcatty.app/Contents/MacOS/netcatty-tool-cli"),
"/Applications/Netcatty.app/Contents/MacOS/**",
);
assert.equal(
toOpenCodeFileParentGlob("/tmp/netcatty/skills/netcatty-tool-cli/SKILL.md"),
"/tmp/netcatty/skills/netcatty-tool-cli/**",
);
});
test("toOpenCodeDirectoryGlob keeps directory roots stable when missing on disk", () => {
assert.equal(
toOpenCodeDirectoryGlob("/Users/me/Library/Application Support/netcatty/netcatty-tool-cli"),
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/**",
);
});
test("toOpenCodeDirectoryPermissionPatterns includes exact and wildcard forms", () => {
assert.deepEqual(
toOpenCodeDirectoryPermissionPatterns("/Users/me/Library/Application Support/netcatty/netcatty-tool-cli"),
[
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli",
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/*",
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/**",
],
);
});
test("toOpenCodeFileParentPermissionPatterns normalizes Windows paths", () => {
assert.deepEqual(
toOpenCodeFileParentPermissionPatterns(
"C:\\Users\\me\\AppData\\Local\\Programs\\Netcatty\\resources\\app.asar.unpacked\\electron\\cli\\netcatty-tool-cli.cmd",
{ platform: "win32", pathModule: path.win32 },
),
[
"C:/Users/me/AppData/Local/Programs/Netcatty/resources/app.asar.unpacked/electron/cli",
"C:/Users/me/AppData/Local/Programs/Netcatty/resources/app.asar.unpacked/electron/cli/*",
"C:/Users/me/AppData/Local/Programs/Netcatty/resources/app.asar.unpacked/electron/cli/**",
],
);
});
test("buildNetcattySkillsOpenCodePathAllowlist dedupes launcher and script roots", () => {
const launcher = "/Applications/Netcatty.app/Contents/MacOS/netcatty-tool-cli";
const script = "/Applications/Netcatty.app/Contents/Resources/app.asar.unpacked/electron/cli/netcatty-tool-cli.cjs";
const skill = "/Applications/Netcatty.app/Contents/Resources/app.asar.unpacked/skills/netcatty-tool-cli/SKILL.md";
const patterns = buildNetcattySkillsOpenCodePathAllowlist({
launcherPath: launcher,
cliScriptPath: script,
skillPath: skill,
discoveryFilePath: "/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/discovery.json",
cliStateDir: "/Users/me/Library/Application Support/netcatty/netcatty-tool-cli",
});
assert.deepEqual(patterns, [
"/Applications/Netcatty.app/Contents/MacOS",
"/Applications/Netcatty.app/Contents/MacOS/*",
"/Applications/Netcatty.app/Contents/MacOS/**",
"/Applications/Netcatty.app/Contents/Resources/app.asar.unpacked/electron/cli",
"/Applications/Netcatty.app/Contents/Resources/app.asar.unpacked/electron/cli/*",
"/Applications/Netcatty.app/Contents/Resources/app.asar.unpacked/electron/cli/**",
"/Applications/Netcatty.app/Contents/Resources/app.asar.unpacked/skills/netcatty-tool-cli",
"/Applications/Netcatty.app/Contents/Resources/app.asar.unpacked/skills/netcatty-tool-cli/*",
"/Applications/Netcatty.app/Contents/Resources/app.asar.unpacked/skills/netcatty-tool-cli/**",
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli",
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/*",
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/**",
]);
});
test("buildNetcattySkillsOpenCodePathAllowlist includes temp dir and extra attachment paths", () => {
const patterns = buildNetcattySkillsOpenCodePathAllowlist({
discoveryFilePath: "/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/discovery.json",
tempDir: "/var/folders/tmp/Netcatty",
extraFilePaths: ["/var/folders/tmp/Netcatty/ai-attachment-1.png"],
});
assert.deepEqual(patterns, [
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli",
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/*",
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/**",
"/var/folders/tmp/Netcatty",
"/var/folders/tmp/Netcatty/*",
"/var/folders/tmp/Netcatty/**",
]);
});
test("buildNetcattySkillsOpenCodePathAllowlist includes OpenCode-compatible Windows directory resources", () => {
const patterns = buildNetcattySkillsOpenCodePathAllowlist({
launcherPath: "C:\\Users\\me\\AppData\\Local\\Programs\\Netcatty\\resources\\app.asar.unpacked\\electron\\cli\\netcatty-tool-cli.cmd",
cliScriptPath: "C:\\Users\\me\\AppData\\Local\\Programs\\Netcatty\\resources\\app.asar.unpacked\\electron\\cli\\netcatty-tool-cli.cjs",
skillPath: "C:\\Users\\me\\AppData\\Local\\Programs\\Netcatty\\resources\\app.asar.unpacked\\skills\\netcatty-tool-cli\\SKILL.md",
discoveryFilePath: "C:\\Users\\me\\AppData\\Roaming\\netcatty\\netcatty-tool-cli\\discovery.json",
runtimeBinaryPath: "C:\\Users\\me\\AppData\\Local\\Programs\\Netcatty\\Netcatty.exe",
tempDir: "C:\\Users\\me\\AppData\\Local\\Temp\\Netcatty",
extraFilePaths: ["C:\\Users\\me\\AppData\\Local\\Temp\\Netcatty\\attachment.png"],
}, { platform: "win32", pathModule: path.win32 });
assert.equal(patterns.includes("C:/Users/me/AppData/Local/Programs/Netcatty/resources/app.asar.unpacked/electron/cli/*"), true);
assert.equal(patterns.includes("C:/Users/me/AppData/Roaming/netcatty/netcatty-tool-cli/*"), true);
assert.equal(patterns.includes("C:/Users/me/AppData/Local/Temp/Netcatty/*"), true);
assert.equal(patterns.includes("C:/Users/me/AppData/Local/Programs/Netcatty/*"), true);
});
test("buildOpenCodeSkillsPermissionRules allowlists Netcatty CLI paths and denies other external access", () => {
const rules = buildOpenCodeSkillsPermissionRules([
"/Applications/Netcatty.app/Contents/MacOS/**",
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/**",
]);
assert.equal(rules.bash, "allow");
assert.equal(rules.skill, "allow");
assert.equal(rules.list, "deny");
assert.equal(rules.external_directory["*"], "deny");
assert.equal(rules.external_directory["/Applications/Netcatty.app/Contents/MacOS/**"], "allow");
assert.equal(rules.external_directory["/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/**"], "allow");
assert.equal(rules.read["/Applications/Netcatty.app/Contents/MacOS/**"], "allow");
assert.equal(rules.read["/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/**"], "allow");
assert.equal(rules.read["*"], undefined);
// Allowlist entries must come after the catch-all deny so OpenCode's
// last-matching-rule-wins evaluation keeps them effective.
assert.equal(Object.keys(rules.external_directory)[0], "*");
});
test("buildOpenCodeNativeSkillsPermissionRules keeps OpenCode native skill dirs readable", () => {
const rules = buildOpenCodeNativeSkillsPermissionRules();
assert.equal(rules.skill, "allow");
assert.equal(rules.external_directory["*"], "deny");
for (const pattern of buildOpenCodeNativeSkillPermissionPatterns()) {
assert.equal(rules.external_directory[pattern], "allow");
assert.equal(rules.read[pattern], "allow");
}
for (const pattern of buildOpenCodeNativeSkillEnvDenyPatterns()) {
assert.equal(rules.read[pattern], "deny");
}
});
test("native skill read rules re-deny dot-env files inside skill dirs (last match wins)", () => {
const { read } = buildOpenCodeNativeSkillsPermissionRules();
// Regular skill files stay allowed.
assert.equal(evaluateOpenCodeRuleMap("../../.opencode/skills/foo/references/doc.md", read), "allow");
assert.equal(evaluateOpenCodeRuleMap("C:/Users/me/.config/opencode/skills/foo/SKILL.md", read), "allow");
// Dot-env secret files under skill dirs must not be silently readable.
assert.equal(evaluateOpenCodeRuleMap("../../.opencode/skills/foo/.env", read), "deny");
assert.equal(evaluateOpenCodeRuleMap("C:/Users/me/.config/opencode/skills/foo/.env", read), "deny");
assert.equal(evaluateOpenCodeRuleMap("/home/me/.claude/skills/foo/.env.local", read), "deny");
assert.equal(evaluateOpenCodeRuleMap("..\\..\\.agents\\skills\\foo\\references\\prod.env", read), "deny");
});
test("native skill patterns match OpenCode permission requests for skill files (issue #1939)", () => {
const patterns = buildOpenCodeNativeSkillPermissionPatterns();
// external_directory asks with an absolute parent-directory glob
// (forward slashes on Windows after FSUtil.normalizePathPattern).
assert.equal(matchesAnyPattern("C:/Users/me/.opencode/skills/my-skill/references/*", patterns), true);
assert.equal(matchesAnyPattern("/home/me/.config/opencode/skills/my-skill/*", patterns), true);
assert.equal(matchesAnyPattern("/Users/me/.claude/skills/my-skill/references/*", patterns), true);
assert.equal(matchesAnyPattern("/Users/me/.agents/skills/my-skill/*", patterns), true);
assert.equal(matchesAnyPattern("/Users/me/.cache/opencode/skills/abc123/my-skill/*", patterns), true);
// read asks with a worktree-relative path (Windows backslashes included).
assert.equal(matchesAnyPattern("..\\..\\.opencode\\skills\\my-skill\\references\\doc.md", patterns), true);
assert.equal(matchesAnyPattern("../.config/opencode/skills/my-skill/SKILL.md", patterns), true);
assert.equal(matchesAnyPattern(".opencode/skills/my-skill/references/doc.md", patterns), true);
// unrelated external paths stay denied
assert.equal(matchesAnyPattern("C:/Users/me/Documents/secret.txt/*", patterns), false);
assert.equal(matchesAnyPattern("../../etc/passwd", patterns), false);
assert.equal(matchesAnyPattern("C:/Users/me/.ssh/id_rsa", patterns), false);
});

View File

@@ -0,0 +1,946 @@
"use strict";
const net = require("node:net");
const fs = require("node:fs");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
const {
buildOpenCodeNativeSkillsPermissionRules,
buildOpenCodeSkillsPermissionRules,
} = require("./netcattySkillsOpenCodePermissions.cjs");
const OPENCODE_IMAGE_MEDIA_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
const DEFAULT_OPENCODE_PORT = 4096;
function resolveUsableOpenCodeBinPath(binPath, env) {
const candidates = [];
if (binPath) candidates.push(String(binPath));
if (env?.OPENCODE_BIN) candidates.push(String(env.OPENCODE_BIN));
for (const candidate of candidates) {
try {
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
return candidate;
}
} catch {}
}
return undefined;
}
function isOpenCodeImageAttachment(attachment) {
return Boolean(
attachment &&
OPENCODE_IMAGE_MEDIA_TYPES.has(String(attachment.mediaType || "").toLowerCase()) &&
attachment.filePath,
);
}
function parseOpenCodeModel(model) {
const raw = String(model || "").trim();
const slash = raw.indexOf("/");
if (slash <= 0 || slash === raw.length - 1) return undefined;
return {
providerID: raw.slice(0, slash),
modelID: raw.slice(slash + 1),
};
}
function toOpenCodeMcpConfig(injectedMcpServers) {
const mcp = {};
for (const cfg of injectedMcpServers || []) {
if (!cfg || !cfg.name) continue;
mcp[cfg.name] = {
type: "local",
command: [cfg.command, ...(cfg.args || [])],
environment: mcpEnvPairsToObject(cfg.env),
enabled: true,
};
}
return mcp;
}
function buildOpenCodeConfig({ model, injectedMcpServers, toolIntegrationMode, skillsPathAllowlist } = {}) {
const allowBash = toolIntegrationMode === "skills";
const permission = {
edit: "deny",
bash: allowBash ? "allow" : "deny",
webfetch: "deny",
// Netcatty does not yet bridge OpenCode's question reply API to the UI.
// Leaving it enabled creates a tool call that can never be completed.
question: "deny",
// Keep external access locked down, but let OpenCode's native skills
// (e.g. ~/.opencode/skills, ~/.config/opencode/skills) read their own
// reference files in every mode (issue #1939).
...buildOpenCodeNativeSkillsPermissionRules(),
};
if (allowBash && Array.isArray(skillsPathAllowlist) && skillsPathAllowlist.length > 0) {
Object.assign(permission, buildOpenCodeSkillsPermissionRules(skillsPathAllowlist));
}
const config = {
share: "disabled",
autoupdate: false,
permission,
mcp: toOpenCodeMcpConfig(injectedMcpServers),
};
if (model) config.model = model;
return config;
}
function buildOpenCodePromptParts(prompt, attachments) {
const parts = [{ type: "text", text: String(prompt || "") }];
for (const attachment of Array.isArray(attachments) ? attachments : []) {
if (!isOpenCodeImageAttachment(attachment)) continue;
parts.push({
type: "file",
mime: String(attachment.mediaType).toLowerCase(),
filename: attachment.filename,
url: pathToFileURL(attachment.filePath).href,
});
}
return parts;
}
function extractOpenCodeErrorMessage(error) {
if (!error) return "";
if (typeof error === "string") return error;
return String(
error.data?.message ||
error.message ||
error.name ||
"",
);
}
function getOpenCodeResultError(result) {
if (!result || typeof result !== "object") return null;
return result.error || null;
}
function getOpenCodeEventPayload(event) {
if (event?.payload && typeof event.payload === "object") return event.payload;
if (event?.type && event?.properties) return event;
return null;
}
function getOpenCodeSessionIdFromEvent(event) {
const properties = getOpenCodeEventPayload(event)?.properties;
return properties?.sessionID
|| properties?.sessionId
|| properties?.part?.sessionID
|| properties?.part?.sessionId
|| properties?.info?.sessionID
|| properties?.info?.sessionId
|| properties?.info?.id
|| null;
}
function getOpenCodePartId(part) {
return part?.id || part?.partID || part?.partId || null;
}
function rememberOpenCodePartType(state, part) {
const partId = getOpenCodePartId(part);
if (!partId || !part?.type) return;
state.partTypes = state.partTypes || new Map();
state.partTypes.set(partId, part.type);
}
function rememberOpenCodeMessageRole(state, info) {
if (!info || typeof info !== "object") return;
const messageId = info.id;
const role = info.role;
if (!messageId || !role) return;
state.messageRoles = state.messageRoles || new Map();
state.messageRoles.set(messageId, role);
}
function getOpenCodeMessageId(source) {
if (!source || typeof source !== "object") return null;
return source.messageID
|| source.messageId
|| source.part?.messageID
|| source.part?.messageId
|| null;
}
function shouldEmitOpenCodeAssistantPart(state, source) {
const messageId = getOpenCodeMessageId(source);
if (!messageId) return true;
const role = state.messageRoles?.get(messageId);
if (!role) return true;
return role === "assistant";
}
function forgetOpenCodeMessageRole(state, messageId) {
if (!messageId) return;
state.messageRoles?.delete(messageId);
}
function getOpenCodeDeltaKind(properties, state) {
const partId = properties?.partID || properties?.partId || null;
const knownType = partId && state.partTypes?.get(partId);
if (knownType === "reasoning" || knownType === "text") return knownType;
const field = String(properties?.field || "").toLowerCase();
if (field.includes("reason") || field.includes("thinking")) return "reasoning";
if (field === "text" || field === "content" || field.endsWith(".text") || field.endsWith(".content")) return "text";
return null;
}
function emitOpenCodePartChunk({ emitter, state, partId, kind, text, isDelta }) {
if (typeof text !== "string" || text.length === 0) return false;
let chunk = text;
if (partId) {
state.partOffsets = state.partOffsets || new Map();
const emittedLength = state.partOffsets.get(partId) || 0;
if (isDelta) {
state.partOffsets.set(partId, emittedLength + text.length);
} else {
chunk = text.slice(emittedLength);
state.partOffsets.set(partId, Math.max(emittedLength, text.length));
}
}
if (!chunk) return false;
if (kind === "reasoning") {
emitter.reasoning(chunk);
state.reasoningOpen = true;
} else {
emitter.text(chunk);
}
return true;
}
function translateOpenCodeEvent(event, emitter, state = {}) {
const payload = getOpenCodeEventPayload(event);
if (!payload || typeof payload !== "object") return { idle: false, error: false, content: false };
if (payload.type === "message.updated") {
rememberOpenCodeMessageRole(state, payload.properties?.info);
return { idle: false, error: false, content: false };
}
if (payload.type === "message.removed") {
forgetOpenCodeMessageRole(state, payload.properties?.messageID || payload.properties?.messageId);
return { idle: false, error: false, content: false };
}
if (payload.type === "message.part.updated") {
const part = payload.properties?.part;
if (!part || typeof part !== "object") return { idle: false, error: false, content: false };
if (!shouldEmitOpenCodeAssistantPart(state, part)) {
return { idle: false, error: false, content: false };
}
rememberOpenCodePartType(state, part);
if (part.type === "text") {
const delta = payload.properties?.delta;
if (emitOpenCodePartChunk({
emitter,
state,
partId: getOpenCodePartId(part),
kind: "text",
text: typeof delta === "string" ? delta : part.text,
isDelta: typeof delta === "string",
})) {
return { idle: false, error: false, content: true };
}
return { idle: false, error: false, content: false };
}
if (part.type === "reasoning") {
const delta = payload.properties?.delta;
if (emitOpenCodePartChunk({
emitter,
state,
partId: getOpenCodePartId(part),
kind: "reasoning",
text: typeof delta === "string" ? delta : part.text,
isDelta: typeof delta === "string",
})) {
return { idle: false, error: false, content: true };
}
return { idle: false, error: false, content: false };
}
if (part.type === "tool") {
if (state.reasoningOpen) {
emitter.reasoningEnd?.();
state.reasoningOpen = false;
}
const callId = part.callID || part.id || "";
const toolName = part.tool || "tool";
const input = part.state?.input || {};
if (part.state?.status === "running" || part.state?.status === "pending") {
state.toolCalls = state.toolCalls || new Set();
if (!state.toolCalls.has(callId)) {
state.toolCalls.add(callId);
emitter.toolCall(toolName, input, callId);
}
} else if (part.state?.status === "completed") {
state.toolCalls = state.toolCalls || new Set();
if (!state.toolCalls.has(callId)) {
state.toolCalls.add(callId);
emitter.toolCall(toolName, input, callId);
}
state.toolResults = state.toolResults || new Set();
if (!state.toolResults.has(callId)) {
state.toolResults.add(callId);
emitter.toolResult(callId, part.state.output || "", toolName);
}
} else if (part.state?.status === "error") {
// Tool-level failures must not abort the whole OpenCode turn. Other
// drivers (Cursor / Codex / Grok) surface tool errors as tool results
// so the model can adapt and continue multi-step work (issue #2718).
state.toolCalls = state.toolCalls || new Set();
if (!state.toolCalls.has(callId)) {
state.toolCalls.add(callId);
emitter.toolCall(toolName, input, callId);
}
state.toolResults = state.toolResults || new Set();
if (!state.toolResults.has(callId)) {
state.toolResults.add(callId);
// Prefer non-empty error, then output, then a stable default (blank
// string error must not hide a useful output payload).
const rawError = part.state.error || part.state.output || "OpenCode tool failed";
const errorText = typeof rawError === "string"
? rawError
: (extractOpenCodeErrorMessage(rawError) || "OpenCode tool failed");
emitter.toolResult(callId, errorText, toolName);
}
return { idle: false, error: false, content: true };
}
}
return { idle: false, error: false, content: part.type === "tool" };
}
if (payload.type === "message.part.delta") {
const properties = payload.properties || {};
if (!shouldEmitOpenCodeAssistantPart(state, properties)) {
return { idle: false, error: false, content: false };
}
const delta = typeof properties.delta === "string" ? properties.delta : "";
const kind = getOpenCodeDeltaKind(properties, state);
if (!delta || !kind) return { idle: false, error: false, content: false };
if (emitOpenCodePartChunk({
emitter,
state,
partId: properties.partID || properties.partId || null,
kind,
text: delta,
isDelta: true,
})) {
return { idle: false, error: false, content: true };
}
return { idle: false, error: false, content: false };
}
if (payload.type === "session.error") {
emitter.emitError(extractOpenCodeErrorMessage(payload.properties?.error) || "OpenCode session failed");
return { idle: false, error: true, content: false };
}
if (payload.type === "session.idle") {
if (state.reasoningOpen) {
emitter.reasoningEnd?.();
state.reasoningOpen = false;
}
emitter.status("OpenCode session idle");
return { idle: true, error: false, content: false };
}
if (payload.type === "session.status" && payload.properties?.status?.type) {
emitter.status(`OpenCode session ${payload.properties.status.type}`);
}
return { idle: false, error: false, content: false };
}
function classifyOpenCodeSpawnError(error) {
const code = error && error.code;
const msg = String((error && error.message) || error || "");
return {
isSpawnEnoent: code === "ENOENT" || /ENOENT/i.test(msg) || /not found/i.test(msg),
message: msg,
};
}
function shellQuotePosix(value) {
return `"${String(value).replace(/(["\\$`])/g, "\\$1")}"`;
}
function createOpenCodeShim(binPath, options = {}) {
if (!binPath) return null;
const platform = options.platform || process.platform;
const tempDirBridge = options.tempDirBridge || require("../../tempDirBridge.cjs");
const getTempFilePath = options.getTempFilePath || tempDirBridge.getTempFilePath;
const shimParent = getTempFilePath("opencode-sdk-shim");
const uniqueId = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const shimRoot = path.join(shimParent, uniqueId);
fs.mkdirSync(shimRoot, { recursive: true });
const shimName = platform === "win32" ? "opencode.cmd" : "opencode";
const shimPath = path.join(shimRoot, shimName);
if (platform === "win32") {
fs.writeFileSync(shimPath, `@echo off\r\n"${binPath}" %*\r\n`);
} else {
fs.writeFileSync(shimPath, `#!/bin/sh\nexec ${shellQuotePosix(binPath)} "$@"\n`);
fs.chmodSync(shimPath, 0o755);
}
return {
dir: shimRoot,
path: shimPath,
cleanup() {
try { fs.rmSync(shimRoot, { recursive: true, force: true }); } catch {}
try { fs.rmdirSync(shimParent); } catch {}
},
};
}
function createOpenCodeProcessEnv(env, binPath, options = {}) {
const next = { ...(env || {}) };
let shim = null;
const explicitBinPath = binPath ? resolveUsableOpenCodeBinPath(binPath, null) : undefined;
const envBinPath = explicitBinPath ? undefined : resolveUsableOpenCodeBinPath(null, next);
if (explicitBinPath) {
shim = createOpenCodeShim(explicitBinPath, options);
next.OPENCODE_BIN = explicitBinPath;
next.PATH = [shim?.dir || path.dirname(explicitBinPath), next.PATH || process.env.PATH || ""]
.filter(Boolean)
.join(path.delimiter);
} else if (envBinPath) {
next.OPENCODE_BIN = envBinPath;
} else if (binPath || next.OPENCODE_BIN) {
delete next.OPENCODE_BIN;
}
return {
env: next,
cleanup() {
shim?.cleanup?.();
},
};
}
function withOpenCodeProcessEnv(env, binPath, fn) {
const previous = {};
const { env: next, cleanup } = createOpenCodeProcessEnv(env, binPath);
const restore = () => {
for (const key of Object.keys(next)) {
if (previous[key] === undefined) delete process.env[key];
else process.env[key] = previous[key];
}
cleanup();
};
for (const [key, value] of Object.entries(next)) {
previous[key] = process.env[key];
process.env[key] = String(value);
}
try {
return fn();
} catch (error) {
throw error;
} finally {
restore();
}
}
function getAvailablePort(host = "127.0.0.1") {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, host, () => {
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
server.close((error) => {
if (error) reject(error);
else resolve(port === DEFAULT_OPENCODE_PORT ? getAvailablePort(host) : port);
});
});
});
}
async function withOpenCodeServerPort(options = {}) {
if (options.port != null) return options;
return { ...options, port: await getAvailablePort(options.hostname || "127.0.0.1") };
}
function closeOpenCodeInstance(opencode) {
try { opencode?.server?.close?.(); } catch {}
}
async function createDefaultOpenCode(options, env, binPath) {
let sdk;
try { sdk = await import("@opencode-ai/sdk"); } catch {
throw new Error("OpenCode SDK not installed. Run: npm install @opencode-ai/sdk");
}
const { env: nextEnv, cleanup: cleanupShim } = createOpenCodeProcessEnv(env, binPath);
const previous = {};
for (const [key, value] of Object.entries(nextEnv)) {
previous[key] = process.env[key];
process.env[key] = String(value);
}
// Restore the Electron main-process environment as soon as the child has been
// spawned. Keeping PATH/OPENCODE_BIN pointed at a temporary shim for the
// server lifetime (or list-models idle window) can leak into later turns and
// other spawns; see #2184 review. The on-disk shim stays until close() so a
// still-running child that re-resolves helpers does not race a deleted path.
const restoreProcessEnv = () => {
if (restoreProcessEnv.done) return;
restoreProcessEnv.done = true;
for (const key of Object.keys(nextEnv)) {
if (previous[key] === undefined) delete process.env[key];
else process.env[key] = previous[key];
}
};
const cleanup = () => {
if (cleanup.done) return;
cleanup.done = true;
restoreProcessEnv();
cleanupShim();
};
try {
const opencode = await sdk.createOpencode(options);
restoreProcessEnv();
const originalClose = opencode.server?.close?.bind(opencode.server);
if (typeof originalClose === "function") {
opencode.server.close = () => {
try { originalClose(); } catch {}
cleanup();
};
} else {
cleanup();
}
return opencode;
} catch (error) {
cleanup();
throw error;
}
}
function createAbortWait(signal) {
if (!signal) return { promise: new Promise(() => {}), dispose() {} };
if (signal.aborted) return { promise: Promise.resolve(), dispose() {} };
let resolveAbort;
const promise = new Promise((resolve) => { resolveAbort = resolve; });
const onAbort = () => resolveAbort();
signal.addEventListener("abort", onAbort, { once: true });
return {
promise,
dispose() {
signal.removeEventListener("abort", onAbort);
},
};
}
function createStopWait() {
let stopped = false;
let resolveStop;
const promise = new Promise((resolve) => { resolveStop = resolve; });
return {
promise,
get stopped() { return stopped; },
stop() {
if (stopped) return;
stopped = true;
resolveStop();
},
};
}
async function runOpenCodeTurn({
prompt, systemPrompt, attachments, cwd, model, injectedMcpServers, toolIntegrationMode,
skillsPathAllowlist, resumeSessionId, env, binPath, emitter, abortController, openCodeFactory,
}) {
const config = buildOpenCodeConfig({ model, injectedMcpServers, toolIntegrationMode, skillsPathAllowlist });
let opencode = null;
let sessionId = resumeSessionId || null;
let hasContent = false;
let failed = false;
let abortSent = false;
let removeAbortListener = null;
const state = { reasoningOpen: false };
const directoryQuery = cwd ? { directory: cwd } : undefined;
try {
const factory = openCodeFactory || ((options) => createDefaultOpenCode(options, env, binPath));
opencode = await factory(await withOpenCodeServerPort({ config, signal: abortController?.signal }));
const { client } = opencode;
const abortOpenCode = async () => {
if (abortSent) return;
abortSent = true;
if (sessionId) {
try { await client.session.abort({ path: { id: sessionId }, query: directoryQuery }); } catch {}
}
try { opencode?.server?.close?.(); } catch {}
};
if (abortController?.signal) {
const onAbort = () => { void abortOpenCode(); };
abortController.signal.addEventListener("abort", onAbort, { once: true });
removeAbortListener = () => abortController.signal.removeEventListener("abort", onAbort);
}
const events = await client.global.event({ signal: abortController?.signal });
if (!sessionId) {
const created = await client.session.create({
body: { title: "Netcatty OpenCode" },
query: directoryQuery,
});
sessionId = created?.data?.id || created?.id || null;
}
if (!sessionId) throw new Error("OpenCode did not create a session");
emitter.sessionId(sessionId);
const stopEventLoopWait = createStopWait();
const eventLoop = (async () => {
const iterator = events.stream?.[Symbol.asyncIterator]?.();
if (!iterator) return;
const abortWait = createAbortWait(abortController?.signal);
try {
while (true) {
const nextEvent = iterator.next();
const raced = await Promise.race([
nextEvent.then(
(value) => ({ type: "event", value }),
(error) => ({ type: "error", error }),
),
abortWait.promise.then(() => ({ type: "abort" })),
stopEventLoopWait.promise.then(() => ({ type: "stop" })),
]);
if (raced.type === "abort") break;
if (raced.type === "stop") break;
if (raced.type === "error") throw raced.error;
const { value: event, done } = raced.value;
if (done) break;
if (abortController?.signal?.aborted) break;
const eventSessionId = getOpenCodeSessionIdFromEvent(event);
if (eventSessionId && eventSessionId !== sessionId) continue;
const result = translateOpenCodeEvent(event, emitter, state);
if (result.content) hasContent = true;
if (result.error) {
failed = true;
break;
}
if (result.idle) break;
}
} finally {
abortWait.dispose();
if (abortController?.signal?.aborted || stopEventLoopWait.stopped) {
try { void iterator.return?.(); } catch {}
}
}
})();
const body = {
parts: buildOpenCodePromptParts(prompt, attachments),
};
if (systemPrompt) body.system = String(systemPrompt);
const parsedModel = parseOpenCodeModel(model);
if (parsedModel) body.model = parsedModel;
const promptAbortWait = createAbortWait(abortController?.signal);
const promptResult = await Promise.race([
client.session.promptAsync({
path: { id: sessionId },
query: directoryQuery,
body,
signal: abortController?.signal,
throwOnError: true,
}).then(
(result) => {
const error = getOpenCodeResultError(result);
return error ? { type: "error", error } : { type: "prompt" };
},
(error) => ({ type: "error", error }),
),
promptAbortWait.promise.then(() => ({ type: "abort" })),
]);
promptAbortWait.dispose();
if (promptResult.type === "error") {
failed = true;
await abortOpenCode();
stopEventLoopWait.stop();
await eventLoop.catch(() => {});
throw promptResult.error;
}
if (promptResult.type === "abort") {
await abortOpenCode();
} else {
await eventLoop;
}
if (abortController?.signal?.aborted) {
await abortOpenCode();
}
if (!hasContent && !failed && !abortController?.signal?.aborted) {
emitter.emitError("OpenCode returned an empty response. Run `opencode` in a terminal to configure authentication and models.");
return { sessionId };
}
if (!failed && !abortController?.signal?.aborted) emitter.emitDone();
return { sessionId };
} catch (error) {
const classified = classifyOpenCodeSpawnError(error);
if (classified.isSpawnEnoent) {
emitter.emitError("OpenCode CLI not found or not runnable. Install OpenCode and ensure `opencode` is on PATH, or set a custom path in Settings.");
} else {
emitter.emitError(extractOpenCodeErrorMessage(error) || classified.message || "OpenCode turn failed");
}
return { sessionId };
} finally {
removeAbortListener?.();
closeOpenCodeInstance(opencode);
}
}
function mapOpenCodeModels(response) {
const providers = Array.isArray(response?.providers) ? response.providers : [];
const models = [];
for (const provider of providers) {
const providerId = provider?.id || provider?.providerID;
if (!providerId || !provider?.models || typeof provider.models !== "object") continue;
for (const [modelId, info] of Object.entries(provider.models)) {
models.push({
id: `${providerId}/${modelId}`,
name: `${provider.name || providerId} ${info?.name || modelId}`,
});
}
}
return models;
}
function getOpenCodeDefaultModelId(response) {
const value = response?.default;
if (!value) return null;
if (typeof value === "string") return value.includes("/") ? value : null;
if (typeof value !== "object") return null;
if (typeof value.model === "string" && value.model.includes("/")) return value.model;
if (typeof value.providerID === "string" && typeof value.modelID === "string") {
return `${value.providerID}/${value.modelID}`;
}
if (typeof value.provider === "string" && typeof value.model === "string") {
return `${value.provider}/${value.model}`;
}
for (const [providerId, modelId] of Object.entries(value)) {
if (typeof modelId === "string" && providerId && modelId) {
return modelId.includes("/") ? modelId : `${providerId}/${modelId}`;
}
if (modelId && typeof modelId === "object" && typeof modelId.modelID === "string") {
const nestedProvider = typeof modelId.providerID === "string" ? modelId.providerID : providerId;
return `${nestedProvider}/${modelId.modelID}`;
}
}
return null;
}
function emptyOpenCodeModelCatalog() {
return { currentModelId: null, models: [] };
}
function abortError(signal) {
return signal?.reason instanceof Error
? signal.reason
: new Error(String(signal?.reason || "aborted"));
}
function whenAborted(signal) {
if (!signal) return new Promise(() => {});
if (signal.aborted) return Promise.reject(abortError(signal));
return new Promise((_, reject) => {
signal.addEventListener("abort", () => reject(abortError(signal)), { once: true });
});
}
// Env vars that can change which OpenCode config / provider catalog is visible.
const OPENCODE_CATALOG_ENV_KEYS = [
"HOME",
"USERPROFILE",
"XDG_CONFIG_HOME",
"OPENCODE_BIN",
"OPENCODE_CONFIG",
"OPENCODE_CONFIG_DIR",
"OPENCODE_CONFIG_CONTENT",
];
function buildOpenCodeCatalogEnvFingerprint(env) {
return OPENCODE_CATALOG_ENV_KEYS
.map((key) => `${key}=${env?.[key] == null ? "" : String(env[key])}`)
.join("\u0000");
}
function buildOpenCodeListServerKey(binPath, env) {
const resolvedBin = String(
resolveUsableOpenCodeBinPath(binPath, env)
|| binPath
|| env?.OPENCODE_BIN
|| "default",
);
// Same binary + different HOME/XDG/OpenCode config must not share a catalog
// server or cache entry (multi-agent / multi-profile setups).
return `${resolvedBin}\u0000${buildOpenCodeCatalogEnvFingerprint(env)}`;
}
// Shared list-models servers: coalesce concurrent catalog loads for the same
// binary, then tear down after a short idle so idle Netcatty does not keep
// opencode processes around (issue #2184).
const OPENCODE_LIST_SERVER_IDLE_MS = 1500;
const openCodeListServers = new Map();
function clearOpenCodeListServerIdle(entry) {
if (!entry?.idleTimer) return;
clearTimeout(entry.idleTimer);
entry.idleTimer = null;
}
function disposeOpenCodeListServer(key, entry) {
const current = openCodeListServers.get(key);
if (current && current !== entry) return;
openCodeListServers.delete(key);
clearOpenCodeListServerIdle(entry);
try { entry?.createAbort?.abort?.(); } catch {}
closeOpenCodeInstance(entry?.opencode);
entry.opencode = null;
}
function releaseOpenCodeListServer(key) {
const entry = openCodeListServers.get(key);
if (!entry) return;
entry.refs = Math.max(0, (entry.refs || 0) - 1);
if (entry.refs > 0) return;
// Create still in flight with no waiters: abort so the SDK kills the child.
if (!entry.opencode && entry.createAbort && !entry.createAbort.signal.aborted) {
try { entry.createAbort.abort(); } catch {}
disposeOpenCodeListServer(key, entry);
return;
}
clearOpenCodeListServerIdle(entry);
entry.idleTimer = setTimeout(() => {
const current = openCodeListServers.get(key);
if (!current || current !== entry || current.refs > 0) return;
disposeOpenCodeListServer(key, entry);
}, OPENCODE_LIST_SERVER_IDLE_MS);
if (typeof entry.idleTimer.unref === "function") entry.idleTimer.unref();
}
async function acquireOpenCodeListServer({ env, binPath, openCodeFactory, signal } = {}) {
if (signal?.aborted) throw abortError(signal);
const key = buildOpenCodeListServerKey(binPath, env);
let entry = openCodeListServers.get(key);
if (entry) {
clearOpenCodeListServerIdle(entry);
} else {
const createAbort = new AbortController();
entry = {
key,
refs: 0,
opencode: null,
ready: null,
idleTimer: null,
createAbort,
};
const factory = openCodeFactory || ((options) => createDefaultOpenCode(options, env, binPath));
entry.ready = (async () => {
const options = await withOpenCodeServerPort({
config: { autoupdate: false },
timeout: 10000,
signal: createAbort.signal,
});
const opencode = await factory(options);
// If the last waiter cancelled while create was finishing, kill immediately
// so the process cannot leak outside the pool map.
if (createAbort.signal.aborted) {
closeOpenCodeInstance(opencode);
throw abortError(createAbort.signal);
}
entry.opencode = opencode;
return opencode;
})().catch((error) => {
// Drop a failed create immediately so the next list-models can retry.
disposeOpenCodeListServer(key, entry);
throw error;
});
openCodeListServers.set(key, entry);
}
entry.refs += 1;
try {
const opencode = await Promise.race([
entry.ready,
whenAborted(signal),
]);
if (signal?.aborted) throw abortError(signal);
return { key, opencode };
} catch (error) {
entry.refs = Math.max(0, entry.refs - 1);
if (entry.refs <= 0) {
// Last waiter left before ready: abort spawn so the SDK child is killed.
try { entry.createAbort?.abort?.(); } catch {}
disposeOpenCodeListServer(key, entry);
}
throw error;
}
}
function resetOpenCodeListServerPool() {
for (const [key, entry] of openCodeListServers.entries()) {
disposeOpenCodeListServer(key, entry);
}
openCodeListServers.clear();
}
async function listOpenCodeModels({ env, binPath, openCodeFactory, abortController, signal } = {}) {
const effectiveSignal = signal || abortController?.signal;
let acquired = null;
try {
if (effectiveSignal?.aborted) return emptyOpenCodeModelCatalog();
acquired = await acquireOpenCodeListServer({
env,
binPath,
openCodeFactory,
signal: effectiveSignal,
});
if (effectiveSignal?.aborted) return emptyOpenCodeModelCatalog();
const response = await Promise.race([
acquired.opencode.client.config.providers(),
whenAborted(effectiveSignal),
]);
if (response?.error) {
throw new Error(extractOpenCodeErrorMessage(response.error) || "OpenCode providers unavailable");
}
const data = response?.data || response;
return {
currentModelId: getOpenCodeDefaultModelId(data),
models: mapOpenCodeModels(data),
};
} catch {
return emptyOpenCodeModelCatalog();
} finally {
if (acquired) releaseOpenCodeListServer(acquired.key);
}
}
module.exports = {
buildOpenCodeConfig,
buildOpenCodePromptParts,
classifyOpenCodeSpawnError,
closeOpenCodeInstance,
createOpenCodeProcessEnv,
withOpenCodeProcessEnv,
listOpenCodeModels,
mapOpenCodeModels,
parseOpenCodeModel,
resolveUsableOpenCodeBinPath,
resetOpenCodeListServerPool,
runOpenCodeTurn,
toOpenCodeMcpConfig,
translateOpenCodeEvent,
OPENCODE_LIST_SERVER_IDLE_MS,
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,874 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
registerSdkStreamHandlers,
buildSdkTurnPrompt,
formatSdkHistoryReplaySection,
buildSdkModelCacheKey,
getSdkModelCacheEntry,
setSdkModelCacheEntry,
buildSdkSessionKey,
normalizeSdkListModelsResult,
resolveSdkPromptPlacement,
resolveSdkResumeSessionId,
shouldReplaySdkHistory,
expireSiblingCursorCliModeSessions,
expireSiblingGrokRuntimeSessions,
resolveBackendKey,
resolveSdkBackendBinPath,
shouldCacheSdkRuntimeModels,
} = require("./sdkStreamHandlers.cjs");
/**
* Register the real IPC handlers against a stubbed ctx so lifecycle handlers
* (cleanup) can be invoked directly. registerSdkStreamHandlers exposes its
* request-scoped maps on ctx for exactly this kind of test.
*/
function registerWithStubbedCtx() {
const handlers = new Map();
const ctx = {
ipcMain: { handle: (channel, fn) => handlers.set(channel, fn) },
electronModule: undefined,
validateSender: () => true,
mcpServerBridge: {
setChatSessionCancelled: () => {},
cancelPtyExecsForSession: () => {},
cancelWorkerBackgroundJobsForSession: () => {},
cleanupScopedMetadata: async () => {},
},
};
registerSdkStreamHandlers(ctx);
return { handlers, ctx };
}
test("sdk-agent:cleanup aborts and removes request entries for the target chat only", async () => {
const { handlers, ctx } = registerWithStubbedCtx();
const targetController = new AbortController();
const otherController = new AbortController();
ctx.sdkActiveStreams.set("req-1", targetController);
ctx.sdkRequestSessions.set("req-1", "chat-1");
ctx.sdkRequestRuntimes.set("req-1", { backendKey: "codebuddy", codexRuntime: "sdk", binPath: "/bin/cb" });
ctx.sdkActiveStreams.set("req-2", otherController);
ctx.sdkRequestSessions.set("req-2", "chat-2");
ctx.sdkRequestRuntimes.set("req-2", { backendKey: "codex", codexRuntime: "sdk", binPath: "/bin/codex" });
const cleanup = handlers.get("netcatty:ai:sdk-agent:cleanup");
assert.equal(typeof cleanup, "function");
const result = await cleanup({ sender: {} }, { chatSessionId: "chat-1" });
assert.deepEqual(result, { ok: true });
// Target chat: controller aborted and every request-scoped entry removed.
assert.ok(targetController.signal.aborted);
assert.ok(!ctx.sdkActiveStreams.has("req-1"));
assert.ok(!ctx.sdkRequestSessions.has("req-1"));
assert.ok(!ctx.sdkRequestRuntimes.has("req-1"));
// Other chat: untouched.
assert.ok(!otherController.signal.aborted);
assert.equal(ctx.sdkActiveStreams.get("req-2"), otherController);
assert.equal(ctx.sdkRequestSessions.get("req-2"), "chat-2");
assert.deepEqual(ctx.sdkRequestRuntimes.get("req-2"), {
backendKey: "codex",
codexRuntime: "sdk",
binPath: "/bin/codex",
});
});
test("resolveBackendKey maps backend command/value to registry key", () => {
assert.equal(resolveBackendKey("claude"), "claude");
assert.equal(resolveBackendKey("codex"), "codex");
assert.equal(resolveBackendKey("copilot"), "copilot");
assert.equal(resolveBackendKey("codebuddy"), "codebuddy");
assert.equal(resolveBackendKey("opencode"), "opencode");
});
test("resolveBackendKey returns null for unknown", () => {
assert.equal(resolveBackendKey("claude-agent-acp"), null);
assert.equal(resolveBackendKey(""), null);
assert.equal(resolveBackendKey(undefined), null);
});
test("SDK session keys include backend and resolved CLI path", () => {
assert.notEqual(
buildSdkSessionKey("chat-1", "codex", "/usr/local/bin/codex"),
buildSdkSessionKey("chat-1", "codex", "/opt/homebrew/bin/codex"),
);
assert.notEqual(
buildSdkSessionKey("chat-1", "codex", "/usr/local/bin/codex"),
buildSdkSessionKey("chat-1", "claude", "/usr/local/bin/codex"),
);
});
test("Cursor session keys isolate CLI login from API key auth modes", () => {
assert.notEqual(
buildSdkSessionKey("chat-1", "cursor", "/usr/bin/agent", "sdk", "cli-login"),
buildSdkSessionKey("chat-1", "cursor", "cursor", "sdk", "api-key"),
);
});
test("SDK model cache keys include resolved CLI path", () => {
assert.notEqual(
buildSdkModelCacheKey("claude", "/usr/local/bin/claude"),
buildSdkModelCacheKey("claude", "/opt/homebrew/bin/claude"),
);
});
test("SDK model cache keys include catalog-affecting agent environment", () => {
assert.notEqual(
buildSdkModelCacheKey("opencode", "/usr/bin/opencode", { HOME: "/Users/a", OPENCODE_CONFIG_DIR: "/a/config" }),
buildSdkModelCacheKey("opencode", "/usr/bin/opencode", { HOME: "/Users/b", OPENCODE_CONFIG_DIR: "/b/config" }),
);
assert.equal(
buildSdkModelCacheKey("opencode", "/usr/bin/opencode", { HOME: "/Users/a" }),
buildSdkModelCacheKey("opencode", "/usr/bin/opencode", { HOME: "/Users/a" }),
);
assert.doesNotMatch(
buildSdkModelCacheKey("cursor", "/usr/bin/cursor", { CURSOR_API_KEY: "very-secret-key" }),
/very-secret-key/,
);
});
test("SDK model cache removes expired entries instead of retaining tombstones", () => {
const cache = new Map([
["expired", { at: 1, currentModelId: null, models: [{ id: "old" }] }],
["fresh", { at: 95, currentModelId: null, models: [{ id: "new" }] }],
]);
assert.equal(getSdkModelCacheEntry(cache, "expired", { now: 100, ttlMs: 10, maxEntries: 8 }), null);
assert.equal(cache.has("expired"), false);
assert.equal(getSdkModelCacheEntry(cache, "fresh", { now: 100, ttlMs: 10, maxEntries: 8 }).models[0].id, "new");
});
test("SDK model cache evicts the least recently used catalog at its hard limit", () => {
const cache = new Map();
setSdkModelCacheEntry(cache, "a", { at: 1, models: [{ id: "a" }] }, { now: 1, ttlMs: 100, maxEntries: 2 });
setSdkModelCacheEntry(cache, "b", { at: 2, models: [{ id: "b" }] }, { now: 2, ttlMs: 100, maxEntries: 2 });
assert.ok(getSdkModelCacheEntry(cache, "a", { now: 3, ttlMs: 100, maxEntries: 2 }));
setSdkModelCacheEntry(cache, "c", { at: 3, models: [{ id: "c" }] }, { now: 3, ttlMs: 100, maxEntries: 2 });
assert.deepEqual(Array.from(cache.keys()), ["a", "c"]);
});
test("normalizeSdkListModelsResult preserves current model ids from object results", () => {
assert.deepEqual(normalizeSdkListModelsResult({
currentModelId: "openai/gpt-5.1",
models: [{ id: "openai/gpt-5.1" }, null, { name: "missing-id" }],
}), {
currentModelId: "openai/gpt-5.1",
models: [{ id: "openai/gpt-5.1" }],
});
assert.deepEqual(normalizeSdkListModelsResult([{ id: "claude-sonnet" }]), {
currentModelId: null,
models: [{ id: "claude-sonnet" }],
});
});
test("CodeBuddy and OpenCode keep Netcatty context in the system prompt only", () => {
const input = {
turnPrompt: "user request",
contextualPrompt: "netcatty context\n\nuser request",
systemContext: "netcatty context",
};
assert.deepEqual(resolveSdkPromptPlacement({
...input,
backendKey: "codebuddy",
}), {
prompt: "user request",
systemPrompt: "netcatty context",
});
assert.deepEqual(resolveSdkPromptPlacement({
...input,
backendKey: "opencode",
}), {
prompt: "user request",
systemPrompt: "netcatty context",
});
assert.deepEqual(resolveSdkPromptPlacement({
...input,
backendKey: "claude",
}), {
prompt: "netcatty context\n\nuser request",
systemPrompt: undefined,
});
});
test("shouldCacheSdkRuntimeModels caches all SDK backends including OpenCode", () => {
// OpenCode used to skip the cache, which re-spawned opencode servers on every
// model-catalog probe (#2184). TTL still bounds staleness.
assert.equal(shouldCacheSdkRuntimeModels("opencode"), true);
assert.equal(shouldCacheSdkRuntimeModels("claude"), true);
assert.equal(shouldCacheSdkRuntimeModels("codebuddy"), true);
assert.equal(shouldCacheSdkRuntimeModels("copilot"), true);
});
test("SDK resume only uses the current backend/path session key", () => {
const sessions = new Map([
[buildSdkSessionKey("chat-1", "codex", "/old/codex"), "old-session"],
]);
assert.equal(
resolveSdkResumeSessionId({
sdkSessionIds: sessions,
sdkSessionKey: buildSdkSessionKey("chat-1", "codex", "/new/codex"),
backendKey: "codex",
binPath: "/new/codex",
hasConfiguredCommand: true,
}),
undefined,
);
sessions.set(buildSdkSessionKey("chat-1", "codex", "/new/codex"), "new-session");
assert.equal(
resolveSdkResumeSessionId({
sdkSessionIds: sessions,
sdkSessionKey: buildSdkSessionKey("chat-1", "codex", "/new/codex"),
backendKey: "codex",
binPath: "/new/codex",
hasConfiguredCommand: true,
}),
"new-session",
);
});
test("SDK resume uses persisted session identity only when backend and path match", () => {
const persisted = `netcatty-sdk-session:${encodeURIComponent(JSON.stringify({
v: 1,
id: "persisted-session",
backend: "codex",
binPath: "/opt/homebrew/bin/codex",
}))}`;
assert.equal(
resolveSdkResumeSessionId({
sdkSessionIds: new Map(),
sdkSessionKey: buildSdkSessionKey("chat-1", "codex", "/opt/homebrew/bin/codex"),
existingSessionId: persisted,
backendKey: "codex",
binPath: "/opt/homebrew/bin/codex",
hasConfiguredCommand: true,
}),
"persisted-session",
);
assert.equal(
resolveSdkResumeSessionId({
sdkSessionIds: new Map(),
sdkSessionKey: buildSdkSessionKey("chat-1", "codex", "/other/codex"),
existingSessionId: persisted,
backendKey: "codex",
binPath: "/other/codex",
hasConfiguredCommand: true,
}),
undefined,
);
});
test("Codex sessions never resume across SDK and App Server runtimes", () => {
const sdkIdentity = `netcatty-sdk-session:${encodeURIComponent(JSON.stringify({
v: 1,
id: "sdk-thread",
backend: "codex",
binPath: "/usr/bin/codex",
runtime: "sdk",
}))}`;
assert.equal(resolveSdkResumeSessionId({
sdkSessionIds: new Map(),
sdkSessionKey: buildSdkSessionKey("chat-1", "codex", "/usr/bin/codex", "app-server"),
existingSessionId: sdkIdentity,
backendKey: "codex",
binPath: "/usr/bin/codex",
runtime: "app-server",
hasConfiguredCommand: false,
}), undefined);
assert.equal(resolveSdkResumeSessionId({
sdkSessionIds: new Map(),
sdkSessionKey: buildSdkSessionKey("chat-1", "codex", "/usr/bin/codex", "app-server"),
existingSessionId: "legacy-thread",
backendKey: "codex",
binPath: "/usr/bin/codex",
runtime: "app-server",
hasConfiguredCommand: false,
}), undefined);
});
test("SDK resume keeps legacy session ids only when no manual command is configured", () => {
assert.equal(
resolveSdkResumeSessionId({
sdkSessionIds: new Map(),
sdkSessionKey: buildSdkSessionKey("chat-1", "codex", "/usr/bin/codex"),
existingSessionId: "legacy-session",
backendKey: "codex",
binPath: "/usr/bin/codex",
hasConfiguredCommand: false,
}),
"legacy-session",
);
assert.equal(
resolveSdkResumeSessionId({
sdkSessionIds: new Map(),
sdkSessionKey: buildSdkSessionKey("chat-1", "codex", "/manual/codex"),
existingSessionId: "legacy-session",
backendKey: "codex",
binPath: "/manual/codex",
hasConfiguredCommand: true,
}),
undefined,
);
});
test("Cursor CLI login sessions do not resume on the API key SDK path", () => {
const cliIdentity = `netcatty-sdk-session:${encodeURIComponent(JSON.stringify({
v: 1,
id: "61668441-bfcb-4795-a575-c46d70ad01fe",
backend: "cursor",
binPath: "/usr/bin/agent",
runtime: "sdk",
authMode: "cli-login",
cliMode: "agent",
}))}`;
assert.equal(
resolveSdkResumeSessionId({
sdkSessionIds: new Map(),
sdkSessionKey: buildSdkSessionKey("chat-1", "cursor", "cursor", "sdk", "api-key"),
existingSessionId: cliIdentity,
backendKey: "cursor",
binPath: "cursor",
runtime: "sdk",
authMode: "api-key",
hasConfiguredCommand: false,
}),
undefined,
);
assert.equal(
resolveSdkResumeSessionId({
sdkSessionIds: new Map(),
sdkSessionKey: buildSdkSessionKey("chat-1", "cursor", "/usr/bin/agent", "sdk", "cli-login", "agent"),
existingSessionId: cliIdentity,
backendKey: "cursor",
binPath: "/usr/bin/agent",
runtime: "sdk",
authMode: "cli-login",
cliMode: "agent",
hasConfiguredCommand: false,
}),
"61668441-bfcb-4795-a575-c46d70ad01fe",
);
assert.equal(
resolveSdkResumeSessionId({
sdkSessionIds: new Map(),
sdkSessionKey: buildSdkSessionKey("chat-1", "cursor", "/usr/bin/agent", "sdk", "cli-login", "ask"),
existingSessionId: cliIdentity,
backendKey: "cursor",
binPath: "/usr/bin/agent",
runtime: "sdk",
authMode: "cli-login",
cliMode: "ask",
hasConfiguredCommand: false,
}),
undefined,
);
assert.equal(
resolveSdkResumeSessionId({
sdkSessionIds: new Map(),
sdkSessionKey: buildSdkSessionKey("chat-1", "cursor", "cursor", "sdk", "cli-login"),
existingSessionId: "61668441-bfcb-4795-a575-c46d70ad01fe",
backendKey: "cursor",
binPath: "cursor",
runtime: "sdk",
authMode: "cli-login",
hasConfiguredCommand: false,
}),
undefined,
);
});
test("expireSiblingCursorCliModeSessions drops the inactive Cursor CLI mode", () => {
const askKey = buildSdkSessionKey("chat-1", "cursor", "/bin/cursor-agent", "sdk", "cli-login", "ask");
const agentKey = buildSdkSessionKey("chat-1", "cursor", "/bin/cursor-agent", "sdk", "cli-login", "agent");
const otherChatAskKey = buildSdkSessionKey("chat-2", "cursor", "/bin/cursor-agent", "sdk", "cli-login", "ask");
const sessions = new Map([
[askKey, "ask-session"],
[agentKey, "agent-session"],
[otherChatAskKey, "other-ask"],
]);
// Observer → Confirm: expire Ask so a later switch-back cannot revive it.
assert.equal(
expireSiblingCursorCliModeSessions(sessions, {
chatSessionId: "chat-1",
backendKey: "cursor",
binPath: "/bin/cursor-agent",
runtime: "sdk",
authMode: "cli-login",
cliMode: "agent",
}),
true,
);
assert.equal(sessions.has(askKey), false);
assert.equal(sessions.get(agentKey), "agent-session");
assert.equal(sessions.get(otherChatAskKey), "other-ask");
// Confirm → Observer: expire agent; Ask was already gone, so resume is fresh.
sessions.set(agentKey, "agent-session-2");
assert.equal(
expireSiblingCursorCliModeSessions(sessions, {
chatSessionId: "chat-1",
backendKey: "cursor",
binPath: "/bin/cursor-agent",
runtime: "sdk",
authMode: "cli-login",
cliMode: "ask",
}),
true,
);
assert.equal(sessions.has(agentKey), false);
assert.equal(
resolveSdkResumeSessionId({
sdkSessionIds: sessions,
sdkSessionKey: askKey,
existingSessionId: `netcatty-sdk-session:${encodeURIComponent(JSON.stringify({
v: 1,
id: "agent-session-2",
backend: "cursor",
binPath: "/bin/cursor-agent",
runtime: "sdk",
authMode: "cli-login",
cliMode: "agent",
}))}`,
backendKey: "cursor",
binPath: "/bin/cursor-agent",
runtime: "sdk",
authMode: "cli-login",
cliMode: "ask",
hasConfiguredCommand: false,
}),
undefined,
);
});
test("buildSdkTurnPrompt replays history only when requested", () => {
const prompt = buildSdkTurnPrompt({
prompt: "latest question",
replayHistory: true,
historyMessages: [
{ role: "user", content: "previous question" },
{ role: "assistant", content: "previous answer" },
],
});
assert.match(prompt, /Conversation context replay/);
assert.match(prompt, /USER: previous question/);
assert.match(prompt, /ASSISTANT: previous answer/);
assert.match(prompt, /latest question$/);
const steadyStatePrompt = buildSdkTurnPrompt({
prompt: "latest question",
replayHistory: false,
historyMessages: [{ role: "user", content: "previous question" }],
});
assert.equal(steadyStatePrompt, "latest question");
});
test("formatSdkHistoryReplaySection matches buildSdkTurnPrompt history wording", () => {
const messages = [
{ role: "user", content: "previous question" },
{ role: "assistant", content: "previous answer" },
];
const section = formatSdkHistoryReplaySection(messages);
assert.match(section, /Conversation context replay/);
assert.match(section, /USER: previous question/);
assert.match(section, /ASSISTANT: previous answer/);
// Same section is embedded when replayHistory is true.
const full = buildSdkTurnPrompt({
prompt: "latest",
replayHistory: true,
historyMessages: messages,
});
assert.ok(full.startsWith(section));
assert.equal(formatSdkHistoryReplaySection([]), "");
assert.equal(formatSdkHistoryReplaySection(undefined), "");
});
test("CodeBuddy does not replay renderer history when a persisted session can resume", () => {
assert.equal(shouldReplaySdkHistory({
backendKey: "codebuddy",
codexRuntime: "sdk",
resumeSessionId: "resumed-codebuddy",
hasInMemorySession: false,
}), false);
assert.equal(shouldReplaySdkHistory({
backendKey: "codebuddy",
codexRuntime: "sdk",
resumeSessionId: undefined,
hasInMemorySession: false,
}), true);
assert.equal(shouldReplaySdkHistory({
backendKey: "claude",
codexRuntime: "sdk",
resumeSessionId: "resumed-claude",
hasInMemorySession: false,
}), true);
});
test("Grok does not replay renderer history when an ACP session can resume", () => {
// Mirrors CodeBuddy: session/load / resume already restores Grok transcript.
// Applies to both ACP and streaming-json once a resume id is present.
assert.equal(shouldReplaySdkHistory({
backendKey: "grok",
codexRuntime: "sdk",
resumeSessionId: "resumed-grok",
hasInMemorySession: false,
}), false);
assert.equal(shouldReplaySdkHistory({
backendKey: "grok",
codexRuntime: "sdk",
resumeSessionId: undefined,
hasInMemorySession: false,
}), true);
// Even with an in-memory map miss, resume id alone must suppress replay.
assert.equal(shouldReplaySdkHistory({
backendKey: "grok",
codexRuntime: "sdk",
resumeSessionId: "s1",
hasInMemorySession: true,
}), false);
// First turn (no resume) still seeds context even if in-memory key exists.
assert.equal(shouldReplaySdkHistory({
backendKey: "grok",
codexRuntime: "sdk",
resumeSessionId: undefined,
hasInMemorySession: true,
}), true);
});
test("expireSiblingGrokRuntimeSessions drops the inactive Grok runtime", () => {
const acpKey = buildSdkSessionKey("chat-1", "grok", "/usr/bin/grok", "acp");
const headlessKey = buildSdkSessionKey("chat-1", "grok", "/usr/bin/grok", "streaming-json");
const otherChatAcpKey = buildSdkSessionKey("chat-2", "grok", "/usr/bin/grok", "acp");
const sessions = new Map([
[acpKey, "acp-session"],
[headlessKey, "json-session"],
[otherChatAcpKey, "other-acp"],
]);
// Switch to streaming-json: expire ACP so switch-back cannot revive it.
assert.equal(
expireSiblingGrokRuntimeSessions(sessions, {
chatSessionId: "chat-1",
backendKey: "grok",
binPath: "/usr/bin/grok",
runtime: "streaming-json",
}),
true,
);
assert.equal(sessions.has(acpKey), false);
assert.equal(sessions.get(headlessKey), "json-session");
assert.equal(sessions.get(otherChatAcpKey), "other-acp");
// Switch back to ACP: expire headless; ACP was already gone → fresh resume.
sessions.set(headlessKey, "json-session-2");
assert.equal(
expireSiblingGrokRuntimeSessions(sessions, {
chatSessionId: "chat-1",
backendKey: "grok",
binPath: "/usr/bin/grok",
runtime: "acp",
}),
true,
);
assert.equal(sessions.has(headlessKey), false);
assert.equal(
resolveSdkResumeSessionId({
sdkSessionIds: sessions,
sdkSessionKey: acpKey,
existingSessionId: `netcatty-sdk-session:${encodeURIComponent(JSON.stringify({
v: 1,
id: "json-session-2",
backend: "grok",
binPath: "/usr/bin/grok",
runtime: "streaming-json",
}))}`,
backendKey: "grok",
binPath: "/usr/bin/grok",
runtime: "acp",
hasConfiguredCommand: false,
}),
undefined,
);
// Non-grok backends no-op.
assert.equal(
expireSiblingGrokRuntimeSessions(sessions, {
chatSessionId: "chat-1",
backendKey: "claude",
binPath: "/usr/bin/claude",
runtime: "sdk",
}),
false,
);
});
test("Grok ACP and streaming-json session identities never cross-resume", () => {
const acpIdentity = `netcatty-sdk-session:${encodeURIComponent(JSON.stringify({
v: 1,
id: "grok-acp-thread",
backend: "grok",
binPath: "/usr/bin/grok",
runtime: "acp",
}))}`;
const headlessIdentity = `netcatty-sdk-session:${encodeURIComponent(JSON.stringify({
v: 1,
id: "grok-headless-thread",
backend: "grok",
binPath: "/usr/bin/grok",
runtime: "streaming-json",
}))}`;
// ACP identity must not resume onto streaming-json runtime.
assert.equal(resolveSdkResumeSessionId({
sdkSessionIds: new Map(),
sdkSessionKey: buildSdkSessionKey("chat-1", "grok", "/usr/bin/grok", "streaming-json"),
existingSessionId: acpIdentity,
backendKey: "grok",
binPath: "/usr/bin/grok",
runtime: "streaming-json",
hasConfiguredCommand: false,
}), undefined);
// streaming-json identity must not resume onto ACP runtime.
assert.equal(resolveSdkResumeSessionId({
sdkSessionIds: new Map(),
sdkSessionKey: buildSdkSessionKey("chat-1", "grok", "/usr/bin/grok", "acp"),
existingSessionId: headlessIdentity,
backendKey: "grok",
binPath: "/usr/bin/grok",
runtime: "acp",
hasConfiguredCommand: false,
}), undefined);
// Matching runtime resumes.
assert.equal(resolveSdkResumeSessionId({
sdkSessionIds: new Map(),
sdkSessionKey: buildSdkSessionKey("chat-1", "grok", "/usr/bin/grok", "acp"),
existingSessionId: acpIdentity,
backendKey: "grok",
binPath: "/usr/bin/grok",
runtime: "acp",
hasConfiguredCommand: false,
}), "grok-acp-thread");
assert.equal(resolveSdkResumeSessionId({
sdkSessionIds: new Map(),
sdkSessionKey: buildSdkSessionKey("chat-1", "grok", "/usr/bin/grok", "streaming-json"),
existingSessionId: headlessIdentity,
backendKey: "grok",
binPath: "/usr/bin/grok",
runtime: "streaming-json",
hasConfiguredCommand: false,
}), "grok-headless-thread");
// Bare legacy ids are only safe for runtime "sdk" — not Grok dual runtimes.
assert.equal(resolveSdkResumeSessionId({
sdkSessionIds: new Map(),
sdkSessionKey: buildSdkSessionKey("chat-1", "grok", "/usr/bin/grok", "acp"),
existingSessionId: "legacy-bare-id",
backendKey: "grok",
binPath: "/usr/bin/grok",
runtime: "acp",
hasConfiguredCommand: false,
}), undefined);
});
test("buildSdkTurnPrompt stages attachments as local file hints", () => {
const staged = [];
const prompt = buildSdkTurnPrompt({
prompt: "describe it",
attachments: [
{ base64Data: Buffer.from("img").toString("base64"), mediaType: "image/png", filename: "screen.png" },
],
writeAttachmentToTemp: (attachment) => `/tmp/${attachment.filename}`,
onStagedAttachment: (attachment) => staged.push(attachment),
});
assert.match(prompt, /Attached files/);
assert.match(prompt, /read_attachment/);
assert.match(prompt, /"screen\.png" \(image\/png\)/);
assert.match(prompt, /\/tmp\/screen\.png/);
assert.match(prompt, /describe it$/);
assert.deepEqual(staged, [{
filename: "screen.png",
mediaType: "image/png",
filePath: "/tmp/screen.png",
base64Data: Buffer.from("img").toString("base64"),
}]);
});
test("buildSdkTurnPrompt directs Skills-mode attachments to the controlled CLI", () => {
const prompt = buildSdkTurnPrompt({
prompt: "read it",
toolIntegrationMode: "skills",
attachments: [
{ base64Data: "ZGF0YQ==", mediaType: "text/plain", filename: "notes.txt" },
],
writeAttachmentToTemp: (attachment) => `/tmp/${attachment.filename}`,
});
assert.match(prompt, /attachment list\/read CLI commands/);
assert.doesNotMatch(prompt, /list_attachments|read_attachment/);
});
test("resolveSdkBackendBinPath prefers configured CodeBuddy path", () => {
const out = resolveSdkBackendBinPath({
backendKey: "codebuddy",
shellEnv: { PATH: "/usr/bin" },
env: { CODEBUDDY_CODE_PATH: "/shim/bin/codebuddy" },
resolveCliFromPath: () => "/usr/bin/codebuddy",
normalizeCliPathForPlatform: (value) => value,
realpath: () => "/opt/codebuddy/bin/codebuddy",
});
assert.equal(out, "/opt/codebuddy/bin/codebuddy");
});
test("resolveSdkBackendBinPath prefers the renderer-configured command path", () => {
const out = resolveSdkBackendBinPath({
backendKey: "codex",
configuredCommand: "/opt/homebrew/bin/codex",
shellEnv: { PATH: "/usr/bin" },
env: {},
resolveCliFromPath: () => "/usr/bin/codex",
normalizeCliPathForPlatform: (value) => value,
resolveSdkBinPath: () => "/usr/bin/codex",
realpath: () => "/opt/homebrew/bin/codex",
});
assert.equal(out, "/opt/homebrew/bin/codex");
});
test("resolveSdkBackendBinPath rejects invalid renderer-configured command paths", () => {
assert.throws(
() => resolveSdkBackendBinPath({
backendKey: "codex",
configuredCommand: "/missing/codex",
shellEnv: { PATH: "/usr/bin" },
env: {},
resolveCliFromPath: () => "/usr/bin/codex",
normalizeCliPathForPlatform: () => null,
resolveSdkBinPath: () => "/usr/bin/codex",
}),
/Agent CLI path not found: \/missing\/codex/,
);
});
test("resolveSdkBackendBinPath applies Codex SDK normalization to configured command paths", () => {
const out = resolveSdkBackendBinPath({
backendKey: "codex",
configuredCommand: "C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd",
shellEnv: { Path: "C:\\Windows\\System32" },
env: {},
resolveCliFromPath: () => "C:\\Windows\\System32\\codex.cmd",
normalizeCliPathForPlatform: (value) => value,
resolveCodexExecutableForSdk: (p) =>
p.endsWith("codex.cmd")
? "C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex-win32-x64\\vendor\\x86_64-pc-windows-msvc\\bin\\codex.exe"
: p,
realpath: (p) => p,
});
assert.equal(
out,
"C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex-win32-x64\\vendor\\x86_64-pc-windows-msvc\\bin\\codex.exe",
);
});
test("resolveSdkBackendBinPath applies CodeBuddy SDK normalization to configured command paths", () => {
const out = resolveSdkBackendBinPath({
backendKey: "codebuddy",
configuredCommand: "C:\\Users\\me\\AppData\\Roaming\\npm\\codebuddy.cmd",
shellEnv: { Path: "C:\\Windows\\System32" },
env: {},
resolveCliFromPath: () => "C:\\Windows\\System32\\codebuddy.cmd",
normalizeCliPathForPlatform: (value) => value,
resolveCodebuddyExecutableForSdk: (p) =>
p.endsWith("codebuddy.cmd")
? "C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@tencent-ai\\codebuddy-code\\bin\\codebuddy"
: p,
realpath: (p) => p,
});
assert.equal(
out,
"C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@tencent-ai\\codebuddy-code\\bin\\codebuddy",
);
});
test("resolveSdkBackendBinPath falls back to PATH when CodeBuddy path is invalid", () => {
const out = resolveSdkBackendBinPath({
backendKey: "codebuddy",
shellEnv: { PATH: "/usr/bin" },
env: { CODEBUDDY_CODE_PATH: "/missing/codebuddy" },
resolveCliFromPath: () => "/usr/bin/codebuddy",
normalizeCliPathForPlatform: () => null,
});
assert.equal(out, "/usr/bin/codebuddy");
});
test("resolveSdkBackendBinPath realpaths CodeBuddy PATH discovery fallback", () => {
const out = resolveSdkBackendBinPath({
backendKey: "codebuddy",
shellEnv: { PATH: "/usr/bin" },
env: {},
resolveCliFromPath: () => "/shim/bin/codebuddy",
normalizeCliPathForPlatform: () => null,
realpath: () => "/opt/codebuddy/bin/codebuddy",
});
assert.equal(out, "/opt/codebuddy/bin/codebuddy");
});
test("resolveSdkBackendBinPath resolves Windows CodeBuddy shim to the package JS entry", () => {
const out = resolveSdkBackendBinPath({
backendKey: "codebuddy",
shellEnv: { Path: "C:\\Users\\me\\AppData\\Roaming\\npm" },
env: {},
resolveCliFromPath: () => "C:\\Users\\me\\AppData\\Roaming\\npm\\codebuddy.cmd",
normalizeCliPathForPlatform: () => null,
realpath: (p) => p,
resolveCodebuddyExecutableForSdk: (p) =>
p.endsWith("codebuddy.cmd")
? "C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@tencent-ai\\codebuddy-code\\bin\\codebuddy"
: p,
});
assert.equal(
out,
"C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@tencent-ai\\codebuddy-code\\bin\\codebuddy",
);
});
test("resolveSdkBackendBinPath falls back to bundled CLI when Windows CodeBuddy shim is unresolvable", () => {
const out = resolveSdkBackendBinPath({
backendKey: "codebuddy",
shellEnv: { Path: "C:\\Users\\me\\AppData\\Roaming\\npm" },
env: {},
resolveCliFromPath: () => "C:\\Users\\me\\AppData\\Roaming\\npm\\codebuddy.cmd",
normalizeCliPathForPlatform: () => null,
realpath: (p) => p,
resolveCodebuddyExecutableForSdk: () => null,
});
assert.equal(out, undefined);
});
test("resolveSdkBackendBinPath keeps non-CodeBuddy SDK path normalization", () => {
const out = resolveSdkBackendBinPath({
backendKey: "codex",
shellEnv: { PATH: "C:\\Users\\me\\AppData\\Roaming\\npm" },
env: {},
resolveCliFromPath: () => "C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd",
resolveSdkBinPath: () => "C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js",
});
assert.equal(out, "C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js");
});
test("resolveSdkBackendBinPath does not fall back to Windows shell shims for non-CodeBuddy", () => {
const out = resolveSdkBackendBinPath({
backendKey: "codex",
shellEnv: { PATH: "C:\\Users\\me\\AppData\\Roaming\\npm" },
env: {},
resolveCliFromPath: () => "C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd",
resolveSdkBinPath: () => null,
});
assert.equal(out, undefined);
});

View File

@@ -0,0 +1,75 @@
"use strict";
const crypto = require("node:crypto");
const VAULT_AGENT_TIMEOUT_MS = 15_000;
const pendingVaultRequests = new Map();
function createVaultAgentBridge({ getMainWindowFn, validateSender }) {
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:ai:vault-agent:response", (event, { requestId, result }) => {
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
if (!requestId || typeof requestId !== "string") {
return { ok: false, error: "requestId is required" };
}
const entry = pendingVaultRequests.get(requestId);
if (!entry) {
return { ok: false, error: "Unknown or expired vault agent request." };
}
clearTimeout(entry.timer);
pendingVaultRequests.delete(requestId);
entry.resolve(result);
return { ok: true };
});
}
async function invokeVaultAgent(op, params = {}, options = {}) {
const mainWin = typeof getMainWindowFn === "function" ? getMainWindowFn() : null;
if (!mainWin || mainWin.isDestroyed()) {
return {
ok: false,
error: "No active Netcatty window is available for vault access.",
};
}
const requestId = crypto.randomUUID();
return new Promise((resolve) => {
const timer = setTimeout(() => {
if (!pendingVaultRequests.has(requestId)) return;
pendingVaultRequests.delete(requestId);
resolve({
ok: false,
error: "Vault agent bridge timed out waiting for renderer.",
});
}, options.timeoutMs ?? VAULT_AGENT_TIMEOUT_MS);
pendingVaultRequests.set(requestId, { resolve, timer });
try {
mainWin.webContents.send("netcatty:ai:vault-agent:request", {
requestId,
op,
params,
});
} catch (err) {
clearTimeout(timer);
pendingVaultRequests.delete(requestId);
resolve({
ok: false,
error: err?.message || String(err),
});
}
});
}
return {
registerHandlers,
invokeVaultAgent,
};
}
module.exports = {
createVaultAgentBridge,
VAULT_AGENT_TIMEOUT_MS,
};

View File

@@ -0,0 +1,197 @@
"use strict";
const path = require("node:path");
const fs = require("node:fs");
const VALID_VARIANTS = new Set([
"original",
"bright",
"dark",
"colorful",
"high-contrast",
"white-navy",
"white-sky",
"white-rose",
"white-emerald",
"white-amber",
"white-violet",
"rainbow",
]);
const DEFAULT_VARIANT = "original";
let currentVariant = DEFAULT_VARIANT;
let currentIconPath = null;
let preferPublicSources = false;
let useMacIconSources = false;
function isValidAppIconVariant(variant) {
return typeof variant === "string" && VALID_VARIANTS.has(variant);
}
function normalizeAppIconVariant(variant) {
return isValidAppIconVariant(variant) ? variant : DEFAULT_VARIANT;
}
function isPackagedApp(app) {
try {
return app?.isPackaged === true;
} catch {
return false;
}
}
function pickExistingPath(candidates) {
return candidates.find((candidate) => fs.existsSync(candidate));
}
function buildSourceCandidates(appPath, relativeParts) {
const publicCandidate = path.join(appPath, "public", ...relativeParts);
const distCandidate = path.join(appPath, "dist", ...relativeParts);
return preferPublicSources
? [publicCandidate, distCandidate]
: [distCandidate, publicCandidate];
}
function resolveOriginalIconPath(appPath) {
const primaryParts = useMacIconSources
? ["icons", "variants", "macos", "original.png"]
: ["icons", "variants", "original.png"];
const candidates = useMacIconSources
? buildSourceCandidates(appPath, primaryParts)
: [
...buildSourceCandidates(appPath, primaryParts),
...buildSourceCandidates(appPath, ["icon-win.png"]),
...buildSourceCandidates(appPath, ["icon.png"]),
];
const existing = pickExistingPath(candidates);
if (existing) return existing;
// Dev fallback: elf SVG.
const svgCandidate = path.join(appPath, "public", "senmesh-elf-logo.svg");
if (fs.existsSync(svgCandidate)) return svgCandidate;
return candidates[0];
}
function buildVariantSourceCandidates(appPath, fileName) {
const relativeParts = useMacIconSources
? ["icons", "variants", "macos", fileName]
: ["icons", "variants", fileName];
return buildSourceCandidates(appPath, relativeParts);
}
function resolveVariantIconPath(variant, appPath) {
const normalized = normalizeAppIconVariant(variant);
if (normalized === "original") {
return resolveOriginalIconPath(appPath);
}
const fileName = `${normalized}.png`;
const candidates = buildVariantSourceCandidates(appPath, fileName);
const resolved = pickExistingPath(candidates);
if (resolved) return resolved;
return resolveOriginalIconPath(appPath);
}
function resolveStrictVariantIconPath(variant, appPath) {
const normalized = normalizeAppIconVariant(variant);
if (normalized === "original") {
return resolveOriginalIconPath(appPath);
}
const fileName = `${normalized}.png`;
const candidates = buildVariantSourceCandidates(appPath, fileName);
return pickExistingPath(candidates) || null;
}
function initializeAppIconManager(appPath, options = {}) {
preferPublicSources = options.preferPublic === true;
useMacIconSources = options.isMac === true;
currentVariant = DEFAULT_VARIANT;
currentIconPath = resolveVariantIconPath(currentVariant, appPath);
return currentIconPath;
}
function getAppIconPath(appPath) {
if (!currentIconPath) {
return initializeAppIconManager(appPath);
}
return currentIconPath;
}
function getAppIconVariant() {
return currentVariant;
}
function createNativeImage(nativeImage, iconPath) {
if (!nativeImage || !iconPath || !fs.existsSync(iconPath)) return null;
try {
// Read from disk so regenerated assets at the same path refresh immediately.
return nativeImage.createFromBuffer(fs.readFileSync(iconPath));
} catch {
try {
return nativeImage.createFromPath(iconPath);
} catch {
return null;
}
}
}
function applyIconToWindow(win, iconPath, nativeImage) {
if (!win || win.isDestroyed?.() || !iconPath || !win.setIcon) return;
try {
const image = createNativeImage(nativeImage, iconPath);
if (image) {
win.setIcon(image);
return;
}
win.setIcon(iconPath);
} catch {
// ignore
}
}
function applyAppIconVariant(variant, context) {
const { app, BrowserWindow, nativeImage, appPath, isMac } = context;
preferPublicSources = !isPackagedApp(app);
useMacIconSources = isMac === true;
const normalized = normalizeAppIconVariant(variant);
const iconPath = resolveStrictVariantIconPath(normalized, appPath);
if (!iconPath || !fs.existsSync(iconPath)) {
return false;
}
currentVariant = normalized;
currentIconPath = iconPath;
const windows = BrowserWindow?.getAllWindows?.() || [];
for (const win of windows) {
applyIconToWindow(win, iconPath, nativeImage);
}
if (isMac && app?.dock?.setIcon && nativeImage) {
try {
const dockImage = createNativeImage(nativeImage, iconPath);
if (dockImage) {
app.dock.setIcon(dockImage);
}
} catch {
// ignore
}
}
return true;
}
module.exports = {
DEFAULT_VARIANT,
VALID_VARIANTS,
isValidAppIconVariant,
normalizeAppIconVariant,
initializeAppIconManager,
getAppIconPath,
getAppIconVariant,
resolveVariantIconPath,
resolveStrictVariantIconPath,
applyAppIconVariant,
applyIconToWindow,
};

View File

@@ -0,0 +1,128 @@
"use strict";
const { test } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const appIconManager = require("./appIconManager.cjs");
test("normalizeAppIconVariant falls back to original for invalid values", () => {
assert.equal(appIconManager.normalizeAppIconVariant("nope"), "original");
assert.equal(appIconManager.normalizeAppIconVariant("bright"), "bright");
});
test("resolveVariantIconPath prefers public sources in dev when both exist", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-icon-dev-"));
const publicPath = path.join(tmp, "public", "icons", "variants", "bright.png");
const distPath = path.join(tmp, "dist", "icons", "variants", "bright.png");
fs.mkdirSync(path.dirname(publicPath), { recursive: true });
fs.mkdirSync(path.dirname(distPath), { recursive: true });
fs.writeFileSync(publicPath, "public-new");
fs.writeFileSync(distPath, "dist-old");
appIconManager.initializeAppIconManager(tmp, { preferPublic: true });
assert.equal(appIconManager.resolveVariantIconPath("bright", tmp), publicPath);
});
test("resolveVariantIconPath prefers dist sources when packaged", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-icon-packaged-"));
const publicPath = path.join(tmp, "public", "icons", "variants", "bright.png");
const distPath = path.join(tmp, "dist", "icons", "variants", "bright.png");
fs.mkdirSync(path.dirname(publicPath), { recursive: true });
fs.mkdirSync(path.dirname(distPath), { recursive: true });
fs.writeFileSync(publicPath, "public-new");
fs.writeFileSync(distPath, "dist-packaged");
appIconManager.initializeAppIconManager(tmp, { preferPublic: false });
assert.equal(appIconManager.resolveVariantIconPath("bright", tmp), distPath);
});
test("original icon uses platform-specific sizing", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-icon-platform-"));
const publicDir = path.join(tmp, "public");
fs.mkdirSync(publicDir, { recursive: true });
const macPath = path.join(publicDir, "icons", "variants", "macos", "original.png");
const desktopPath = path.join(publicDir, "icons", "variants", "original.png");
fs.mkdirSync(path.dirname(macPath), { recursive: true });
fs.mkdirSync(path.dirname(desktopPath), { recursive: true });
fs.writeFileSync(macPath, "mac");
fs.writeFileSync(desktopPath, "desktop");
appIconManager.initializeAppIconManager(tmp, { preferPublic: true, isMac: true });
assert.equal(appIconManager.resolveVariantIconPath("original", tmp), macPath);
appIconManager.initializeAppIconManager(tmp, { preferPublic: true, isMac: false });
assert.equal(appIconManager.resolveVariantIconPath("original", tmp), desktopPath);
});
test("macOS variants use HIG-sized assets without changing other platforms", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-icon-variant-platform-"));
const variantsDir = path.join(tmp, "public", "icons", "variants");
const macVariantsDir = path.join(variantsDir, "macos");
fs.mkdirSync(macVariantsDir, { recursive: true });
const desktopPath = path.join(variantsDir, "bright.png");
const macPath = path.join(macVariantsDir, "bright.png");
fs.writeFileSync(desktopPath, "desktop");
fs.writeFileSync(macPath, "mac");
appIconManager.initializeAppIconManager(tmp, { preferPublic: true, isMac: true });
assert.equal(appIconManager.resolveVariantIconPath("bright", tmp), macPath);
appIconManager.initializeAppIconManager(tmp, { preferPublic: true, isMac: false });
assert.equal(appIconManager.resolveVariantIconPath("bright", tmp), desktopPath);
});
test("macOS leaves the Dock icon unchanged when its runtime original asset is missing", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-icon-mac-missing-"));
const publicDir = path.join(tmp, "public");
fs.mkdirSync(publicDir, { recursive: true });
fs.writeFileSync(path.join(publicDir, "icon.png"), "packaged-mac");
fs.writeFileSync(path.join(publicDir, "icon-win.png"), "full-bleed");
appIconManager.initializeAppIconManager(tmp, { preferPublic: true, isMac: true });
let dockSetCount = 0;
const applied = appIconManager.applyAppIconVariant("original", {
app: { isPackaged: false, dock: { setIcon() { dockSetCount += 1; } } },
BrowserWindow: { getAllWindows: () => [] },
nativeImage: {
createFromBuffer: (buf) => ({ buffer: buf.toString() }),
createFromPath: (p) => ({ path: p }),
},
appPath: tmp,
isMac: true,
});
assert.equal(applied, false);
assert.equal(dockSetCount, 0);
});
test("applyAppIconVariant updates current icon path", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-icon-apply-"));
const publicDir = path.join(tmp, "public");
const variantsDir = path.join(publicDir, "icons", "variants");
fs.mkdirSync(variantsDir, { recursive: true });
const originalPath = path.join(publicDir, "icon.png");
const brightPath = path.join(variantsDir, "macos", "bright.png");
fs.mkdirSync(path.dirname(brightPath), { recursive: true });
fs.writeFileSync(originalPath, "orig");
fs.writeFileSync(brightPath, "bright");
appIconManager.initializeAppIconManager(tmp, { preferPublic: true });
const windows = [];
const applied = appIconManager.applyAppIconVariant("bright", {
app: { isPackaged: false, dock: { setIcon() {} } },
BrowserWindow: { getAllWindows: () => windows },
nativeImage: {
createFromBuffer: (buf) => ({ buffer: buf.toString() }),
createFromPath: (p) => ({ path: p }),
},
appPath: tmp,
isMac: true,
});
assert.equal(applied, true);
assert.equal(appIconManager.getAppIconVariant(), "bright");
assert.equal(appIconManager.getAppIconPath(tmp), brightPath);
});

View File

@@ -0,0 +1,882 @@
const {
canLockFromSettings,
createAppLockPasswordVerifier,
normalizeAppLockTimeoutMinutes,
shouldLockOnBackgroundHide,
verifyAppLockPassword,
} = require("./appLockSettingsStore.cjs");
function normalizeReason(reason) {
return typeof reason === "string" && reason.trim() !== "" ? reason : null;
}
function cloneState(state) {
return {
initialized: state.initialized === true,
locked: state.locked === true,
reason: normalizeReason(state.reason),
version: state.version,
lastLockedAt: typeof state.lastLockedAt === "number" ? state.lastLockedAt : null,
lastUnlockedAt: typeof state.lastUnlockedAt === "number" ? state.lastUnlockedAt : null,
lastActivityAt: typeof state.lastActivityAt === "number" ? state.lastActivityAt : null,
};
}
function createAppLockRuntimeBridge() {
let state = {
initialized: false,
locked: false,
reason: null,
version: 0,
lastLockedAt: null,
lastUnlockedAt: null,
lastActivityAt: null,
};
const listeners = new Set();
let idleTimerId = null;
let idleTimerConfig = null;
function getState() {
return cloneState(state);
}
function notify() {
const snapshot = getState();
listeners.forEach((listener) => {
try {
listener(snapshot);
} catch {
// ignore subscriber failures
}
});
}
function clearScheduledTimerOnly() {
if (idleTimerId !== null) {
clearTimeout(idleTimerId);
idleTimerId = null;
}
}
function getTimeoutMs(timeoutMinutes) {
if (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0) return null;
return timeoutMinutes * 60_000;
}
function rescheduleIdleTimer() {
clearScheduledTimerOnly();
if (!idleTimerConfig || state.initialized !== true || state.locked === true) {
return;
}
const timeoutMs = getTimeoutMs(idleTimerConfig.timeoutMinutes);
if (timeoutMs === null || typeof state.lastActivityAt !== "number") {
return;
}
const elapsedMs = Math.max(0, Date.now() - state.lastActivityAt);
const delayMs = Math.max(0, timeoutMs - elapsedMs);
idleTimerId = setTimeout(() => {
idleTimerId = null;
if (!idleTimerConfig || state.initialized !== true || state.locked === true) {
return;
}
const currentTimeoutMs = getTimeoutMs(idleTimerConfig.timeoutMinutes);
if (currentTimeoutMs === null || typeof state.lastActivityAt !== "number") {
return;
}
const currentElapsedMs = Math.max(0, Date.now() - state.lastActivityAt);
if (currentElapsedMs < currentTimeoutMs) {
rescheduleIdleTimer();
return;
}
if (!idleTimerConfig.canLock()) {
clearIdleTimer();
return;
}
const nextState = lock("idle");
try {
idleTimerConfig.onIdleLock(nextState);
} catch {
// ignore callback failures
}
}, delayMs);
if (idleTimerId && typeof idleTimerId.unref === "function") {
idleTimerId.unref();
}
}
function applyStatePatch(patch, { notifyListeners = true } = {}) {
state = {
...state,
...patch,
version: state.version + 1,
};
rescheduleIdleTimer();
if (notifyListeners) {
notify();
}
return getState();
}
function initialize(nextState) {
const now = Date.now();
const locked = nextState?.locked === true;
return applyStatePatch({
initialized: true,
locked,
reason: locked ? normalizeReason(nextState?.reason) || "startup" : null,
lastLockedAt: locked
? (typeof nextState?.lastLockedAt === "number" ? nextState.lastLockedAt : now)
: null,
lastUnlockedAt: locked
? null
: (typeof nextState?.lastUnlockedAt === "number" ? nextState.lastUnlockedAt : null),
lastActivityAt: typeof nextState?.lastActivityAt === "number" ? nextState.lastActivityAt : now,
});
}
function lock(reason = "manual") {
const nextReason = normalizeReason(reason) || "manual";
if (state.initialized === true && state.locked === true && state.reason === nextReason) {
return getState();
}
return applyStatePatch({
initialized: true,
locked: true,
reason: nextReason,
lastLockedAt: Date.now(),
});
}
function unlock() {
const now = Date.now();
if (state.initialized === true && state.locked === false && state.reason === null) {
return getState();
}
return applyStatePatch({
initialized: true,
locked: false,
reason: null,
lastUnlockedAt: now,
lastActivityAt: now,
});
}
function recordActivity(timestamp = Date.now()) {
if (state.initialized !== true || state.locked === true) {
return getState();
}
if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) {
return getState();
}
if (state.lastActivityAt === timestamp) {
return getState();
}
return applyStatePatch(
{
lastActivityAt: timestamp,
},
{ notifyListeners: false },
);
}
function subscribe(listener) {
if (typeof listener !== "function") {
return () => {};
}
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function scheduleIdleTimer({ timeoutMinutes, canLock, onIdleLock }) {
idleTimerConfig = {
timeoutMinutes,
canLock: typeof canLock === "function" ? canLock : () => true,
onIdleLock: typeof onIdleLock === "function" ? onIdleLock : () => {},
};
rescheduleIdleTimer();
}
function clearIdleTimer() {
idleTimerConfig = null;
clearScheduledTimerOnly();
}
return {
initialize,
getState,
lock,
unlock,
recordActivity,
subscribe,
scheduleIdleTimer,
clearIdleTimer,
};
}
module.exports = {
createAppLockController,
createAppLockRuntimeBridge,
};
function createAppLockController({
settingsStore,
runtimeBridge,
systemAuthBridge = null,
getMainWindows = () => [],
// Session windows use registerAsMainWindow:false and are only tracked as
// app-content windows; they still mount AppLockGate and must receive lock
// runtime / settings broadcasts (Codex P1).
getAppContentWindows = () => [],
getSettingsWindow = () => null,
getTrayPanelWindow = () => null,
getTerminalPopupWindows = () => [],
waitForUnlockFailureDelay = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
}) {
if (!settingsStore || typeof settingsStore.getSnapshot !== "function" || typeof settingsStore.save !== "function") {
throw new Error("createAppLockController requires a settingsStore");
}
if (!runtimeBridge || typeof runtimeBridge.getState !== "function") {
throw new Error("createAppLockController requires a runtimeBridge");
}
// Serialize full read-modify-write settings mutations (Codex P2).
let settingsMutationChain = Promise.resolve();
// Single in-flight system-auth prompt shared across windows (Codex P2).
let systemUnlockInFlight = null;
let passwordUnlockInFlight = null;
let passwordUnlockFailureCount = 0;
const lockedWindowTitles = new Map();
const protectedWindows = new WeakSet();
function syncIdleTimer() {
const settings = getSettings();
if (!canLockFromSettings(settings)) {
runtimeBridge.clearIdleTimer?.();
return;
}
runtimeBridge.scheduleIdleTimer?.({
timeoutMinutes: settings.timeoutMinutes,
canLock: () => canLockFromSettings(getSettings()),
onIdleLock: (nextState) => {
broadcast("netcatty:appLock:runtimeStateChanged", nextState);
},
});
}
function getWindowsForBroadcast() {
const windows = [
...(Array.isArray(getMainWindows()) ? getMainWindows() : []),
// Detached #/session-window and other app-content-only windows.
...(Array.isArray(getAppContentWindows()) ? getAppContentWindows() : []),
getSettingsWindow(),
getTrayPanelWindow(),
...(Array.isArray(getTerminalPopupWindows()) ? getTerminalPopupWindows() : []),
];
const seen = new Set();
return windows.filter((win) => {
if (!win || typeof win.isDestroyed !== "function" || win.isDestroyed()) return false;
if (!win.webContents || typeof win.webContents.isDestroyed !== "function" || win.webContents.isDestroyed()) {
return false;
}
const id = win.webContents.id || win;
if (seen.has(id)) return false;
seen.add(id);
return true;
});
}
function broadcast(channel, payload) {
for (const win of getWindowsForBroadcast()) {
try {
win.webContents.send(channel, payload);
} catch {
// ignore disposed windows during broadcast
}
}
}
function enforceWindowProtection(win) {
if (!win || win.isDestroyed?.() || getRuntimeState()?.locked !== true) return;
try {
if (typeof win.getTitle === "function") {
const currentTitle = win.getTitle();
if (currentTitle && currentTitle !== "NetMesh") {
lockedWindowTitles.set(win, currentTitle);
}
}
win.setTitle?.("NetMesh");
if (win.webContents?.isDevToolsOpened?.()) {
win.webContents.closeDevTools?.();
}
} catch {
// ignore per-window failures during lock transitions
}
}
// Windows shows the native title-bar menu (Minimize / Maximize / Close) on
// right-click in -webkit-app-region: drag areas. The lock overlay is a drag
// region so the window stays movable, so hide that menu while locked.
function suppressSystemContextMenu(event) {
if (getRuntimeState()?.locked !== true) return;
try {
event?.preventDefault?.();
} catch {
// ignore
}
}
function protectWindow(win) {
if (!win || win.isDestroyed?.()) return;
if (!protectedWindows.has(win)) {
protectedWindows.add(win);
const enforce = () => enforceWindowProtection(win);
try { win.on?.("show", enforce); } catch { /* ignore */ }
try { win.on?.("focus", enforce); } catch { /* ignore */ }
try { win.on?.("system-context-menu", suppressSystemContextMenu); } catch { /* ignore */ }
try { win.webContents?.on?.("devtools-opened", enforce); } catch { /* ignore */ }
try {
win.webContents?.on?.("did-finish-load", () => {
queueMicrotask(enforce);
});
} catch {
// ignore
}
}
enforceWindowProtection(win);
}
function setWindowTitle(win, title) {
if (!win || win.isDestroyed?.()) return false;
const nextTitle = typeof title === "string" && title.trim() ? title.trim() : "NetMesh";
try {
if (getRuntimeState()?.locked === true) {
lockedWindowTitles.set(win, nextTitle);
win.setTitle?.("NetMesh");
return false;
}
win.setTitle?.(nextTitle);
return true;
} catch {
return false;
}
}
function protectWindowsForRuntimeState(nextState) {
if (nextState?.locked === true) {
for (const win of getWindowsForBroadcast()) protectWindow(win);
return;
}
for (const [win, title] of lockedWindowTitles) {
try {
if (!win.isDestroyed?.() && win.getTitle?.() === "Netcatty") {
win.setTitle?.(title || "Netcatty");
}
} catch {
// ignore disposed windows during unlock
}
}
lockedWindowTitles.clear();
}
// Runtime transitions are the single lock boundary. Idle, background,
// startup, and manual locks all pass through this subscription.
runtimeBridge.subscribe?.(protectWindowsForRuntimeState);
// initialize() can lock the runtime before the controller exists.
protectWindowsForRuntimeState(runtimeBridge.getState());
function getSettings() {
return settingsStore.getSnapshot();
}
/**
* Renderer-facing settings replace real salt/hash with zeroed placeholders of
* the correct size so presence checks still work, but offline brute-force of
* the verifier from any renderer is impossible (Codex P2 on 8c0b9c5a).
* Password verification always runs in main against the private store.
*/
function getPublicSettings() {
const settings = getSettings();
if (!settings || typeof settings !== "object") return settings;
if (!settings.passwordVerifier) return settings;
const redactedSalt = Buffer.alloc(16).toString("base64");
const redactedHash = Buffer.alloc(32).toString("base64");
return {
...settings,
passwordVerifier: {
version: settings.passwordVerifier.version,
algorithm: settings.passwordVerifier.algorithm,
iterations: settings.passwordVerifier.iterations,
salt: redactedSalt,
hash: redactedHash,
},
};
}
function getRuntimeState() {
return runtimeBridge.getState();
}
async function getSystemAuthStatusOnly() {
if (!systemAuthBridge || typeof systemAuthBridge.getStatus !== "function") {
return {
supported: false,
available: false,
platform: "unsupported",
label: null,
reason: null,
};
}
try {
return await systemAuthBridge.getStatus();
} catch {
return {
supported: false,
available: false,
platform: "unsupported",
label: null,
reason: "failed",
};
}
}
async function getSystemUnlockStatus() {
const status = await getSystemAuthStatusOnly();
const settings = getSettings();
const canLock = canLockFromSettings(settings);
return {
supported: status.supported === true,
available: status.available === true && canLock,
enabled: settings.systemUnlockEnabled === true && canLock,
platform: status.platform || "unsupported",
label: status.label || null,
reason: status.reason || null,
};
}
async function saveSettings(nextSettings) {
const saved = await settingsStore.save(nextSettings);
syncIdleTimer();
const publicSaved = getPublicSettings();
broadcast("netcatty:appLock:settingsChanged", publicSaved);
return publicSaved;
}
/** Queue a full RMW settings mutation so concurrent changes cannot clobber. */
function mutateSettings(mutator) {
const run = async () => {
const current = getSettings();
const next = await mutator(current);
if (!next) return getPublicSettings();
return saveSettings(next);
};
const pending = settingsMutationChain.then(run, run);
settingsMutationChain = pending.then(() => {}, () => {});
return pending;
}
async function requestEnable() {
return mutateSettings(async (current) => {
if (!current.passwordVerifier) return null;
return {
...current,
enabled: true,
};
});
}
async function requestDisable(currentPassword) {
// Full RMW on the mutation queue so a concurrent setTimeoutMinutes cannot
// snapshot pre-disable settings and re-enable App Lock after us (Codex P2).
let fail = null;
const saved = await mutateSettings(async (current) => {
if (current.passwordVerifier) {
if (!currentPassword) {
fail = { ok: false, error: "empty-current" };
return null;
}
const verified = await verifyAppLockPassword(currentPassword, current.passwordVerifier);
if (!verified) {
fail = { ok: false, error: "incorrect" };
return null;
}
}
return {
...current,
enabled: false,
passwordVerifier: null,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
};
});
if (fail) return fail;
const runtimeState = runtimeBridge.unlock();
syncIdleTimer();
broadcast("netcatty:appLock:runtimeStateChanged", runtimeState);
return saved;
}
async function requestReset(currentPassword) {
let fail = null;
const saved = await mutateSettings(async (current) => {
const verified = await verifyCurrentPassword(current, currentPassword);
if (verified !== true) {
fail = verified;
return null;
}
return {
...current,
enabled: false,
passwordVerifier: null,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
};
});
if (fail) return fail;
const runtimeState = runtimeBridge.unlock();
syncIdleTimer();
broadcast("netcatty:appLock:runtimeStateChanged", runtimeState);
return saved;
}
async function requestPasswordChange(input = {}) {
const nextPassword = typeof input.nextPassword === "string" ? input.nextPassword : "";
const currentPassword = typeof input.currentPassword === "string" ? input.currentPassword : "";
const hadVerifierAtRequest = Boolean(getSettings().passwordVerifier);
if (nextPassword.length === 0) {
return { ok: false, error: "empty-next" };
}
let fail = null;
let enablingFromNoVerifier = false;
const saved = await mutateSettings(async (current) => {
// A password-change request must not turn into a first-time enable if a
// disable/reset queued ahead of it removed the verifier.
if (hadVerifierAtRequest && !current.passwordVerifier) {
fail = { ok: false, error: "incorrect" };
return null;
}
if (current.passwordVerifier) {
if (!currentPassword) {
fail = { ok: false, error: "empty-current" };
return null;
}
const verified = await verifyAppLockPassword(currentPassword, current.passwordVerifier);
if (!verified) {
fail = { ok: false, error: "incorrect" };
return null;
}
}
enablingFromNoVerifier = !current.passwordVerifier;
const passwordVerifier = await createAppLockPasswordVerifier(nextPassword);
return {
...current,
enabled: current.enabled || enablingFromNoVerifier,
passwordVerifier,
};
});
if (fail) return fail;
// While lock was disabled the renderer never reported activity. Re-arming
// the idle timer on enable would schedule an immediate lock if Netcatty
// has been open longer than the timeout. Record fresh activity first
// (Codex P2 on dbe1a746).
if (enablingFromNoVerifier) {
try { runtimeBridge.recordActivity(Date.now()); } catch { /* ignore */ }
syncIdleTimer();
}
return saved;
}
async function setTimeoutMinutes(timeoutMinutes) {
return mutateSettings(async (current) => ({
...current,
timeoutMinutes: normalizeAppLockTimeoutMinutes(timeoutMinutes),
}));
}
async function verifyCurrentPassword(current, currentPassword) {
if (!current.passwordVerifier) return true;
if (!currentPassword) return { ok: false, error: "empty-current" };
const verified = await verifyAppLockPassword(currentPassword, current.passwordVerifier);
if (!verified) return { ok: false, error: "incorrect" };
return true;
}
async function setSystemUnlockEnabled(input = {}) {
const enabled = input?.enabled === true;
const autoPromptEnabled = input?.autoPromptEnabled === true;
const currentPassword = typeof input?.currentPassword === "string" ? input.currentPassword : "";
const current = getSettings();
if (!canLockFromSettings(current)) {
return { ok: false, error: "unavailable" };
}
if (!enabled) {
if (runtimeBridge.getState().locked === true && !currentPassword) {
return { ok: false, error: "locked" };
}
if (currentPassword) {
const verified = await verifyCurrentPassword(current, currentPassword);
if (verified !== true) return verified;
}
return mutateSettings(async (latest) => ({
...latest,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
}));
}
if (current.systemUnlockEnabled === true) {
return mutateSettings(async (latest) => ({
...latest,
systemUnlockAutoPromptEnabled: autoPromptEnabled,
}));
}
const status = await getSystemAuthStatusOnly();
if (status.supported !== true) return { ok: false, error: "unsupported" };
if (status.available !== true) return { ok: false, error: "unavailable" };
if (!systemAuthBridge || typeof systemAuthBridge.requestUnlock !== "function") {
return { ok: false, error: "unsupported" };
}
const result = await systemAuthBridge.requestUnlock();
if (!result || result.ok !== true) {
return {
ok: false,
error: result?.error || "failed",
};
}
return mutateSettings(async (latest) => ({
...latest,
systemUnlockEnabled: true,
systemUnlockAutoPromptEnabled: autoPromptEnabled,
}));
}
function setLocked(reason) {
const settings = getSettings();
if (!canLockFromSettings(settings)) {
return getRuntimeState();
}
// Close-to-tray and app-hide use reason "background". Honor "Never
// lock automatically" so hiding the window does not prompt for a password.
if (reason === "background" && !shouldLockOnBackgroundHide(settings)) {
return getRuntimeState();
}
const nextState = runtimeBridge.lock(reason);
syncIdleTimer();
broadcast("netcatty:appLock:runtimeStateChanged", nextState);
return nextState;
}
function isSamePasswordVerifier(a, b) {
return Boolean(
a
&& b
&& a.version === b.version
&& a.algorithm === b.algorithm
&& a.iterations === b.iterations
&& a.salt === b.salt
&& a.hash === b.hash
);
}
async function rejectPasswordUnlockAttempt() {
passwordUnlockFailureCount += 1;
const delayMs = 250 * (2 ** Math.min(passwordUnlockFailureCount - 1, 3));
await waitForUnlockFailureDelay(delayMs);
return { ok: false, error: "incorrect" };
}
async function requestUnlockAttempt(password, requestContext) {
const current = getSettings();
if (!canLockFromSettings(current)) {
passwordUnlockFailureCount = 0;
const nextState = runtimeBridge.unlock();
syncIdleTimer();
broadcast("netcatty:appLock:runtimeStateChanged", nextState);
return { ok: true };
}
if (!password) {
return { ok: false, error: "empty" };
}
const lockAtAttempt = requestContext.lockState;
if (
lockAtAttempt.locked !== true
|| !isSamePasswordVerifier(requestContext.passwordVerifier, current.passwordVerifier)
) {
return rejectPasswordUnlockAttempt();
}
const verified = await verifyAppLockPassword(password, requestContext.passwordVerifier);
const latest = getSettings();
const lockAfterVerify = runtimeBridge.getState();
const staleAttempt = !isSamePasswordVerifier(requestContext.passwordVerifier, latest.passwordVerifier)
|| lockAfterVerify.locked !== true
|| lockAfterVerify.version !== lockAtAttempt.version;
if (!verified || staleAttempt) {
return rejectPasswordUnlockAttempt();
}
passwordUnlockFailureCount = 0;
const nextState = runtimeBridge.unlock();
syncIdleTimer();
broadcast("netcatty:appLock:runtimeStateChanged", nextState);
return { ok: true };
}
function requestUnlock(password) {
const current = getSettings();
const requestContext = {
lockState: runtimeBridge.getState(),
passwordVerifier: current.passwordVerifier,
};
if (!canLockFromSettings(current)) {
return requestUnlockAttempt(password, requestContext);
}
// Calls made while unlocked must never enter a queue that can delay a
// later legitimate unlock after idle/background locking.
if (requestContext.lockState.locked !== true) {
return Promise.resolve({ ok: false, error: "incorrect" });
}
// At most one expensive password attempt may be active. Concurrent IPC
// calls share it instead of creating an unbounded PBKDF/backoff queue.
if (passwordUnlockInFlight) return passwordUnlockInFlight;
passwordUnlockInFlight = requestUnlockAttempt(password, requestContext);
return passwordUnlockInFlight.finally(() => {
passwordUnlockInFlight = null;
});
}
async function requestSystemUnlock() {
if (systemUnlockInFlight) {
return systemUnlockInFlight;
}
systemUnlockInFlight = (async () => {
const current = getSettings();
if (!canLockFromSettings(current)) return { ok: false, error: "unavailable" };
if (current.systemUnlockEnabled !== true) return { ok: false, error: "disabled" };
// Capture the lock presentation epoch before the OS prompt. A re-lock
// (idle → background) advances version while staying locked; accepting a
// stale prompt would unlock the newer lock (Codex P2).
const lockAtPrompt = runtimeBridge.getState();
if (lockAtPrompt.locked !== true) return { ok: false, error: "not-locked" };
const lockVersionAtPrompt = lockAtPrompt.version;
const status = await getSystemAuthStatusOnly();
if (status.supported !== true) return { ok: false, error: "unsupported" };
if (status.available !== true) return { ok: false, error: "unavailable" };
if (!systemAuthBridge || typeof systemAuthBridge.requestUnlock !== "function") {
return { ok: false, error: "unsupported" };
}
const result = await systemAuthBridge.requestUnlock();
if (!result || result.ok !== true) {
return {
ok: false,
error: result?.error || "failed",
};
}
const latestSettings = getSettings();
if (
!canLockFromSettings(latestSettings)
|| latestSettings.systemUnlockEnabled !== true
) {
return { ok: false, error: "disabled" };
}
// Drop the prompt result if we unlocked, re-locked, or the lock reason
// changed while the dialog was open (version advances on each lock patch).
const lockAfterPrompt = runtimeBridge.getState();
if (
lockAfterPrompt.locked !== true
|| lockAfterPrompt.version !== lockVersionAtPrompt
) {
return { ok: false, error: "not-locked" };
}
const nextState = runtimeBridge.unlock();
syncIdleTimer();
broadcast("netcatty:appLock:runtimeStateChanged", nextState);
return { ok: true };
})();
try {
return await systemUnlockInFlight;
} finally {
systemUnlockInFlight = null;
}
}
function reportActivity(timestamp = Date.now()) {
const nextState = runtimeBridge.recordActivity(timestamp);
syncIdleTimer();
return nextState;
}
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:appLock:getRuntimeState", () => getRuntimeState());
ipcMain.handle("netcatty:appLock:getSettings", () => getPublicSettings());
ipcMain.handle("netcatty:appLock:setTimeoutMinutes", (_event, timeoutMinutes) =>
setTimeoutMinutes(timeoutMinutes));
ipcMain.handle("netcatty:appLock:requestEnable", () => requestEnable());
ipcMain.handle("netcatty:appLock:requestDisable", (_event, currentPassword) =>
requestDisable(currentPassword));
ipcMain.handle("netcatty:appLock:requestReset", (_event, currentPassword) =>
requestReset(currentPassword));
ipcMain.handle("netcatty:appLock:requestPasswordChange", (_event, input) =>
requestPasswordChange(input));
ipcMain.handle("netcatty:appLock:setLocked", (_event, reason) => setLocked(reason));
ipcMain.handle("netcatty:appLock:requestUnlock", (_event, password) =>
requestUnlock(password));
ipcMain.handle("netcatty:appLock:getSystemUnlockStatus", () =>
getSystemUnlockStatus());
ipcMain.handle("netcatty:appLock:setSystemUnlockEnabled", (_event, input) =>
setSystemUnlockEnabled(input));
ipcMain.handle("netcatty:appLock:requestSystemUnlock", () =>
requestSystemUnlock());
ipcMain.handle("netcatty:appLock:reportActivity", () => reportActivity());
}
return {
getSettings,
getRuntimeState,
/** Subscribe to runtime lock/unlock transitions (used by tray deferral). */
subscribe: (listener) => runtimeBridge.subscribe(listener),
requestEnable,
requestDisable,
requestReset,
requestPasswordChange,
setTimeoutMinutes,
setLocked,
requestUnlock,
getSystemUnlockStatus,
setSystemUnlockEnabled,
requestSystemUnlock,
reportActivity,
protectWindow,
setWindowTitle,
registerHandlers,
syncIdleTimer,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,265 @@
const {
pbkdf2,
randomBytes,
timingSafeEqual,
} = require("node:crypto");
const APP_LOCK_TIMEOUT_OPTIONS_MINUTES = [0, 1, 5, 15, 30, 60];
const DEFAULT_APP_LOCK_SETTINGS = Object.freeze({
enabled: false,
timeoutMinutes: 15,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: null,
});
const APP_LOCK_VERIFIER_VERSION = 1;
const APP_LOCK_ALGORITHM = "PBKDF2-SHA256";
const APP_LOCK_HASH_ITERATIONS = 210000;
const APP_LOCK_MIN_ITERATIONS = 100000;
const APP_LOCK_SALT_BYTES = 16;
const APP_LOCK_HASH_BYTES = 32;
function cloneSettings(settings) {
return {
enabled: settings.enabled === true,
timeoutMinutes: normalizeAppLockTimeoutMinutes(settings.timeoutMinutes),
systemUnlockEnabled:
settings.systemUnlockEnabled === true &&
settings.enabled === true &&
settings.passwordVerifier !== null,
systemUnlockAutoPromptEnabled:
settings.systemUnlockAutoPromptEnabled === true &&
settings.systemUnlockEnabled === true &&
settings.enabled === true &&
settings.passwordVerifier !== null,
passwordVerifier: settings.passwordVerifier
? {
version: settings.passwordVerifier.version,
algorithm: settings.passwordVerifier.algorithm,
iterations: settings.passwordVerifier.iterations,
salt: settings.passwordVerifier.salt,
hash: settings.passwordVerifier.hash,
}
: null,
};
}
function isRecord(value) {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function decodeBase64Bytes(value) {
if (typeof value !== "string" || value.length === 0) return null;
try {
const bytes = Buffer.from(value, "base64");
if (bytes.length === 0) return null;
if (bytes.toString("base64") !== value) return null;
return bytes;
} catch {
return null;
}
}
function normalizeAppLockTimeoutMinutes(input) {
const value = typeof input === "string" && input.trim() !== "" ? Number(input) : input;
return APP_LOCK_TIMEOUT_OPTIONS_MINUTES.includes(value)
? value
: DEFAULT_APP_LOCK_SETTINGS.timeoutMinutes;
}
function normalizeAppLockPasswordVerifier(input) {
if (!isRecord(input)) return null;
if (input.version !== APP_LOCK_VERIFIER_VERSION) return null;
if (input.algorithm !== APP_LOCK_ALGORITHM) return null;
if (
typeof input.iterations !== "number" ||
!Number.isInteger(input.iterations) ||
input.iterations < APP_LOCK_MIN_ITERATIONS
) {
return null;
}
const saltBytes = decodeBase64Bytes(input.salt);
if (!saltBytes || saltBytes.length !== APP_LOCK_SALT_BYTES) return null;
const hashBytes = decodeBase64Bytes(input.hash);
if (!hashBytes || hashBytes.length !== APP_LOCK_HASH_BYTES) return null;
return {
version: APP_LOCK_VERIFIER_VERSION,
algorithm: APP_LOCK_ALGORITHM,
iterations: input.iterations,
salt: input.salt,
hash: input.hash,
};
}
function derivePasswordHash(password, saltBytes, iterations) {
return new Promise((resolve, reject) => {
pbkdf2(password, saltBytes, iterations, APP_LOCK_HASH_BYTES, "sha256", (error, derivedKey) => {
if (error) {
reject(error);
return;
}
resolve(derivedKey.toString("base64"));
});
});
}
function normalizeAppLockSettings(input) {
if (!isRecord(input)) return cloneSettings(DEFAULT_APP_LOCK_SETTINGS);
const timeoutMinutes = normalizeAppLockTimeoutMinutes(input.timeoutMinutes);
const passwordVerifier = normalizeAppLockPasswordVerifier(input.passwordVerifier);
const enabled = input.enabled === true && passwordVerifier !== null;
const systemUnlockEnabled = input.systemUnlockEnabled === true && enabled;
const systemUnlockAutoPromptEnabled = input.systemUnlockAutoPromptEnabled === true && systemUnlockEnabled;
return {
enabled,
timeoutMinutes,
systemUnlockEnabled,
systemUnlockAutoPromptEnabled,
passwordVerifier,
};
}
function canLockFromSettings(settings) {
const normalized = normalizeAppLockSettings(settings);
return normalized.enabled === true && normalized.passwordVerifier !== null;
}
/**
* Hide-to-tray / app-hide locks are automatic. timeoutMinutes === 0 is
* "Never lock automatically", so those background locks stay off. Startup
* and manual locks still apply whenever canLockFromSettings is true.
*/
function shouldLockOnBackgroundHide(settings) {
const normalized = normalizeAppLockSettings(settings);
return canLockFromSettings(normalized) && normalized.timeoutMinutes > 0;
}
async function createAppLockPasswordVerifier(password) {
if (typeof password !== "string" || password.length === 0) {
throw new Error("App lock password is required");
}
const saltBytes = randomBytes(APP_LOCK_SALT_BYTES);
return {
version: APP_LOCK_VERIFIER_VERSION,
algorithm: APP_LOCK_ALGORITHM,
iterations: APP_LOCK_HASH_ITERATIONS,
salt: saltBytes.toString("base64"),
hash: await derivePasswordHash(password, saltBytes, APP_LOCK_HASH_ITERATIONS),
};
}
async function verifyAppLockPassword(password, verifier) {
const normalized = normalizeAppLockPasswordVerifier(verifier);
if (typeof password !== "string" || password.length === 0 || !normalized) {
return false;
}
const saltBytes = decodeBase64Bytes(normalized.salt);
const hashBytes = decodeBase64Bytes(normalized.hash);
if (!saltBytes || !hashBytes) return false;
const candidateBytes = Buffer.from(
await derivePasswordHash(password, saltBytes, normalized.iterations),
"base64",
);
if (candidateBytes.length !== hashBytes.length) return false;
return timingSafeEqual(candidateBytes, hashBytes);
}
function createAppLockSettingsStore({
filePath,
readFile,
writeFile,
rename,
}) {
if (!filePath) {
throw new Error("createAppLockSettingsStore requires filePath");
}
if (typeof readFile !== "function") {
throw new Error("createAppLockSettingsStore requires readFile");
}
if (typeof writeFile !== "function") {
throw new Error("createAppLockSettingsStore requires writeFile");
}
let snapshot = cloneSettings(DEFAULT_APP_LOCK_SETTINGS);
// Serialize load/save so concurrent mutations cannot race on the same .tmp
// path or overwrite each other with stale snapshots (Codex P2).
let writeChain = Promise.resolve();
async function load() {
let raw;
try {
raw = await readFile(filePath, "utf8");
} catch (err) {
if (err && err.code === "ENOENT") {
snapshot = cloneSettings(DEFAULT_APP_LOCK_SETTINGS);
return cloneSettings(snapshot);
}
throw err;
}
try {
snapshot = normalizeAppLockSettings(JSON.parse(String(raw)));
} catch {
snapshot = cloneSettings(DEFAULT_APP_LOCK_SETTINGS);
}
return cloneSettings(snapshot);
}
async function save(nextSettings) {
const run = async () => {
const normalized = normalizeAppLockSettings(nextSettings);
const payload = `${JSON.stringify(normalized, null, 2)}\n`;
// Atomic replace: write unique temp then rename so a crash mid-write cannot
// leave a truncated file that load() would treat as DEFAULT (Codex P2).
if (typeof rename === "function") {
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tmpPath, payload, { mode: 0o600 });
await rename(tmpPath, filePath);
} else {
await writeFile(filePath, payload, { mode: 0o600 });
}
snapshot = normalized;
return cloneSettings(snapshot);
};
const pending = writeChain.then(run, run);
writeChain = pending.then(() => {}, () => {});
return pending;
}
function getSnapshot() {
return cloneSettings(snapshot);
}
return {
load,
save,
getSnapshot,
};
}
module.exports = {
APP_LOCK_TIMEOUT_OPTIONS_MINUTES,
DEFAULT_APP_LOCK_SETTINGS,
canLockFromSettings,
shouldLockOnBackgroundHide,
createAppLockPasswordVerifier,
createAppLockSettingsStore,
normalizeAppLockPasswordVerifier,
normalizeAppLockSettings,
normalizeAppLockTimeoutMinutes,
verifyAppLockPassword,
};

View File

@@ -0,0 +1,181 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
createAppLockPasswordVerifier,
createAppLockSettingsStore,
canLockFromSettings,
shouldLockOnBackgroundHide,
verifyAppLockPassword,
} = require("./appLockSettingsStore.cjs");
const fs = require("node:fs");
const path = require("node:path");
const settingsStoreSource = fs.readFileSync(path.join(__dirname, "appLockSettingsStore.cjs"), "utf8");
test("password hashing uses the asynchronous crypto API", () => {
assert.doesNotMatch(settingsStoreSource, /pbkdf2Sync/);
assert.match(settingsStoreSource, /\bpbkdf2\b/);
});
const VALID_VERIFIER = {
version: 1,
algorithm: "PBKDF2-SHA256",
iterations: 210000,
salt: Buffer.alloc(16, 1).toString("base64"),
hash: Buffer.alloc(32, 2).toString("base64"),
};
test("settings store loads disabled config when no persisted file exists", async () => {
const store = createAppLockSettingsStore({
filePath: "/tmp/app-lock-settings.json",
readFile: async () => {
const err = new Error("ENOENT");
err.code = "ENOENT";
throw err;
},
writeFile: async () => {},
});
const settings = await store.load();
assert.deepEqual(settings, {
enabled: false,
timeoutMinutes: 15,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: null,
});
assert.deepEqual(store.getSnapshot(), settings);
});
test("settings store normalizes malformed persisted config to disabled defaults", async () => {
const store = createAppLockSettingsStore({
filePath: "/tmp/app-lock-settings.json",
readFile: async () =>
JSON.stringify({
enabled: true,
timeoutMinutes: 999,
passwordVerifier: { hash: "invalid" },
}),
writeFile: async () => {},
});
const settings = await store.load();
assert.deepEqual(settings, {
enabled: false,
timeoutMinutes: 15,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: null,
});
});
test("settings store saves normalized settings and updates snapshot", async () => {
let writeCall = null;
const store = createAppLockSettingsStore({
filePath: "/tmp/app-lock-settings.json",
readFile: async () => {
const err = new Error("ENOENT");
err.code = "ENOENT";
throw err;
},
writeFile: async (filePath, content, options) => {
writeCall = { filePath, content, options };
},
});
const saved = await store.save({
enabled: true,
timeoutMinutes: 0,
systemUnlockEnabled: true,
systemUnlockAutoPromptEnabled: true,
passwordVerifier: VALID_VERIFIER,
});
assert.deepEqual(saved, {
enabled: true,
timeoutMinutes: 0,
systemUnlockEnabled: true,
systemUnlockAutoPromptEnabled: true,
passwordVerifier: VALID_VERIFIER,
});
assert.deepEqual(store.getSnapshot(), saved);
assert.deepEqual(writeCall, {
filePath: "/tmp/app-lock-settings.json",
content: `${JSON.stringify(saved, null, 2)}\n`,
options: { mode: 0o600 },
});
});
test("settings store clears system unlock when no valid verifier exists", async () => {
const store = createAppLockSettingsStore({
filePath: "/tmp/app-lock-settings.json",
readFile: async () =>
JSON.stringify({
enabled: true,
timeoutMinutes: 5,
systemUnlockEnabled: true,
systemUnlockAutoPromptEnabled: true,
passwordVerifier: null,
}),
writeFile: async () => {},
});
const settings = await store.load();
assert.deepEqual(settings, {
enabled: false,
timeoutMinutes: 5,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: null,
});
});
test("canLockFromSettings requires enabled and a verifier", async () => {
assert.equal(canLockFromSettings({ enabled: false, passwordVerifier: VALID_VERIFIER }), false);
assert.equal(canLockFromSettings({ enabled: true, passwordVerifier: null }), false);
assert.equal(canLockFromSettings({ enabled: true, passwordVerifier: { hash: "x" } }), false);
assert.equal(canLockFromSettings({ enabled: true, passwordVerifier: VALID_VERIFIER }), true);
});
test("shouldLockOnBackgroundHide requires an automatic timeout", () => {
assert.equal(shouldLockOnBackgroundHide({
enabled: true,
timeoutMinutes: 0,
passwordVerifier: VALID_VERIFIER,
}), false);
assert.equal(shouldLockOnBackgroundHide({
enabled: true,
timeoutMinutes: 5,
passwordVerifier: VALID_VERIFIER,
}), true);
assert.equal(shouldLockOnBackgroundHide({
enabled: false,
timeoutMinutes: 5,
passwordVerifier: VALID_VERIFIER,
}), false);
});
test("createAppLockPasswordVerifier stores a verifier that verifyAppLockPassword accepts", async () => {
const verifier = await createAppLockPasswordVerifier("correct horse battery staple");
assert.equal(verifier.version, 1);
assert.equal(verifier.algorithm, "PBKDF2-SHA256");
assert.ok(verifier.iterations >= 100000);
assert.notEqual(verifier.salt, "");
assert.notEqual(verifier.hash, "");
assert.equal(await verifyAppLockPassword("correct horse battery staple", verifier), true);
assert.equal(await verifyAppLockPassword("wrong password", verifier), false);
});
test("createAppLockPasswordVerifier preserves a whitespace-only password", async () => {
const verifier = await createAppLockPasswordVerifier(" ");
assert.equal(await verifyAppLockPassword(" ", verifier), true);
assert.equal(await verifyAppLockPassword("", verifier), false);
await assert.rejects(
() => createAppLockPasswordVerifier(""),
/App lock password is required/,
);
});

View File

@@ -0,0 +1,183 @@
const path = require("node:path");
const { execFile: defaultExecFile } = require("node:child_process");
const SYSTEM_AUTH_STATUS_TIMEOUT_MS = 15000;
const SYSTEM_AUTH_VERIFY_TIMEOUT_MS = 120000;
const UNAVAILABLE_ERRORS = new Set([
"DeviceNotPresent",
"NotConfiguredForUser",
"DisabledByPolicy",
"DeviceBusy",
"unavailable",
]);
const CANCELLED_ERRORS = new Set([
"Canceled",
"cancelled",
"RetriesExhausted",
]);
function unsupportedStatus() {
return {
supported: false,
available: false,
platform: "unsupported",
label: null,
reason: null,
};
}
function normalizeSystemAuthStatus(input, platform = "unsupported") {
if (!input || typeof input !== "object") return unsupportedStatus();
const normalizedPlatform = platform === "darwin" || platform === "win32" ? platform : "unsupported";
if (normalizedPlatform === "unsupported") return unsupportedStatus();
return {
supported: input.supported === true,
available: input.available === true,
platform: normalizedPlatform,
label: normalizedPlatform === "darwin" ? "Touch ID" : "Windows Hello",
reason: typeof input.reason === "string" && input.reason ? input.reason : null,
};
}
function normalizeSystemAuthUnlockResult(input) {
if (input && typeof input === "object" && input.ok === true) {
return { ok: true };
}
const error = input && typeof input === "object" ? input.error : null;
if (typeof error === "string") {
if (CANCELLED_ERRORS.has(error)) return { ok: false, error: "cancelled" };
if (UNAVAILABLE_ERRORS.has(error)) return { ok: false, error: "unavailable" };
if (error === "unsupported") return { ok: false, error: "unsupported" };
if (error === "failed") return { ok: false, error: "failed" };
}
return { ok: false, error: "failed" };
}
function parseHelperJson(stdout) {
try {
return JSON.parse(String(stdout || "").trim());
} catch {
return null;
}
}
function nativeWindowHandleToDecimal(handle) {
if (!Buffer.isBuffer(handle) || handle.length === 0) return null;
let value = 0n;
const byteCount = Math.min(handle.length, 8);
for (let index = byteCount - 1; index >= 0; index -= 1) {
value = (value << 8n) + BigInt(handle[index]);
}
return value > 0n ? value.toString(10) : null;
}
function runHelper(execFile, helperPath, args, timeout) {
return new Promise((resolve) => {
if (!helperPath) {
resolve({ ok: false, error: "unavailable" });
return;
}
execFile(helperPath, args, {
encoding: "utf8",
timeout,
windowsHide: true,
maxBuffer: 1024 * 1024,
}, (error, stdout) => {
if (error) {
resolve({ ok: false, error: "failed" });
return;
}
resolve(parseHelperJson(stdout));
});
});
}
function resolveDefaultHelperPath({
platform = process.platform,
arch = process.arch,
isPackaged = false,
resourcesPath = process.resourcesPath,
} = {}) {
if (platform !== "win32") return null;
if (isPackaged) {
return path.win32.join(resourcesPath, "windowsHello", "NetcattyWindowsHello.exe");
}
return path.join(__dirname, "windowsHelloHelper", "build", arch, "NetcattyWindowsHello.exe");
}
function createAppLockSystemAuthBridge({
platform = process.platform,
systemPreferences = null,
execFile = defaultExecFile,
helperPath = resolveDefaultHelperPath(),
getNativeWindowHandle = () => null,
} = {}) {
async function getMacStatus() {
const canPrompt = typeof systemPreferences?.canPromptTouchID === "function"
? systemPreferences.canPromptTouchID()
: false;
return normalizeSystemAuthStatus({
supported: true,
available: canPrompt === true,
reason: canPrompt === true ? null : "unavailable",
}, "darwin");
}
async function requestMacUnlock() {
if (typeof systemPreferences?.promptTouchID !== "function") {
return { ok: false, error: "unavailable" };
}
try {
await systemPreferences.promptTouchID("Unlock Netcatty");
return { ok: true };
} catch (err) {
// Electron exposes only a localized rejection message here. Since the
// status check already established availability, a rejected prompt is a
// user-visible cancellation regardless of system language.
void err;
return { ok: false, error: "cancelled" };
}
}
async function getWindowsStatus() {
const result = await runHelper(execFile, helperPath, ["status"], SYSTEM_AUTH_STATUS_TIMEOUT_MS);
return normalizeSystemAuthStatus({
supported: true,
available: result?.available === true,
reason: typeof result?.reason === "string" ? result.reason : null,
}, "win32");
}
async function requestWindowsUnlock() {
const hwnd = nativeWindowHandleToDecimal(getNativeWindowHandle());
if (!hwnd) return { ok: false, error: "unavailable" };
const result = await runHelper(
execFile,
helperPath,
["verify", "--hwnd", hwnd, "--message", "Unlock Netcatty"],
SYSTEM_AUTH_VERIFY_TIMEOUT_MS,
);
return normalizeSystemAuthUnlockResult(result);
}
return {
async getStatus() {
if (platform === "darwin") return getMacStatus();
if (platform === "win32") return getWindowsStatus();
return unsupportedStatus();
},
async requestUnlock() {
if (platform === "darwin") return requestMacUnlock();
if (platform === "win32") return requestWindowsUnlock();
return { ok: false, error: "unsupported" };
},
};
}
module.exports = {
createAppLockSystemAuthBridge,
nativeWindowHandleToDecimal,
normalizeSystemAuthStatus,
normalizeSystemAuthUnlockResult,
resolveDefaultHelperPath,
};

View File

@@ -0,0 +1,177 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
createAppLockSystemAuthBridge,
normalizeSystemAuthStatus,
normalizeSystemAuthUnlockResult,
resolveDefaultHelperPath,
} = require("./appLockSystemAuthBridge.cjs");
test("normalizes unsupported status", () => {
assert.deepEqual(normalizeSystemAuthStatus(null), {
supported: false,
available: false,
platform: "unsupported",
label: null,
reason: null,
});
});
test("macOS status and unlock use Touch ID systemPreferences", async () => {
let promptReason = null;
const bridge = createAppLockSystemAuthBridge({
platform: "darwin",
systemPreferences: {
canPromptTouchID: () => true,
promptTouchID: async (reason) => {
promptReason = reason;
},
},
});
assert.deepEqual(await bridge.getStatus(), {
supported: true,
available: true,
platform: "darwin",
label: "Touch ID",
reason: null,
});
assert.deepEqual(await bridge.requestUnlock(), { ok: true });
assert.equal(promptReason, "Unlock Netcatty");
});
test("macOS cancellation maps to cancelled", async () => {
const bridge = createAppLockSystemAuthBridge({
platform: "darwin",
systemPreferences: {
canPromptTouchID: () => true,
promptTouchID: async () => {
throw new Error("User canceled");
},
},
});
assert.deepEqual(await bridge.requestUnlock(), { ok: false, error: "cancelled" });
});
test("Windows status and unlock call helper with HWND", async () => {
const calls = [];
const bridge = createAppLockSystemAuthBridge({
platform: "win32",
helperPath: "C:\\NetcattyWindowsHello.exe",
getNativeWindowHandle: () => Buffer.from("8877665544332211", "hex"),
execFile: (file, args, options, callback) => {
calls.push({ file, args, options });
const command = args[0];
const stdout = command === "status"
? '{"supported":true,"available":true,"reason":null}'
: '{"ok":true}';
callback(null, stdout, "");
},
});
assert.deepEqual(await bridge.getStatus(), {
supported: true,
available: true,
platform: "win32",
label: "Windows Hello",
reason: null,
});
assert.deepEqual(await bridge.requestUnlock(), { ok: true });
assert.equal(calls[0].file, "C:\\NetcattyWindowsHello.exe");
assert.deepEqual(calls[0].args, ["status"]);
assert.equal(calls[0].options.windowsHide, true);
assert.equal(calls[0].options.timeout, 15000);
assert.deepEqual(calls[1].args, ["verify", "--hwnd", "1234605616436508552", "--message", "Unlock Netcatty"]);
assert.equal(calls[1].options.timeout, 120000);
});
test("Windows dev helper path follows the architecture-specific build output", () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
const originalArch = Object.getOwnPropertyDescriptor(process, "arch");
Object.defineProperty(process, "platform", { value: "win32" });
Object.defineProperty(process, "arch", { value: "x64" });
try {
const helperPath = resolveDefaultHelperPath({
isPackaged: false,
resourcesPath: "C:\\fake-electron-resources",
});
assert.match(helperPath, /windowsHelloHelper[\\/]build[\\/]x64[\\/]NetcattyWindowsHello\.exe$/);
} finally {
Object.defineProperty(process, "platform", originalPlatform);
Object.defineProperty(process, "arch", originalArch);
}
});
test("Windows packaged helper path uses the app resources directory", () => {
assert.equal(
resolveDefaultHelperPath({
platform: "win32",
isPackaged: true,
resourcesPath: "C:\\Netcatty\\resources",
arch: "x64",
}),
"C:\\Netcatty\\resources\\windowsHello\\NetcattyWindowsHello.exe",
);
});
test("macOS localized prompt rejection maps to cancelled", async () => {
const bridge = createAppLockSystemAuthBridge({
platform: "darwin",
systemPreferences: {
canPromptTouchID: () => true,
promptTouchID: async () => {
throw new Error("用户已取消认证");
},
},
});
assert.deepEqual(await bridge.requestUnlock(), { ok: false, error: "cancelled" });
});
test("Windows helper maps unavailable and cancelled states", async () => {
const bridge = createAppLockSystemAuthBridge({
platform: "win32",
helperPath: "helper.exe",
getNativeWindowHandle: () => Buffer.from("0100000000000000", "hex"),
execFile: (_file, args, _options, callback) => {
const stdout = args[0] === "status"
? '{"supported":true,"available":false,"reason":"DisabledByPolicy"}'
: '{"ok":false,"error":"RetriesExhausted"}';
callback(null, stdout, "");
},
});
assert.deepEqual(await bridge.getStatus(), {
supported: true,
available: false,
platform: "win32",
label: "Windows Hello",
reason: "DisabledByPolicy",
});
assert.deepEqual(await bridge.requestUnlock(), { ok: false, error: "cancelled" });
});
test("Windows helper failure maps to failed", async () => {
const bridge = createAppLockSystemAuthBridge({
platform: "win32",
helperPath: "helper.exe",
getNativeWindowHandle: () => Buffer.from("0100000000000000", "hex"),
execFile: (_file, _args, _options, callback) => {
callback(new Error("boom"), "", "boom");
},
});
assert.deepEqual(await bridge.requestUnlock(), { ok: false, error: "failed" });
});
test("normalizes Windows helper result enums", () => {
assert.deepEqual(normalizeSystemAuthUnlockResult({ ok: true }), { ok: true });
assert.deepEqual(normalizeSystemAuthUnlockResult({ ok: false, error: "Canceled" }), { ok: false, error: "cancelled" });
assert.deepEqual(normalizeSystemAuthUnlockResult({ ok: false, error: "RetriesExhausted" }), { ok: false, error: "cancelled" });
assert.deepEqual(normalizeSystemAuthUnlockResult({ ok: false, error: "DeviceBusy" }), { ok: false, error: "unavailable" });
assert.deepEqual(normalizeSystemAuthUnlockResult({ ok: false, error: "NotConfiguredForUser" }), { ok: false, error: "unavailable" });
assert.deepEqual(normalizeSystemAuthUnlockResult({ ok: false, error: "unexpected" }), { ok: false, error: "failed" });
});

View File

@@ -0,0 +1,36 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const helperSource = fs.readFileSync(
path.join(__dirname, "windowsHelloHelper", "NetcattyWindowsHello.cpp"),
"utf8",
);
test("Windows Hello helper uses desktop HWND interop", () => {
assert.match(helperSource, /IUserConsentVerifierInterop/);
assert.match(helperSource, /RequestVerificationForWindowAsync/);
assert.match(helperSource, /--hwnd/);
assert.doesNotMatch(helperSource, /UserConsentVerifier::RequestVerificationAsync/);
});
test("Windows Hello helper maps all expected verifier states", () => {
for (const state of [
"Available",
"DeviceNotPresent",
"NotConfiguredForUser",
"DisabledByPolicy",
"DeviceBusy",
"RetriesExhausted",
"Canceled",
"Verified",
]) {
assert.match(helperSource, new RegExp(state));
}
});
test("Windows Hello helper uses a multi-threaded apartment for blocking waits", () => {
assert.match(helperSource, /init_apartment\(winrt::apartment_type::multi_threaded\)/);
assert.doesNotMatch(helperSource, /init_apartment\(winrt::apartment_type::single_threaded\)/);
});

View File

@@ -0,0 +1,651 @@
/**
* Auto-Update Bridge
*
* Wraps electron-updater to provide IPC-driven update checks, downloads, and
* install-on-quit. Designed around a "prompt" model: the renderer asks to
* check, then explicitly triggers download and install.
*
* Linux packages use electron-updater's package-manager path when the
* electron-builder package-type marker is present. Unmarked Linux builds
* (including snap and development runs) get a graceful fallback so the
* renderer can offer a manual "open GitHub releases" link.
*/
let _deps = null;
/**
* Read the persisted auto-update preference from a JSON file in userData.
* Returns true (default) if the file doesn't exist or is unreadable.
*/
function readAutoUpdatePreference() {
try {
const { app } = _deps?.electronModule || {};
if (!app) return true;
const path = require('path');
const fs = require('fs');
const prefPath = path.join(app.getPath('userData'), 'auto-update-pref.json');
const data = JSON.parse(fs.readFileSync(prefPath, 'utf8'));
return data.enabled !== false;
} catch {
return true; // default to enabled
}
}
/**
* Persist the auto-update preference to a JSON file in userData.
*/
function writeAutoUpdatePreference(enabled) {
try {
const { app } = _deps?.electronModule || {};
if (!app) return;
const path = require('path');
const fs = require('fs');
const prefPath = path.join(app.getPath('userData'), 'auto-update-pref.json');
fs.writeFileSync(prefPath, JSON.stringify({ enabled }), 'utf8');
} catch (err) {
console.warn('[AutoUpdate] Failed to write preference:', err?.message || err);
}
}
const SUPPORTED_LINUX_PACKAGE_TYPES = new Set(["deb", "rpm", "pacman"]);
/**
* Identify the Linux package format written by electron-builder into the
* packaged resources directory. AppImage does not have this marker; its
* runtime exposes the APPIMAGE environment variable instead.
*/
function getLinuxPackageType() {
if (process.env.APPIMAGE) return "AppImage";
if (typeof process.resourcesPath !== "string") return null;
try {
const fs = require("fs");
const path = require("path");
const packageType = fs.readFileSync(path.join(process.resourcesPath, "package-type"), "utf8").trim();
return SUPPORTED_LINUX_PACKAGE_TYPES.has(packageType) ? packageType : null;
} catch {
return null;
}
}
/**
* Returns true when the current packaging format supports electron-updater
* (macOS zip/dmg, Windows NSIS, Linux AppImage/deb/rpm/pacman).
*/
function isAutoUpdateSupported() {
if (process.platform === "darwin" || process.platform === "win32") {
return true;
}
return process.platform === "linux" && getLinuxPackageType() !== null;
}
/** Lazily resolved autoUpdater — avoids importing electron-updater in
* contexts where native modules might not be available. */
let _autoUpdater = null;
/** Guard against duplicate listener registration */
let _listenersRegistered = false;
/** Track whether a download is in progress to distinguish download errors from check errors */
let _isDownloading = false;
/** Track whether quitAndInstall has entered the package installation phase */
let _isInstalling = false;
/** Track whether a checkForUpdates call is in flight (set before call, cleared on result event) */
let _isChecking = false;
/**
* Snapshot of the last known update status so newly opened windows can hydrate
* without waiting for the next IPC event.
* @type {{ status: 'idle' | 'downloading' | 'ready' | 'error', percent: number, error: string | null, version: string | null, isChecking: boolean }}
*/
let _lastStatus = { status: 'idle', percent: 0, error: null, version: null, isChecking: false };
function getAutoUpdater() {
if (_autoUpdater) return _autoUpdater;
try {
const { autoUpdater } = require("electron-updater");
autoUpdater.autoDownload = readAutoUpdatePreference();
autoUpdater.autoInstallOnAppQuit = false;
// Silence the default electron-log transport (we log ourselves).
autoUpdater.logger = null;
_autoUpdater = autoUpdater;
return autoUpdater;
} catch (err) {
console.error("[AutoUpdate] Failed to load electron-updater:", err?.message || err);
return null;
}
}
/**
* Register persistent global IPC event listeners for auto-download flow.
* Called once in init(). Forwards electron-updater events to the renderer
* even when no manual download was initiated.
*/
function setupGlobalListeners() {
if (_listenersRegistered) return;
const updater = getAutoUpdater();
if (!updater) return;
_listenersRegistered = true;
updater.on("update-not-available", () => {
_isChecking = false;
// Reset stale status so late-opening windows don't hydrate from a
// previous 'error' or 'ready' snapshot after a "no update" check.
_lastStatus = { status: 'idle', percent: 0, error: null, version: null, isChecking: false };
broadcastToAllWindows("netcatty:update:update-not-available", {});
});
updater.on("update-available", (info) => {
_isChecking = false;
// Only track as downloading when autoDownload is enabled — otherwise no
// download will actually start and the status would be stuck at 0%.
// Use 'available' so late-opening windows can still hydrate the version.
const willDownload = updater.autoDownload !== false;
_isDownloading = willDownload;
_lastStatus = { status: willDownload ? 'downloading' : 'available', percent: 0, error: null, version: info.version || null, isChecking: false };
broadcastToAllWindows("netcatty:update:update-available", {
version: info.version || "",
releaseNotes: typeof info.releaseNotes === "string" ? info.releaseNotes : "",
releaseDate: info.releaseDate || null,
});
});
updater.on("download-progress", (info) => {
_lastStatus.percent = Math.round(info.percent ?? 0);
broadcastToAllWindows("netcatty:update:download-progress", {
percent: info.percent ?? 0,
bytesPerSecond: info.bytesPerSecond ?? 0,
transferred: info.transferred ?? 0,
total: info.total ?? 0,
});
});
updater.on("update-downloaded", () => {
_isDownloading = false;
_lastStatus = { ..._lastStatus, status: 'ready', percent: 100 };
broadcastToAllWindows("netcatty:update:downloaded");
});
updater.on("error", (err) => {
_isChecking = false;
// Only broadcast download-phase errors; check-phase errors (e.g. network failures
// during checkForUpdates) are not download failures and must not set autoDownloadStatus.
// Install errors are also broadcast: Linux package managers report a cancelled
// elevation prompt or a failed command after update-downloaded has already
// cleared _isDownloading.
if (!_isDownloading && !_isInstalling) {
_lastStatus = { ..._lastStatus, isChecking: false };
console.warn("[AutoUpdate] Check-phase error (not broadcast to renderer):", err?.message || err);
return;
}
_isDownloading = false;
const errorMsg = err?.message || "Unknown update error";
if (_isInstalling) {
_isInstalling = false;
cancelQuittingForUpdateWatchdog();
setQuittingForUpdate(false);
}
_lastStatus = { ..._lastStatus, status: 'error', error: errorMsg };
broadcastToAllWindows("netcatty:update:error", {
error: errorMsg,
});
});
console.log("[AutoUpdate] Global listeners registered");
}
/**
* Trigger an automatic update check after a delay.
* No-op on platforms that don't support auto-update (for example Linux snap
* or an unmarked development build).
* Called from main process after the main window is created.
*
* @param {number} delayMs - Milliseconds to wait before checking (default: 5000)
*/
let _autoCheckTimer = null;
function startAutoCheck(delayMs = 5000) {
if (!isAutoUpdateSupported()) {
console.log("[AutoUpdate] Platform does not support auto-update, skipping auto-check");
return;
}
// Cancel any existing timer to avoid duplicate concurrent checks
// (e.g. from multiple windows initializing or re-enable toggle).
cancelAutoCheck();
_autoCheckTimer = setTimeout(async () => {
_autoCheckTimer = null;
const updater = getAutoUpdater();
if (!updater) {
console.warn("[AutoUpdate] Auto-check skipped — updater not available");
return;
}
// Respect autoDownload flag — the renderer may have disabled it via IPC
// before this timer fires.
if (updater.autoDownload === false) {
console.log("[AutoUpdate] Auto-check skipped — autoDownload is disabled");
return;
}
_isChecking = true;
_lastStatus = { ..._lastStatus, isChecking: true };
try {
console.log("[AutoUpdate] Starting automatic update check...");
await updater.checkForUpdates();
} catch (err) {
_isChecking = false;
_lastStatus = { ..._lastStatus, isChecking: false };
console.warn("[AutoUpdate] Auto-check failed:", err?.message || err);
}
}, delayMs);
}
/**
* Cancel a pending startAutoCheck timer. Called when the renderer triggers
* a manual check to avoid racing with the queued auto-check.
*/
function cancelAutoCheck() {
if (_autoCheckTimer) {
clearTimeout(_autoCheckTimer);
_autoCheckTimer = null;
}
}
/**
* Flip the windowManager "quitting for update" flag, swallowing the case where
* the window manager module isn't available. Used by the install handler to
* commit the app to a clean quit before quitAndInstall, and to roll back if the
* install never actually quits (#1215).
*/
function setQuittingForUpdate(enabled) {
try {
const windowManager = require("./windowManager.cjs");
windowManager.setQuittingForUpdate(!!enabled);
} catch {
// ignore — window manager may not be available
}
}
/**
* The webContents for usable dirty-editor windows. Used by the install handler
* to ask every renderer that can own editor tabs about unsaved work before
* committing to a quit. Targets registered editor owners specifically (not
* getAllWindows()[0]) so we never query tray/settings windows, whose renderers
* don't participate in the dirty-editor protocol.
*/
function getDirtyEditorWebContentsList() {
try {
const windowManager = require("./windowManager.cjs");
const windows = typeof windowManager.getDirtyEditorWindows === "function"
? windowManager.getDirtyEditorWindows()
: typeof windowManager.getMainWindows === "function"
? windowManager.getMainWindows()
: [windowManager.getMainWindow?.()].filter(Boolean);
return windows
.filter((win) => win && !win.isDestroyed?.())
.map((win) => win.webContents)
.filter((wc) => wc && !wc.isDestroyed?.() && !wc.isCrashed?.());
} catch {
return [];
}
}
/**
* Tell the renderer that the update can't install yet because there are unsaved
* editors. The renderer surfaces a toast asking the user to save, then click
* "Restart Now" again.
*
* Broadcast to ALL windows, not just the main one: the install can be triggered
* from the Settings window's "Restart to Update" button, and that's the focused
* window the user is looking at. Sending only to the (possibly hidden/behind)
* main window would make the click appear to do nothing (#1215 review). The
* unsaved editors live in the main window, but every window surfaces the same
* "save first" notice so it lands wherever the user is.
*/
function notifyNeedsSave() {
broadcastToAllWindows("netcatty:update:needs-save");
}
/** Max time to wait for the renderer's unsaved-editors reply before the install
* fails open and proceeds (matches the before-quit guard timeout). */
const INSTALL_DIRTY_CHECK_TIMEOUT_MS = 5000;
/**
* Ask the main-window renderer whether it has unsaved editor changes, reusing
* the shared dirty-editor round-trip. Resolves false (fail open) if the helper
* is unavailable, so a missing module can never block an install. `ipcMain` is
* the instance passed to registerHandlers; the helper needs it to listen for
* the reply.
*/
function queryDirtyEditorsSafe(webContents, ipcMain) {
try {
const { queryDirtyEditors } = require("./dirtyEditorGuard.cjs");
return queryDirtyEditors(webContents, INSTALL_DIRTY_CHECK_TIMEOUT_MS, { ipcMain });
} catch (err) {
console.warn("[AutoUpdate] dirty-editor guard unavailable:", err?.message || err);
return Promise.resolve(false);
}
}
/**
* If quitAndInstall doesn't lead to the app actually quitting (it returns
* without app.quit(), e.g. on a Squirrel.Mac follow-up error or a stale
* downloaded file), the quitting-for-update flags would stay set and
* permanently bypass close-to-tray + the dirty-editor quit guard. This
* watchdog clears them if we're still running after a grace period.
*
* The grace period is deliberately long. On macOS quitAndInstall() can return
* while Squirrel.Mac is still pulling the already-downloaded ZIP from the local
* update server before it actually closes the windows; for a large/slow update
* that second stage can take well over 10s. If the watchdog cleared isQuitting
* during that window, the eventual native quit would hit a *non*-quitting
* close-to-tray handler and get stranded again — the exact #1215 failure. So we
* only roll back after a window long enough that the app is realistically stuck,
* not merely slow. The cost of waiting longer is just that close-to-tray stays
* bypassed a bit longer in the rare genuine-failure case (#1215 review).
*/
let _quittingForUpdateWatchdog = null;
const QUITTING_FOR_UPDATE_WATCHDOG_MS = 60000;
function cancelQuittingForUpdateWatchdog() {
if (_quittingForUpdateWatchdog) {
clearTimeout(_quittingForUpdateWatchdog);
_quittingForUpdateWatchdog = null;
}
}
function scheduleQuittingForUpdateWatchdog() {
if (_quittingForUpdateWatchdog) {
clearTimeout(_quittingForUpdateWatchdog);
}
_quittingForUpdateWatchdog = setTimeout(() => {
_quittingForUpdateWatchdog = null;
_isInstalling = false;
// Still alive after the grace period — the install did not quit the app.
console.warn("[AutoUpdate] App still running after quitAndInstall; clearing quitting-for-update state");
setQuittingForUpdate(false);
}, QUITTING_FOR_UPDATE_WATCHDOG_MS);
// Don't let the watchdog keep the event loop (and thus the process) alive —
// if the app is otherwise ready to quit, the timer must not block it.
if (typeof _quittingForUpdateWatchdog.unref === "function") {
_quittingForUpdateWatchdog.unref();
}
}
/**
* Cancel an install after the main quit guard finds unsaved editor changes.
* The main process owns the dirty-editor decision, but the install lifecycle
* state lives here and must be released together with the window-manager flag.
*/
function cancelPendingInstall() {
if (!_isInstalling) return;
_isInstalling = false;
cancelQuittingForUpdateWatchdog();
setQuittingForUpdate(false);
}
function init(deps) {
_deps = deps;
setupGlobalListeners();
}
/**
* Broadcast an IPC event to all non-destroyed BrowserWindows.
* Ensures both the main window and settings window always receive
* auto-update events.
* @param {string} channel
* @param {unknown} [payload]
*/
function broadcastToAllWindows(channel, payload) {
try {
const { BrowserWindow } = _deps?.electronModule || {};
if (!BrowserWindow) return;
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed()) {
if (payload !== undefined) {
win.webContents.send(channel, payload);
} else {
win.webContents.send(channel);
}
}
}
} catch (err) {
console.warn("[AutoUpdate] broadcastToAllWindows failed:", err?.message || err);
}
}
function registerHandlers(ipcMain) {
// ---- Check for updates ------------------------------------------------
ipcMain.handle("netcatty:update:check", async () => {
// Cancel any pending auto-check to prevent concurrent checkForUpdates()
// calls — electron-updater rejects them and surfaces false errors.
cancelAutoCheck();
if (!isAutoUpdateSupported()) {
return {
available: false,
supported: false,
error: "Auto-update is not supported on this platform/package format.",
};
}
const updater = getAutoUpdater();
if (!updater) {
return {
available: false,
supported: false,
error: "Update module failed to load.",
};
}
// If a check is already in flight (e.g. from startAutoCheck), don't
// start a concurrent one — electron-updater rejects it and surfaces a
// confusing error. Return a sentinel so the renderer knows to wait.
if (_isChecking) {
return { available: false, supported: true, checking: true };
}
// If a download is already in progress or the update is ready to install,
// skip the check entirely — calling checkForUpdates() while downloading
// can cause electron-updater to error, which corrupts the download state
// and forces the user to download manually (GitHub issue #522).
if (_isDownloading) {
return { available: true, supported: true, downloading: true, version: _lastStatus.version };
}
if (_lastStatus.status === 'ready') {
return { available: true, supported: true, ready: true, version: _lastStatus.version };
}
try {
_isChecking = true;
_lastStatus = { ..._lastStatus, isChecking: true };
const result = await updater.checkForUpdates();
if (!result || !result.updateInfo) {
return { available: false, supported: true };
}
const { version, releaseNotes, releaseDate } = result.updateInfo;
// Compare with current version using semver ordering.
// Only report an update when the feed version is strictly newer,
// avoiding false positives for pre-release or nightly builds.
const { app } = _deps?.electronModule || {};
const currentVersion = app?.getVersion?.() || "0.0.0";
const isNewer = currentVersion.localeCompare(version, undefined, { numeric: true, sensitivity: 'base' }) < 0;
if (!isNewer) {
return { available: false, supported: true };
}
return {
available: true,
supported: true,
version,
releaseNotes: typeof releaseNotes === "string" ? releaseNotes : "",
releaseDate: releaseDate || null,
};
} catch (err) {
_isChecking = false;
_lastStatus = { ..._lastStatus, isChecking: false };
console.warn("[AutoUpdate] Check failed:", err?.message || err);
return {
available: false,
supported: true,
error: err?.message || "Unknown update check error",
};
}
});
// ---- Download update ---------------------------------------------------
ipcMain.handle("netcatty:update:download", async () => {
if (_isDownloading) {
return { success: true };
}
const updater = getAutoUpdater();
if (!updater) {
return { success: false, error: "Update module not available." };
}
try {
_isDownloading = true;
_lastStatus = { ..._lastStatus, status: 'downloading', percent: 0, error: null };
await updater.downloadUpdate();
return { success: true };
} catch (err) {
_isDownloading = false;
_lastStatus = { ..._lastStatus, status: 'error', error: err?.message || "Download failed", percent: 0 };
// Don't broadcast here — the global updater "error" listener already handles it
console.error("[AutoUpdate] Download failed:", err?.message || err);
return { success: false, error: err?.message || "Download failed" };
}
});
// ---- Get current update status (for late-opening windows) ---------------
ipcMain.handle("netcatty:update:getStatus", () => {
return { ..._lastStatus };
});
// ---- Install (quit & install) ------------------------------------------
ipcMain.handle("netcatty:update:install", async () => {
const updater = getAutoUpdater();
if (!updater) return;
if (_isInstalling) return;
// Check for unsaved editors BEFORE committing to a quit (#1215 review).
//
// On macOS quitAndInstall() closes windows first and only then fires
// before-quit. Once setQuittingForUpdate(true) lets the main window
// actually close (instead of hiding to tray), the before-quit dirty-editor
// guard can run after the window is already gone — isReachableByUser is
// false, so it commits the quit and silently drops unsaved SFTP edits.
//
// So we ask the renderer here, while the window and renderer are still
// alive. If there's unsaved work in any main window, abort the install
// (don't touch the quitting flags, don't quitAndInstall) and tell the
// renderer to prompt the user to save; they can click "Restart Now" again
// afterwards. If no main window is reachable (no window / crashed
// renderer) there's no user to ask, so we install directly — matching the
// before-quit fail-open path.
const editorWebContents = getDirtyEditorWebContentsList();
if (editorWebContents.length > 0) {
const dirtyResults = await Promise.all(
editorWebContents.map((webContents) => queryDirtyEditorsSafe(webContents, ipcMain)),
);
if (dirtyResults.some(Boolean)) {
// Broadcast so the notice reaches whichever window the user clicked
// from (main or Settings), not just the main window we queried.
notifyNeedsSave();
return;
}
}
// Another request may have completed its dirty-editor check while this
// request was waiting. Only one request may commit the app to a quit.
if (_isInstalling) return;
// Commit the app to a real quit BEFORE quitAndInstall fires app.quit().
// Without this the in-place install silently fails (#1215): the main-window
// close handler hides to tray when close-to-tray is on, so the process
// stays alive and Squirrel.Mac's ShipIt helper — which waits on the parent
// PID to die before swapping the bundle — ends up in launchd "pending
// spawn" limbo and never installs. setQuittingForUpdate(true) sets
// isQuitting so close-to-tray is bypassed and the window actually closes.
_isInstalling = true;
setQuittingForUpdate(true);
// On macOS, the system tray keeps the app process alive even after all
// windows are closed, which prevents quitAndInstall from completing.
// Destroy the tray (and its panel window) before quitting so the app
// can exit cleanly and the installer can proceed.
if (process.platform === "darwin") {
try {
const globalShortcutBridge = require("./globalShortcutBridge.cjs");
globalShortcutBridge.cleanup();
} catch {
// ignore — bridge may not be available
}
}
try {
updater.quitAndInstall(false, true);
} catch (err) {
// quitAndInstall threw synchronously — the app will NOT quit. Roll back
// the quitting-for-update flags so later closes/quits behave normally
// instead of permanently bypassing close-to-tray + the dirty-editor
// guard (#1215 review).
console.error("[AutoUpdate] quitAndInstall failed:", err?.message || err);
_isInstalling = false;
cancelQuittingForUpdateWatchdog();
setQuittingForUpdate(false);
return;
}
// Linux package-manager failures emit the updater error synchronously from
// quitAndInstall(). The error listener clears _isInstalling in that case,
// so do not install a watchdog after the failure has already been handled.
if (!_isInstalling) return;
// quitAndInstall can also fail to quit asynchronously (e.g. Squirrel.Mac's
// follow-up check errors, or a stale/missing downloaded file) — it returns
// without app.quit() ever firing. A watchdog clears the flags if we're
// still alive shortly after, so the app doesn't get stuck in a state where
// every window close bypasses close-to-tray and the dirty-editor guard.
scheduleQuittingForUpdateWatchdog();
});
// ---- Get auto-update preference -----------------------------------------
ipcMain.handle("netcatty:update:getAutoUpdate", () => {
return { enabled: readAutoUpdatePreference() };
});
// ---- Enable/disable auto-update ----------------------------------------
let _prevAutoDownloadEnabled = readAutoUpdatePreference();
ipcMain.handle("netcatty:update:setAutoUpdate", (_event, { enabled }) => {
const wasEnabled = _prevAutoDownloadEnabled;
_prevAutoDownloadEnabled = !!enabled;
const updater = getAutoUpdater();
if (updater) {
updater.autoDownload = !!enabled;
console.log("[AutoUpdate] autoDownload set to:", !!enabled);
}
// Persist so the preference survives app restarts
writeAutoUpdatePreference(!!enabled);
if (!enabled) {
cancelAutoCheck();
} else if (!wasEnabled && !_isChecking) {
// Only re-schedule when actually re-enabling (not on every mount sync),
// to avoid duplicate checks from multiple windows initializing.
// Skip if a check is already in flight to prevent concurrent calls.
startAutoCheck(2000);
}
return { success: true };
});
console.log("[AutoUpdate] Handlers registered");
}
module.exports = {
init,
registerHandlers,
isAutoUpdateSupported,
startAutoCheck,
cancelPendingInstall,
};

View File

@@ -0,0 +1,878 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const Module = require("node:module");
const tempDirBridge = require("./tempDirBridge.cjs");
const BRIDGE_PATH = require.resolve("./autoUpdateBridge.cjs");
const WINDOW_MANAGER_PATH = require.resolve("./windowManager.cjs");
const GLOBAL_SHORTCUT_PATH = require.resolve("./globalShortcutBridge.cjs");
const DIRTY_EDITOR_GUARD_PATH = require.resolve("./dirtyEditorGuard.cjs");
// electron-updater pulls in native/electron-only code, so it can't be required
// in a plain `node --test` process. We intercept the bare `electron-updater`
// specifier and the bridge's lazy sibling requires at load time and hand back
// lightweight fakes. The patch stays installed for the whole test body because
// the bridge resolves electron-updater / windowManager / globalShortcutBridge
// lazily at IPC-invoke time, not at module load.
const ELECTRON_UPDATER_ID = "electron-updater";
/**
* Run `fn` with electron-updater, windowManager, and globalShortcutBridge
* replaced by the supplied fakes. The fakes are also exposed to `fn` so it can
* assert on their interactions. Restores Module._load and the bridge cache on
* exit so tests stay isolated.
*/
async function withMocks({ autoUpdater, autoUpdaterExports, windowManager, globalShortcutBridge, dirtyEditorGuard, browserWindows } = {}, fn) {
const fakeAutoUpdater = autoUpdater || {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
quitAndInstall() {},
};
const fakeWindowManager = windowManager || {
calls: [],
setQuittingForUpdate(value) {
this.calls.push(value);
},
isQuittingForUpdate() {
return this.calls[this.calls.length - 1] === true;
},
};
const fakeGlobalShortcut = globalShortcutBridge || {
cleanupCount: 0,
cleanup() {
this.cleanupCount += 1;
},
};
// Default: no dirty-editor guard override. The bridge requires the real
// dirtyEditorGuard.cjs (harmless — it only resolves ipcMain lazily, and the
// install handler only reaches it when there's a reachable main window). Most
// tests don't supply a main window, so the guard is never invoked.
const fakeDirtyEditorGuard = dirtyEditorGuard;
const originalLoad = Module._load;
Module._load = function patchedLoad(request, parent, isMain) {
if (request === ELECTRON_UPDATER_ID) {
// autoUpdaterExports lets a test simulate a broken electron-updater
// (e.g. {} with no autoUpdater) so getAutoUpdater() resolves to null.
return autoUpdaterExports !== undefined
? autoUpdaterExports
: { autoUpdater: fakeAutoUpdater };
}
if (parent && parent.filename === BRIDGE_PATH) {
const resolved = path.resolve(path.dirname(BRIDGE_PATH), request);
const withExt = resolved.endsWith(".cjs") ? resolved : `${resolved}.cjs`;
if (withExt === WINDOW_MANAGER_PATH) return fakeWindowManager;
if (withExt === GLOBAL_SHORTCUT_PATH) return fakeGlobalShortcut;
if (withExt === DIRTY_EDITOR_GUARD_PATH && fakeDirtyEditorGuard) {
return fakeDirtyEditorGuard;
}
}
return originalLoad.call(this, request, parent, isMain);
};
delete require.cache[BRIDGE_PATH];
try {
const bridge = require("./autoUpdateBridge.cjs");
// Provide a minimal electronModule so readAutoUpdatePreference and
// broadcastToAllWindows don't throw. getPath returns a throwaway dir; the
// pref read falls back to its default when the file is absent.
const fakeApp = {
getPath: () => path.join("/", "tmp", "nc-autoupdate-test"),
getVersion: () => "1.1.17",
};
bridge.init({
electronModule: {
app: fakeApp,
// browserWindows lets a test observe broadcastToAllWindows (used by the
// needs-save notice). Defaults to none.
BrowserWindow: { getAllWindows: () => browserWindows || [] },
},
});
// Await so the Module._load patch stays installed for the *entire* test
// body, including after the now-async install handler yields on its first
// `await` (otherwise the lazy windowManager/dirtyEditorGuard requires would
// resolve the real modules once the finally below restored Module._load).
return await fn({ bridge, fakeAutoUpdater, fakeWindowManager, fakeGlobalShortcut });
} finally {
Module._load = originalLoad;
delete require.cache[BRIDGE_PATH];
}
}
/**
* A fake BrowserWindow for broadcastToAllWindows() (used by the needs-save
* notice). Records every channel sent to its webContents so a test can assert
* whether netcatty:update:needs-save was broadcast.
*/
function makeBroadcastWindow() {
const sentChannels = [];
return {
sentChannels,
isDestroyed() {
return false;
},
webContents: {
send(channel) {
sentChannels.push(channel);
},
},
};
}
/**
* Build a fake windowManager that also exposes a main window whose webContents
* the install handler can query for dirty editors. `sentChannels` records every
* webContents.send() on the *main window* (i.e. the dirty-editor query), kept
* separate from broadcast windows so tests can distinguish the two.
*/
function makeWindowManagerWithMainWindow() {
const sentChannels = [];
const webContents = {
send(channel) {
sentChannels.push(channel);
},
isDestroyed() {
return false;
},
isCrashed() {
return false;
},
};
return {
calls: [],
sentChannels,
webContents,
setQuittingForUpdate(value) {
this.calls.push(value);
},
isQuittingForUpdate() {
return this.calls[this.calls.length - 1] === true;
},
getMainWindow() {
return {
webContents,
isDestroyed() {
return false;
},
};
},
getMainWindows() {
return [this.getMainWindow()];
},
};
}
function makeWindowManagerWithMainWindows(count, options = {}) {
const windows = Array.from({ length: count }, (_unused, index) => {
const sentChannels = [];
const webContents = {
id: index + 1,
sentChannels,
send(channel) {
sentChannels.push(channel);
},
isDestroyed() {
return false;
},
isCrashed() {
return false;
},
};
return {
webContents,
isDestroyed() {
return false;
},
};
});
return {
calls: [],
windows,
appContentWindows: options.appContentWindows || windows,
dirtyEditorWindows: options.dirtyEditorWindows || windows,
setQuittingForUpdate(value) {
this.calls.push(value);
},
isQuittingForUpdate() {
return this.calls[this.calls.length - 1] === true;
},
getMainWindow() {
return windows[0] || null;
},
getMainWindows() {
return windows;
},
getAppContentWindows() {
return this.appContentWindows;
},
getDirtyEditorWindows() {
return this.dirtyEditorWindows;
},
};
}
async function withLinuxPackageEnvironment({ packageType, appImage }, fn) {
const packageDir = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "auto-update-test-"));
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const resourcesPathDescriptor = Object.getOwnPropertyDescriptor(process, "resourcesPath");
const previousAppImage = process.env.APPIMAGE;
try {
if (packageType) {
fs.writeFileSync(path.join(packageDir, "package-type"), `${packageType}\n`, "utf8");
}
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
Object.defineProperty(process, "resourcesPath", { value: packageDir, configurable: true });
if (appImage) {
process.env.APPIMAGE = path.join(packageDir, "Netcatty.AppImage");
} else {
delete process.env.APPIMAGE;
}
return await fn();
} finally {
if (platformDescriptor) {
Object.defineProperty(process, "platform", platformDescriptor);
} else {
delete process.platform;
}
if (resourcesPathDescriptor) {
Object.defineProperty(process, "resourcesPath", resourcesPathDescriptor);
} else {
delete process.resourcesPath;
}
if (previousAppImage === undefined) {
delete process.env.APPIMAGE;
} else {
process.env.APPIMAGE = previousAppImage;
}
fs.rmSync(packageDir, { recursive: true, force: true });
}
}
/**
* Minimal ipcMain stand-in that captures the handlers the bridge registers so a
* test can invoke a single channel directly.
*/
function makeIpcMain() {
const handlers = new Map();
return {
handle(channel, handler) {
handlers.set(channel, handler);
},
on() {},
invoke(channel, ...args) {
const handler = handlers.get(channel);
if (!handler) throw new Error(`No handler registered for ${channel}`);
return handler({}, ...args);
},
has(channel) {
return handlers.has(channel);
},
};
}
test("recognizes packaged Linux FPM formats as auto-update capable", async () => {
for (const packageType of ["deb", "rpm", "pacman"]) {
await withLinuxPackageEnvironment({ packageType }, async () => {
await withMocks({}, async ({ bridge }) => {
assert.equal(bridge.isAutoUpdateSupported(), true, packageType);
});
});
}
});
test("allows the update check to reach electron-updater for a packaged Linux deb", async () => {
let checkCalls = 0;
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
checkForUpdates() {
checkCalls += 1;
return Promise.resolve({
updateInfo: {
version: "1.1.18",
releaseNotes: "",
releaseDate: null,
},
});
},
};
await withLinuxPackageEnvironment({ packageType: "deb" }, async () => {
await withMocks({ autoUpdater }, async ({ bridge, fakeAutoUpdater }) => {
fakeAutoUpdater.autoDownload = false;
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
const result = await ipcMain.invoke("netcatty:update:check");
assert.equal(checkCalls, 1);
assert.equal(result.supported, true);
assert.equal(result.available, true);
assert.equal(result.version, "1.1.18");
});
});
});
test("keeps the manual-update fallback for an unmarked Linux package", async () => {
await withLinuxPackageEnvironment({}, async () => {
await withMocks({}, async ({ bridge }) => {
assert.equal(bridge.isAutoUpdateSupported(), false);
});
});
});
test("keeps AppImage auto-update support on Linux", async () => {
await withLinuxPackageEnvironment({ appImage: true }, async () => {
await withMocks({}, async ({ bridge }) => {
assert.equal(bridge.isAutoUpdateSupported(), true);
});
});
});
test("install handler marks quitting-for-update before quitAndInstall", async () => {
const order = [];
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
quitAndInstall(isSilent, isForceRunAfter) {
order.push("quitAndInstall");
autoUpdater._installArgs = [isSilent, isForceRunAfter];
},
};
const fakeWindowManager = {
calls: [],
setQuittingForUpdate(value) {
order.push("setQuittingForUpdate");
this.calls.push(value);
},
isQuittingForUpdate() {
return this.calls[this.calls.length - 1] === true;
},
};
await withMocks({ autoUpdater, windowManager: fakeWindowManager }, async ({ bridge, fakeGlobalShortcut }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
await ipcMain.invoke("netcatty:update:install");
// The flag must be set with `true`...
assert.deepEqual(fakeWindowManager.calls, [true]);
// ...and it must happen BEFORE quitAndInstall fires app.quit(), otherwise the
// close-to-tray / before-quit guards would already be racing the quit (#1215).
assert.equal(order[0], "setQuittingForUpdate");
assert.ok(order.indexOf("setQuittingForUpdate") < order.indexOf("quitAndInstall"));
// Only macOS needs the tray destroyed before quitAndInstall; other
// platforms clean it up from the normal will-quit handler.
assert.equal(fakeGlobalShortcut.cleanupCount, process.platform === "darwin" ? 1 : 0);
assert.equal(order.includes("quitAndInstall"), true);
});
});
test("install handler is a no-op when the updater fails to load", async () => {
const fakeWindowManager = {
calls: [],
setQuittingForUpdate(value) {
this.calls.push(value);
},
isQuittingForUpdate() {
return false;
},
};
// electron-updater exports no `autoUpdater` => getAutoUpdater() returns null,
// so the handler must return early WITHOUT committing the app to a quit. Doing
// so otherwise would leave isQuitting=true and break close-to-tray even though
// no install actually started.
await withMocks({ autoUpdaterExports: {}, windowManager: fakeWindowManager }, async ({ bridge, fakeGlobalShortcut }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
await ipcMain.invoke("netcatty:update:install");
assert.deepEqual(fakeWindowManager.calls, []);
assert.equal(fakeGlobalShortcut.cleanupCount, 0);
});
});
test("install handler rolls back quitting-for-update when quitAndInstall throws", async () => {
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
quitAndInstall() {
throw new Error("boom");
},
};
const fakeWindowManager = {
calls: [],
setQuittingForUpdate(value) {
this.calls.push(value);
},
isQuittingForUpdate() {
return this.calls[this.calls.length - 1] === true;
},
};
await withMocks({ autoUpdater, windowManager: fakeWindowManager }, async ({ bridge }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
await ipcMain.invoke("netcatty:update:install");
// First set true (commit), then reset to false on the synchronous throw so
// the app doesn't get stuck bypassing close-to-tray / the quit guard (#1215).
assert.deepEqual(fakeWindowManager.calls, [true, false]);
assert.equal(fakeWindowManager.isQuittingForUpdate(), false);
});
});
test("install handler reports package-manager failures and restores the app state", async () => {
const listeners = new Map();
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on(event, listener) {
listeners.set(event, listener);
},
quitAndInstall() {
listeners.get("error")(new Error("authorization cancelled"));
},
};
const fakeWindowManager = {
calls: [],
setQuittingForUpdate(value) {
this.calls.push(value);
},
isQuittingForUpdate() {
return this.calls[this.calls.length - 1] === true;
},
};
const broadcastWindow = makeBroadcastWindow();
await withLinuxPackageEnvironment({ packageType: "deb" }, async () => {
await withMocks({
autoUpdater,
windowManager: fakeWindowManager,
browserWindows: [broadcastWindow],
}, async ({ bridge, fakeGlobalShortcut }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
listeners.get("update-available")({ version: "1.1.18" });
listeners.get("update-downloaded")();
assert.equal((await ipcMain.invoke("netcatty:update:getStatus")).status, "ready");
await ipcMain.invoke("netcatty:update:install");
assert.deepEqual(fakeWindowManager.calls, [true, false]);
assert.equal(fakeWindowManager.isQuittingForUpdate(), false);
assert.equal(fakeGlobalShortcut.cleanupCount, 0);
assert.deepEqual(await ipcMain.invoke("netcatty:update:getStatus"), {
status: "error",
percent: 100,
error: "authorization cancelled",
version: "1.1.18",
isChecking: false,
});
assert.equal(broadcastWindow.sentChannels.includes("netcatty:update:error"), true);
});
});
});
test("install handler ignores concurrent install requests", async () => {
let installCalls = 0;
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
quitAndInstall() {
installCalls += 1;
},
};
const fakeWindowManager = makeWindowManagerWithMainWindow();
const dirtyResolvers = [];
const fakeDirtyEditorGuard = {
queryDirtyEditors() {
return new Promise((resolve) => dirtyResolvers.push(resolve));
},
};
const originalSetTimeout = global.setTimeout;
let watchdogFn = null;
global.setTimeout = (fn) => {
watchdogFn = fn;
return { unref() {} };
};
try {
await withMocks({
autoUpdater,
windowManager: fakeWindowManager,
dirtyEditorGuard: fakeDirtyEditorGuard,
}, async ({ bridge }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
const firstInstall = ipcMain.invoke("netcatty:update:install");
const secondInstall = ipcMain.invoke("netcatty:update:install");
assert.equal(dirtyResolvers.length, 2);
dirtyResolvers.forEach((resolve) => resolve(false));
await Promise.all([firstInstall, secondInstall]);
assert.equal(installCalls, 1);
assert.deepEqual(fakeWindowManager.calls, [true]);
watchdogFn?.();
assert.equal(fakeWindowManager.isQuittingForUpdate(), false);
});
} finally {
global.setTimeout = originalSetTimeout;
}
});
test("cancelPendingInstall releases the install guard for an immediate retry", async () => {
let installCalls = 0;
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
quitAndInstall() {
installCalls += 1;
},
};
const fakeWindowManager = {
calls: [],
setQuittingForUpdate(value) {
this.calls.push(value);
},
isQuittingForUpdate() {
return this.calls[this.calls.length - 1] === true;
},
};
const originalSetTimeout = global.setTimeout;
const originalClearTimeout = global.clearTimeout;
global.setTimeout = () => ({ unref() {} });
global.clearTimeout = () => {};
try {
await withMocks({ autoUpdater, windowManager: fakeWindowManager }, async ({ bridge }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
await ipcMain.invoke("netcatty:update:install");
assert.equal(installCalls, 1);
assert.deepEqual(fakeWindowManager.calls, [true]);
// Simulate main.cjs cancelling the quit after a dirty-editor result.
bridge.cancelPendingInstall();
assert.deepEqual(fakeWindowManager.calls, [true, false]);
await ipcMain.invoke("netcatty:update:install");
assert.equal(installCalls, 2);
assert.deepEqual(fakeWindowManager.calls, [true, false, true]);
});
} finally {
global.setTimeout = originalSetTimeout;
global.clearTimeout = originalClearTimeout;
}
});
test("install handler watchdog clears quitting-for-update if the app never quits", async () => {
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
// Returns without ever quitting the app (simulates a Squirrel follow-up
// failure / stale download where app.quit() is never reached).
quitAndInstall() {},
};
const fakeWindowManager = {
calls: [],
setQuittingForUpdate(value) {
this.calls.push(value);
},
isQuittingForUpdate() {
return this.calls[this.calls.length - 1] === true;
},
};
// Capture the watchdog timer instead of waiting for the real delay.
const originalSetTimeout = global.setTimeout;
let watchdogFn = null;
global.setTimeout = (fn) => {
watchdogFn = fn;
// Return a fake timer handle with an unref() no-op so the bridge can call it.
return { unref() {} };
};
try {
await withMocks({ autoUpdater, windowManager: fakeWindowManager }, async ({ bridge }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
await ipcMain.invoke("netcatty:update:install");
// Committed to quit, watchdog scheduled but not yet fired.
assert.deepEqual(fakeWindowManager.calls, [true]);
assert.equal(typeof watchdogFn, "function");
// Fire the watchdog — the app is still alive, so it must clear the flag.
watchdogFn();
assert.deepEqual(fakeWindowManager.calls, [true, false]);
assert.equal(fakeWindowManager.isQuittingForUpdate(), false);
});
} finally {
global.setTimeout = originalSetTimeout;
}
});
// ---------------------------------------------------------------------------
// #1215 P1: the install handler must check for unsaved editors BEFORE
// committing to a quit. On macOS quitAndInstall() closes the window first and
// only then fires before-quit, so the before-quit dirty guard can run after the
// window is gone and silently drop unsaved SFTP edits. Checking up front (while
// the renderer is alive) is the fix.
// ---------------------------------------------------------------------------
test("install handler aborts and notifies when the renderer reports dirty editors", async () => {
const order = [];
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
quitAndInstall() {
order.push("quitAndInstall");
},
};
const fakeWindowManager = makeWindowManagerWithMainWindow();
const originalSetQuitting = fakeWindowManager.setQuittingForUpdate;
fakeWindowManager.setQuittingForUpdate = function (value) {
order.push("setQuittingForUpdate");
originalSetQuitting.call(this, value);
};
// queryDirtyEditors reports unsaved work.
let queriedWebContents = null;
const fakeDirtyEditorGuard = {
queryDirtyEditors(webContents) {
order.push("queryDirtyEditors");
queriedWebContents = webContents;
return Promise.resolve(true);
},
};
// Two windows (e.g. main + settings) so we can assert the needs-save notice is
// broadcast to BOTH, not just the queried main window (#1215 review).
const win1 = makeBroadcastWindow();
const win2 = makeBroadcastWindow();
await withMocks(
{
autoUpdater,
windowManager: fakeWindowManager,
dirtyEditorGuard: fakeDirtyEditorGuard,
browserWindows: [win1, win2],
},
async ({ bridge, fakeGlobalShortcut }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
await ipcMain.invoke("netcatty:update:install");
// Dirty editors → the install must be fully aborted:
// - no quitAndInstall, no setQuittingForUpdate, no tray cleanup
assert.equal(order.includes("quitAndInstall"), false);
assert.equal(order.includes("setQuittingForUpdate"), false);
assert.deepEqual(fakeWindowManager.calls, []);
assert.equal(fakeGlobalShortcut.cleanupCount, 0);
// - every window is told to prompt the user to save (broadcast needs-save)
assert.equal(win1.sentChannels.includes("netcatty:update:needs-save"), true);
assert.equal(win2.sentChannels.includes("netcatty:update:needs-save"), true);
// - the dirty check ran first, against the main window's webContents
assert.equal(order[0], "queryDirtyEditors");
assert.equal(queriedWebContents, fakeWindowManager.webContents);
},
);
});
test("install handler checks every registered dirty-editor window before installing", async () => {
const order = [];
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
quitAndInstall() {
order.push("quitAndInstall");
},
};
const fakeWindowManager = makeWindowManagerWithMainWindows(1);
const peerWebContents = {
id: 2,
sentChannels: [],
send(channel) {
this.sentChannels.push(channel);
},
isDestroyed() {
return false;
},
isCrashed() {
return false;
},
};
const peerWindow = {
webContents: peerWebContents,
isDestroyed() {
return false;
},
};
const lifecycleOnlyWindow = {
webContents: {
id: 3,
isDestroyed: () => false,
isCrashed: () => false,
},
isDestroyed: () => false,
};
fakeWindowManager.appContentWindows = [
fakeWindowManager.windows[0],
peerWindow,
lifecycleOnlyWindow,
];
fakeWindowManager.dirtyEditorWindows = [fakeWindowManager.windows[0], peerWindow];
const queriedWebContents = [];
const fakeDirtyEditorGuard = {
queryDirtyEditors(webContents) {
order.push(`queryDirtyEditors:${webContents.id}`);
queriedWebContents.push(webContents);
return Promise.resolve(webContents.id === 2);
},
};
const win = makeBroadcastWindow();
await withMocks(
{
autoUpdater,
windowManager: fakeWindowManager,
dirtyEditorGuard: fakeDirtyEditorGuard,
browserWindows: [win],
},
async ({ bridge, fakeGlobalShortcut }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
await ipcMain.invoke("netcatty:update:install");
assert.deepEqual(queriedWebContents, fakeWindowManager.dirtyEditorWindows.map((window) => window.webContents));
assert.equal(order.includes("quitAndInstall"), false);
assert.deepEqual(fakeWindowManager.calls, []);
assert.equal(fakeGlobalShortcut.cleanupCount, 0);
assert.equal(win.sentChannels.includes("netcatty:update:needs-save"), true);
},
);
});
test("install handler proceeds to quitAndInstall when there are no dirty editors", async () => {
const order = [];
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
quitAndInstall() {
order.push("quitAndInstall");
},
};
const fakeWindowManager = makeWindowManagerWithMainWindow();
const originalSetQuitting = fakeWindowManager.setQuittingForUpdate;
fakeWindowManager.setQuittingForUpdate = function (value) {
order.push("setQuittingForUpdate");
originalSetQuitting.call(this, value);
};
// queryDirtyEditors reports a clean editor state.
const fakeDirtyEditorGuard = {
queryDirtyEditors() {
order.push("queryDirtyEditors");
return Promise.resolve(false);
},
};
const win = makeBroadcastWindow();
// Capture the watchdog so the test doesn't wait on the real 10s timer.
const originalSetTimeout = global.setTimeout;
global.setTimeout = () => ({ unref() {} });
try {
await withMocks(
{
autoUpdater,
windowManager: fakeWindowManager,
dirtyEditorGuard: fakeDirtyEditorGuard,
browserWindows: [win],
},
async ({ bridge, fakeGlobalShortcut }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
await ipcMain.invoke("netcatty:update:install");
// Clean editors → install runs as before:
// dirty check first, then commit-to-quit, then quitAndInstall.
assert.equal(order[0], "queryDirtyEditors");
assert.deepEqual(fakeWindowManager.calls, [true]);
assert.ok(order.indexOf("setQuittingForUpdate") < order.indexOf("quitAndInstall"));
assert.equal(order.includes("quitAndInstall"), true);
assert.equal(fakeGlobalShortcut.cleanupCount, process.platform === "darwin" ? 1 : 0);
// No needs-save broadcast when nothing is dirty.
assert.equal(win.sentChannels.includes("netcatty:update:needs-save"), false);
},
);
} finally {
global.setTimeout = originalSetTimeout;
}
});
test("install handler installs directly when no main window is reachable", async () => {
const order = [];
const autoUpdater = {
autoDownload: true,
autoInstallOnAppQuit: false,
logger: undefined,
on() {},
quitAndInstall() {
order.push("quitAndInstall");
},
};
// Default fake windowManager has no getMainWindow() → getMainWebContents()
// returns null → there's no user to ask, so the install must proceed without
// ever calling queryDirtyEditors (matches the before-quit fail-open path).
let dirtyCheckCalled = false;
const fakeDirtyEditorGuard = {
queryDirtyEditors() {
dirtyCheckCalled = true;
return Promise.resolve(true);
},
};
const originalSetTimeout = global.setTimeout;
global.setTimeout = () => ({ unref() {} });
try {
await withMocks(
{ autoUpdater, dirtyEditorGuard: fakeDirtyEditorGuard },
async ({ bridge, fakeWindowManager }) => {
const ipcMain = makeIpcMain();
bridge.registerHandlers(ipcMain);
await ipcMain.invoke("netcatty:update:install");
assert.equal(dirtyCheckCalled, false);
assert.deepEqual(fakeWindowManager.calls, [true]);
assert.equal(order.includes("quitAndInstall"), true);
},
);
} finally {
global.setTimeout = originalSetTimeout;
}
});

View File

@@ -0,0 +1,83 @@
/**
* BoringSSL Diffie-Hellman group compatibility shim.
*
* Electron ships with BoringSSL, which no longer exposes some standard MODP
* groups through the *named* `crypto.createDiffieHellmanGroup()` API — notably
* the 1024-bit Oakley Group 2 ("modp2") that backs the SSH
* `diffie-hellman-group1-sha1` key exchange. ssh2 calls
* `createDiffieHellmanGroup('modp2')` for that kex, so on Electron it throws
* "Unknown DH group" and legacy network devices that only speak group1-sha1
* cannot be reached (issue #1035).
*
* The underlying DH math still works on BoringSSL via `createDiffieHellman()`
* with an explicit prime, so this shim wraps `createDiffieHellmanGroup` to fall
* back to the well-known prime constants when (and only when) the runtime can't
* resolve a group by name. On OpenSSL builds the original call succeeds and the
* fallback is never used, so behavior is unchanged there.
*
* IMPORTANT: ssh2 destructures `createDiffieHellmanGroup` at module load, so this
* must be installed BEFORE ssh2 (or any bridge that requires it) is loaded.
*/
const crypto = require("node:crypto");
// Standard MODP groups (RFC 2409 / RFC 3526), generator 2. These primes are
// public constants and are byte-identical to Node's built-in groups, so the
// fallback produces the exact same key exchange the named group would have.
// Only groups that a runtime might drop yet ssh2 still requests need to live
// here; modp14/16/18 remain available on BoringSSL so they are intentionally
// omitted.
const MODP_GROUP_PRIMES = {
// Oakley Group 2 — RFC 2409, 1024-bit. ssh2: diffie-hellman-group1-sha1.
modp2:
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" +
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD" +
"EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" +
"E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" +
"EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381" +
"FFFFFFFFFFFFFFFF",
};
const MODP_GENERATOR = Buffer.from([0x02]);
function createGroupFromPrime(name) {
const primeHex = MODP_GROUP_PRIMES[name];
if (!primeHex) return null;
return crypto.createDiffieHellman(Buffer.from(primeHex, "hex"), MODP_GENERATOR);
}
/**
* Wrap `target.createDiffieHellmanGroup` so missing named groups fall back to an
* explicit-prime DiffieHellman. Idempotent. Returns true if it installed the
* shim, false if it was already installed (or there was nothing to wrap).
* @param {{ createDiffieHellmanGroup?: Function }} [target] defaults to the crypto module
*/
function installBoringSslDhCompat(target = crypto) {
const original = target.createDiffieHellmanGroup;
if (typeof original !== "function" || original.__boringSslDhCompat) {
return false;
}
const wrapped = function createDiffieHellmanGroup(name) {
try {
return original(name);
} catch (err) {
const fallback = createGroupFromPrime(name);
if (!fallback) throw err;
return fallback;
}
};
wrapped.__boringSslDhCompat = true;
try {
target.createDiffieHellmanGroup = wrapped;
} catch {
// The property may be read-only on some runtimes; force it via defineProperty.
Object.defineProperty(target, "createDiffieHellmanGroup", {
value: wrapped,
configurable: true,
writable: true,
});
}
return true;
}
module.exports = { installBoringSslDhCompat, createGroupFromPrime, MODP_GROUP_PRIMES };

View File

@@ -0,0 +1,76 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const {
installBoringSslDhCompat,
MODP_GROUP_PRIMES,
} = require("./boringSslDhCompat.cjs");
test("falls back to an explicit-prime DH when the runtime lacks a named group", () => {
// Simulate BoringSSL: the named lookup throws "Unknown DH group".
const target = {
createDiffieHellmanGroup(name) {
throw new Error(`Unknown DH group: ${name}`);
},
};
assert.equal(installBoringSslDhCompat(target), true);
const dh = target.createDiffieHellmanGroup("modp2");
// The fallback group uses the exact RFC 2409 group1 prime.
assert.equal(dh.getPrime("hex").toUpperCase(), MODP_GROUP_PRIMES.modp2);
// And it performs a real, correct DH exchange.
const peer = crypto.createDiffieHellman(
Buffer.from(MODP_GROUP_PRIMES.modp2, "hex"),
Buffer.from([2]),
);
const ourPublic = dh.generateKeys();
const peerPublic = peer.generateKeys();
assert.ok(dh.computeSecret(peerPublic).equals(peer.computeSecret(ourPublic)));
});
test("uses the runtime's group when the name resolves (no fallback)", () => {
let calls = 0;
const sentinel = Symbol("native-group");
const target = {
createDiffieHellmanGroup() {
calls += 1;
return sentinel;
},
};
installBoringSslDhCompat(target);
assert.equal(target.createDiffieHellmanGroup("modp2"), sentinel);
assert.equal(calls, 1);
});
test("rethrows the original error for groups it cannot back", () => {
const target = {
createDiffieHellmanGroup() {
throw new Error("Unknown DH group");
},
};
installBoringSslDhCompat(target);
assert.throws(() => target.createDiffieHellmanGroup("modp-nonexistent"), /Unknown DH group/);
});
test("install is idempotent", () => {
const target = {
createDiffieHellmanGroup() {
return null;
},
};
assert.equal(installBoringSslDhCompat(target), true);
assert.equal(installBoringSslDhCompat(target), false);
});
test("on this (OpenSSL) runtime the real modp2 still works through the shim", () => {
// Sanity check against the actual crypto module: installing must not break the
// normal path where the runtime resolves the group by name.
const localCrypto = require("node:crypto");
installBoringSslDhCompat(localCrypto);
const dh = localCrypto.createDiffieHellmanGroup("modp2");
assert.equal(dh.getPrime("hex").toUpperCase(), MODP_GROUP_PRIMES.modp2);
});

View File

@@ -0,0 +1,124 @@
"use strict";
// An abandoned OPEN cannot be cancelled in SSH, but its physical connection
// may own unrelated terminals. Block only additional opens until its callback
// or transport closure settles; retain no unbounded retry queue.
const abandonedOpens = new WeakMap();
function abandonOpen(client, token) {
let state = abandonedOpens.get(client);
if (!state) {
state = { tokens: new Set(), onClose: null };
state.onClose = () => {
if (abandonedOpens.get(client) === state) abandonedOpens.delete(client);
state.tokens.clear();
};
abandonedOpens.set(client, state);
client.once?.("close", state.onClose);
}
state.tokens.add(token);
}
function settleAbandonedOpen(client, token) {
const state = abandonedOpens.get(client);
if (!state || !state.tokens.delete(token) || state.tokens.size > 0) return;
abandonedOpens.delete(client);
client.removeListener?.("close", state.onClose);
}
const DEFAULT_SFTP_CHANNEL_OPEN_TIMEOUT_MS = 10_000;
function closeSftpChannel(channel) {
if (!channel) return;
try { channel.once?.("error", () => {}); } catch { /* ignore */ }
try { channel.end?.(); } catch { /* ignore */ }
try { channel.close?.(); } catch { /* ignore */ }
try { channel.destroy?.(); } catch { /* ignore */ }
}
function createSftpOpenAbortError(signal) {
const reason = signal?.reason;
const error = reason instanceof Error ? reason : new Error("SFTP channel open was aborted");
if (!error.code) error.code = "ABORT_ERR";
return error;
}
function openBoundedSftpChannel(sshClient, options = {}) {
if (!sshClient || typeof sshClient.sftp !== "function") {
return Promise.resolve(null);
}
const signal = options.signal || null;
if (signal?.aborted) return Promise.reject(createSftpOpenAbortError(signal));
if (abandonedOpens.has(sshClient)) {
const error = new Error("A previous SFTP channel open is still pending on this connection");
error.code = "SFTP_CHANNEL_OPEN_PENDING";
return Promise.reject(error);
}
const setTimeoutFn = options.setTimeoutFn || setTimeout;
const clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
const timeoutMs = Math.max(
1,
Number(options.timeoutMs) || DEFAULT_SFTP_CHANNEL_OPEN_TIMEOUT_MS,
);
return new Promise((resolve, reject) => {
let settled = false;
let requestPending = false;
const token = Symbol("sftp-open");
let timer = null;
const cleanup = () => {
if (timer) clearTimeoutFn(timer);
timer = null;
signal?.removeEventListener?.("abort", onAbort);
};
const finish = (error, channel = null, { abandon = false } = {}) => {
if (settled) {
closeSftpChannel(channel);
return false;
}
settled = true;
cleanup();
if (error && channel) closeSftpChannel(channel);
if (abandon && requestPending) abandonOpen(sshClient, token);
if (error) reject(error);
else resolve(channel);
return true;
};
const onAbort = () => finish(createSftpOpenAbortError(signal), null, {
abandon: true,
});
if (signal?.aborted) {
finish(createSftpOpenAbortError(signal));
return;
}
signal?.addEventListener?.("abort", onAbort, { once: true });
// Keep the correctness deadline referenced until the open settles. An
// unreferenced timer can let Node exit with this promise still pending.
timer = setTimeoutFn(() => {
const error = new Error(`SFTP channel open timed out after ${timeoutMs}ms`);
error.code = "SFTP_CHANNEL_OPEN_TIMEOUT";
finish(error, null, { abandon: true });
}, timeoutMs);
try {
requestPending = true;
sshClient.sftp((error, channel) => {
requestPending = false;
settleAbandonedOpen(sshClient, token);
if (error) finish(error, channel || null);
else finish(null, channel || null);
});
} catch (error) {
requestPending = false;
settleAbandonedOpen(sshClient, token);
finish(error);
}
});
}
module.exports = {
DEFAULT_SFTP_CHANNEL_OPEN_TIMEOUT_MS,
closeSftpChannel,
openBoundedSftpChannel,
};

View File

@@ -0,0 +1,183 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter, getEventListeners } = require("node:events");
const { openBoundedSftpChannel } = require("./boundedSftpOpen.cjs");
function trackedTimerApi() {
const active = new Set();
return {
active,
setTimeoutFn(callback, delay) {
let timer;
timer = setTimeout(() => {
active.delete(timer);
callback();
}, delay);
active.add(timer);
return timer;
},
clearTimeoutFn(timer) {
clearTimeout(timer);
active.delete(timer);
},
};
}
function createChannel() {
const channel = new EventEmitter();
channel.endCalls = 0;
channel.closeCalls = 0;
channel.end = () => { channel.endCalls += 1; };
channel.close = () => { channel.closeCalls += 1; };
return channel;
}
test("bounded SFTP open times out and closes a late channel", async () => {
let callback;
let invalidations = 0;
const timers = trackedTimerApi();
const sshClient = {
sftp(next) { callback = next; },
destroy() { invalidations += 1; },
};
const result = openBoundedSftpChannel(sshClient, { timeoutMs: 5, ...timers });
assert.equal(timers.active.size, 1);
assert.equal([...timers.active][0].hasRef(), true);
await assert.rejects(result, (error) => error.code === "SFTP_CHANNEL_OPEN_TIMEOUT");
assert.equal(invalidations, 0, "one channel request must not destroy the shared SSH transport");
assert.equal(timers.active.size, 0);
const channel = createChannel();
callback(null, channel);
assert.ok(channel.endCalls > 0 || channel.closeCalls > 0);
assert.equal(timers.active.size, 0);
});
test("bounded SFTP open cancellation settles immediately and closes a late channel", async () => {
let callback;
let invalidations = 0;
const timers = trackedTimerApi();
const controller = new AbortController();
const sshClient = {
sftp(next) { callback = next; },
destroy() { invalidations += 1; },
};
const result = openBoundedSftpChannel(sshClient, {
signal: controller.signal,
...timers,
});
assert.equal(timers.active.size, 1);
assert.equal([...timers.active][0].hasRef(), true);
controller.abort(new Error("cancelled"));
await assert.rejects(result, /cancelled/);
assert.equal(invalidations, 0, "one channel request must not destroy the shared SSH transport");
assert.equal(timers.active.size, 0);
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
const channel = createChannel();
callback(new Error("late open failure"), channel);
assert.ok(channel.endCalls > 0 || channel.closeCalls > 0);
assert.equal(timers.active.size, 0);
});
for (const reason of ["timeout", "cancel"]) {
test(`abandoned ${reason} opens cannot accumulate requests on a shared transport`, async () => {
const callbacks = [];
let physicalCloses = 0;
const sshClient = new EventEmitter();
sshClient.sftp = callback => { callbacks.push(callback); };
sshClient.end = sshClient.destroy = () => { physicalCloses++; };
const controller = new AbortController();
const opening = openBoundedSftpChannel(sshClient, { timeoutMs: 5, signal: controller.signal });
if (reason === "cancel") controller.abort(new Error("cancelled"));
await assert.rejects(opening);
for (let attempt = 0; attempt < 3; attempt++) {
await assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 }));
}
assert.equal(callbacks.length, 1, "retries must not allocate more abandoned SSH channel requests");
assert.equal(physicalCloses, 0, "existing terminal and SFTP channels remain connected");
const late = createChannel();
callbacks[0](null, late);
assert.ok(late.endCalls > 0 || late.closeCalls > 0);
const next = openBoundedSftpChannel(sshClient, { timeoutMs: 50 });
assert.equal(callbacks.length, 2, "a settled abandoned request must release admission");
const healthy = createChannel();
callbacks[1](null, healthy);
assert.equal(await next, healthy);
assert.equal(physicalCloses, 0);
});
}
test("healthy SFTP channel openings remain parallel", async () => {
const callbacks = [];
const sshClient = { sftp(callback) { callbacks.push(callback); } };
const first = openBoundedSftpChannel(sshClient, { timeoutMs: 50 });
const second = openBoundedSftpChannel(sshClient, { timeoutMs: 50 });
assert.equal(callbacks.length, 2);
const channels = [createChannel(), createChannel()];
callbacks.forEach((callback, index) => callback(null, channels[index]));
assert.deepEqual(await Promise.all([first, second]), channels);
});
test("bounded SFTP open removes cancellation listeners after success and failure", async () => {
for (const outcome of ["success", "error"]) {
const controller = new AbortController();
const timers = trackedTimerApi();
let callback;
const sshClient = { sftp(next) { callback = next; } };
const result = openBoundedSftpChannel(sshClient, {
signal: controller.signal,
...timers,
});
assert.equal(timers.active.size, 1);
assert.equal([...timers.active][0].hasRef(), true);
if (outcome === "success") callback(null, createChannel());
else callback(new Error("open failed"));
if (outcome === "success") assert.ok(await result);
else await assert.rejects(result, /open failed/);
assert.equal(timers.active.size, 0);
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
}
});
test("bounded SFTP open converts synchronous setup errors into rejections", async () => {
const sshClient = { sftp() { throw new Error("sync failure"); } };
await assert.rejects(openBoundedSftpChannel(sshClient), /sync failure/);
});
test("transport closure releases abandoned-open bookkeeping without a late callback", async () => {
const sshClient = new EventEmitter();
const callbacks = [];
sshClient.sftp = callback => callbacks.push(callback);
await assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 }));
assert.equal(sshClient.listenerCount("close"), 1);
sshClient.emit("close");
assert.equal(sshClient.listenerCount("close"), 0);
const next = openBoundedSftpChannel(sshClient, { timeoutMs: 50 });
const channel = createChannel();
callbacks[1](null, channel);
assert.equal(await next, channel);
const late = createChannel();
callbacks[0](null, late);
assert.ok(late.endCalls > 0);
});
test("all abandoned parallel opens must settle before admitting a new one", async () => {
const sshClient = new EventEmitter();
const callbacks = [];
sshClient.sftp = callback => callbacks.push(callback);
const first = openBoundedSftpChannel(sshClient, { timeoutMs: 5 });
const second = openBoundedSftpChannel(sshClient, { timeoutMs: 5 });
await Promise.all([assert.rejects(first), assert.rejects(second)]);
assert.equal(sshClient.listenerCount("close"), 1);
callbacks[0](new Error("first failed"));
await assert.rejects(openBoundedSftpChannel(sshClient), error => error.code === "SFTP_CHANNEL_OPEN_PENDING");
callbacks[1](new Error("second failed"));
assert.equal(sshClient.listenerCount("close"), 0);
const next = openBoundedSftpChannel(sshClient);
callbacks[2](null, createChannel());
await next;
});

View File

@@ -0,0 +1,353 @@
"use strict";
const { invalidateSshTransport } = require("./sshTransportInvalidation.cjs");
const DEFAULT_SSH_CHANNEL_OPEN_TIMEOUT_MS = 30_000;
const DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_RETRIES = 3;
const DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_BACKOFF_MS = 150;
function closeLateChannel(channel) {
if (!channel || typeof channel === "number") return;
try { channel.once?.("error", () => {}); } catch { /* ignore */ }
try { channel.close?.(); } catch { /* ignore */ }
try { channel.end?.(); } catch { /* ignore */ }
try { channel.destroy?.(); } catch { /* ignore */ }
}
function channelAbortError(signal, label) {
const reason = signal?.reason;
const error = reason instanceof Error ? reason : new Error(`${label} was cancelled`);
if (!error.code) error.code = "ABORT_ERR";
return error;
}
function monotonicNow() {
if (typeof performance !== "undefined" && typeof performance.now === "function") {
return performance.now();
}
return Date.now();
}
/**
* Bastion / jump hosts sometimes reject rapid session channel opens with a
* distinctive rate-limit message. The common Chinese bastion typo "offen"
* (for "often") is part of the real wire text we see in the field.
*/
function isSshChannelOpenRateLimitedError(error) {
const message = String(error?.message || error || "");
return /channelOpen\s+too\s+offen\b/i.test(message)
|| /channelOpen\s+too\s+often\b/i.test(message);
}
function sleep(ms, sleepFn = null, signal = null, label = "SSH channel retry") {
return new Promise((resolve, reject) => {
let settled = false;
let timer = null;
const cleanup = () => {
if (timer) clearTimeout(timer);
timer = null;
signal?.removeEventListener?.("abort", onAbort);
};
const finish = (error) => {
if (settled) return;
settled = true;
cleanup();
if (error) reject(error);
else resolve();
};
const onAbort = () => finish(channelAbortError(signal, label));
if (signal?.aborted) {
finish(channelAbortError(signal, label));
return;
}
signal?.addEventListener?.("abort", onAbort, { once: true });
if (typeof sleepFn === "function") {
void Promise.resolve()
.then(() => sleepFn(ms))
.then(() => finish(), (error) => finish(error));
return;
}
timer = setTimeout(() => finish(), ms);
});
}
function openBoundedSshChannel(sshClient, invoke, options = {}) {
const label = String(options.label || "SSH channel open");
const timeoutMs = Math.max(
1,
Number(options.timeoutMs) || DEFAULT_SSH_CHANNEL_OPEN_TIMEOUT_MS,
);
const signal = options.signal || null;
const closeLateResult = options.closeLateResult || closeLateChannel;
const timeoutCode = options.timeoutCode || "SSH_CHANNEL_OPEN_TIMEOUT";
const invalidateOnAbort = options.invalidateOnAbort !== false;
const invalidateOnTimeout = options.invalidateOnTimeout !== false;
const setTimeoutFn = options.setTimeoutFn || setTimeout;
const clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
return new Promise((resolve, reject) => {
let settled = false;
let invoked = false;
let abandoned = false;
let timer = null;
const cleanup = () => {
if (timer) clearTimeoutFn(timer);
timer = null;
signal?.removeEventListener?.("abort", onAbort);
};
const finish = (error, result, { invalidate = false, abandon = false } = {}) => {
if (settled) {
if (result) closeLateResult(result);
if (abandoned) {
abandoned = false;
try { options.onAbandonedOpenSettled?.(); } catch { /* ignore */ }
}
return false;
}
settled = true;
cleanup();
if (error && result) closeLateResult(result);
if (abandon && invoked) {
abandoned = true;
try { options.onAbandonedOpen?.(); } catch { /* ignore */ }
}
if (invalidate) invalidateSshTransport(sshClient);
if (error) reject(error);
else resolve(result);
return true;
};
const onAbort = () => finish(
channelAbortError(signal, label),
null,
{ invalidate: invalidateOnAbort, abandon: !invalidateOnAbort },
);
if (signal?.aborted) {
finish(channelAbortError(signal, label));
return;
}
signal?.addEventListener?.("abort", onAbort, { once: true });
// This is a correctness deadline, not background housekeeping. It must
// remain referenced until the channel open settles; cleanup clears it on
// every success, error, abort, and synchronous-throw path.
timer = setTimeoutFn(() => {
const error = new Error(`${label} timed out after ${timeoutMs} ms`);
error.code = timeoutCode;
finish(error, null, {
invalidate: invalidateOnTimeout,
abandon: !invalidateOnTimeout,
});
}, timeoutMs);
try {
invoked = true;
invoke((error, result) => finish(error, result));
} catch (error) {
finish(error);
}
});
}
async function openBoundedSshShell(sshClient, windowOptions, shellOptions, options = {}) {
const hasRateLimitRetryTimeout = Number.isFinite(options.rateLimitRetryTimeoutMs);
const hasExplicitRateLimitRetries = Number.isFinite(options.rateLimitRetries);
const rateLimitRetries = Math.max(
0,
hasExplicitRateLimitRetries
? Number(options.rateLimitRetries)
: hasRateLimitRetryTimeout
? Number.POSITIVE_INFINITY
: DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_RETRIES,
);
const rateLimitRetryTimeoutMs = hasRateLimitRetryTimeout
? Math.max(0, Number(options.rateLimitRetryTimeoutMs))
: null;
const rateLimitBackoffMs = Math.max(
1,
Number(options.rateLimitBackoffMs) || DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_BACKOFF_MS,
);
const sleepFn = options.sleepFn || null;
const nowFn = typeof options.nowFn === "function" ? options.nowFn : monotonicNow;
const retryStartedAt = nowFn();
const retryDeadline = rateLimitRetryTimeoutMs === null
? null
: retryStartedAt + rateLimitRetryTimeoutMs;
let attempt = 0;
let lastRateLimitError = null;
for (;;) {
const attemptStartedAt = nowFn();
if (
lastRateLimitError
&& retryDeadline !== null
&& attemptStartedAt >= retryDeadline
) {
throw lastRateLimitError;
}
const configuredAttemptTimeoutMs = Math.max(
1,
Number(options.timeoutMs) || DEFAULT_SSH_CHANNEL_OPEN_TIMEOUT_MS,
);
const isRateLimitRetry = lastRateLimitError !== null;
const attemptTimeoutMs = retryDeadline === null || !isRateLimitRetry
? configuredAttemptTimeoutMs
: Math.max(
1,
Math.min(configuredAttemptTimeoutMs, Math.ceil(retryDeadline - attemptStartedAt)),
);
const retryBudgetConstrainsAttempt = isRateLimitRetry
&& attemptTimeoutMs < configuredAttemptTimeoutMs;
try {
return await openBoundedSshChannel(
sshClient,
(callback) => sshClient.shell(windowOptions, shellOptions, callback),
{
...options,
timeoutMs: attemptTimeoutMs,
invalidateOnTimeout: retryBudgetConstrainsAttempt
? false
: options.invalidateOnTimeout,
label: options.label || "SSH shell channel open",
timeoutCode: "SSH_SHELL_OPEN_TIMEOUT",
},
);
} catch (error) {
if (
retryBudgetConstrainsAttempt
&& error?.code === "SSH_SHELL_OPEN_TIMEOUT"
&& lastRateLimitError
) {
throw lastRateLimitError;
}
if (!isSshChannelOpenRateLimitedError(error) || options.signal?.aborted) {
throw error;
}
lastRateLimitError = error;
const nextDelayMs = rateLimitBackoffMs * (attempt + 1);
const retryTimeoutExpired = rateLimitRetryTimeoutMs !== null
&& nowFn() + nextDelayMs >= retryDeadline;
if (
attempt >= rateLimitRetries
|| retryTimeoutExpired
) {
throw error;
}
attempt += 1;
await sleep(
nextDelayMs,
sleepFn,
options.signal,
options.label || "SSH shell channel retry",
);
}
}
}
function openBoundedForwardOut(
sshClient,
sourceAddress,
sourcePort,
targetAddress,
targetPort,
options = {},
) {
return openBoundedSshChannel(
sshClient,
(callback) => sshClient.forwardOut(
sourceAddress,
sourcePort,
targetAddress,
targetPort,
callback,
),
{
...options,
label: options.label || "SSH forwardOut channel open",
timeoutCode: "SSH_FORWARD_OUT_TIMEOUT",
},
);
}
function openBoundedForwardIn(sshClient, bindAddress, bindPort, options = {}) {
return openBoundedSshChannel(
sshClient,
(callback) => sshClient.forwardIn(bindAddress, bindPort, callback),
{
...options,
label: options.label || "SSH forwardIn request",
timeoutCode: "SSH_FORWARD_IN_TIMEOUT",
closeLateResult: () => {},
},
);
}
function deliverChannelOpenCallback(promise, callback) {
void promise.then(
(result) => callback(null, result),
(error) => callback(error),
);
}
function openBoundedSshShellCallback(
sshClient,
windowOptions,
shellOptions,
callback,
options = {},
) {
deliverChannelOpenCallback(
openBoundedSshShell(sshClient, windowOptions, shellOptions, options),
callback,
);
}
function openBoundedForwardOutCallback(
sshClient,
sourceAddress,
sourcePort,
targetAddress,
targetPort,
callback,
options = {},
) {
deliverChannelOpenCallback(
openBoundedForwardOut(
sshClient,
sourceAddress,
sourcePort,
targetAddress,
targetPort,
options,
),
callback,
);
}
function openBoundedForwardInCallback(
sshClient,
bindAddress,
bindPort,
callback,
options = {},
) {
deliverChannelOpenCallback(
openBoundedForwardIn(sshClient, bindAddress, bindPort, options),
callback,
);
}
module.exports = {
DEFAULT_SSH_CHANNEL_OPEN_TIMEOUT_MS,
DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_RETRIES,
DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_BACKOFF_MS,
closeLateChannel,
isSshChannelOpenRateLimitedError,
openBoundedSshChannel,
openBoundedSshShell,
openBoundedSshShellCallback,
openBoundedForwardOut,
openBoundedForwardOutCallback,
openBoundedForwardIn,
openBoundedForwardInCallback,
};

View File

@@ -0,0 +1,369 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter, getEventListeners } = require("node:events");
const {
isSshChannelOpenRateLimitedError,
openBoundedForwardIn,
openBoundedForwardOut,
openBoundedSshShell,
} = require("./boundedSshChannelOpen.cjs");
function trackedTimerApi() {
const active = new Set();
const delays = [];
return {
active,
delays,
setTimeoutFn(callback, delay) {
delays.push(delay);
let timer;
timer = setTimeout(() => {
active.delete(timer);
callback();
}, delay);
active.add(timer);
return timer;
},
clearTimeoutFn(timer) {
clearTimeout(timer);
active.delete(timer);
},
};
}
function pendingClient(method) {
const client = new EventEmitter();
client.pending = [];
client.invalidations = 0;
client[method] = (...args) => client.pending.push(args.at(-1));
client.end = () => {};
client.destroy = () => {
client.invalidations += 1;
client.pending.length = 0;
};
return client;
}
test("unresponsive shell and forward opens invalidate transport and release pending callbacks", async () => {
for (const [method, open] of [
["shell", (client) => openBoundedSshShell(client, {}, {}, { timeoutMs: 2 })],
["forwardOut", (client) => openBoundedForwardOut(client, "127.0.0.1", 0, "host", 22, { timeoutMs: 2 })],
["forwardIn", (client) => openBoundedForwardIn(client, "127.0.0.1", 2222, { timeoutMs: 2 })],
]) {
const client = pendingClient(method);
await assert.rejects(open(client), /timed out/);
assert.equal(client.invalidations, 1, method);
assert.equal(client.pending.length, 0, method);
}
});
test("cancelled channel open invalidates transport and a late stream is closed", async () => {
let callback;
let invalidations = 0;
const timers = trackedTimerApi();
const client = {
shell(_window, _options, next) { callback = next; },
destroy() { invalidations += 1; },
};
const controller = new AbortController();
const pending = openBoundedSshShell(client, {}, {}, {
signal: controller.signal,
...timers,
});
assert.equal(timers.active.size, 1);
assert.equal([...timers.active][0].hasRef(), true);
controller.abort(new Error("cancelled"));
await assert.rejects(pending, /cancelled/);
assert.equal(invalidations, 1);
assert.equal(timers.active.size, 0);
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
const stream = new EventEmitter();
stream.closed = 0;
stream.close = () => { stream.closed += 1; };
callback(new Error("late open failure"), stream);
assert.equal(stream.closed, 1);
assert.equal(timers.active.size, 0);
});
test("non-invalidating cancellation reports an abandoned open until its callback settles", async () => {
let callback;
let abandoned = 0;
let abandonedSettled = 0;
const controller = new AbortController();
const client = {
shell(_window, _options, next) { callback = next; },
};
const pending = openBoundedSshShell(client, {}, {}, {
signal: controller.signal,
invalidateOnAbort: false,
onAbandonedOpen: () => { abandoned += 1; },
onAbandonedOpenSettled: () => { abandonedSettled += 1; },
});
controller.abort(new Error("cancelled"));
await assert.rejects(pending, /cancelled/);
assert.equal(abandoned, 1);
assert.equal(abandonedSettled, 0);
const lateStream = new EventEmitter();
lateStream.closed = 0;
lateStream.close = () => { lateStream.closed += 1; };
callback(null, lateStream);
assert.equal(lateStream.closed, 1);
assert.equal(abandonedSettled, 1);
});
test("channel open keeps its deadline referenced and clears it after success", async () => {
let callback;
const timers = trackedTimerApi();
const controller = new AbortController();
const client = {
shell(_window, _options, next) { callback = next; },
};
const pending = openBoundedSshShell(client, {}, {}, {
signal: controller.signal,
timeoutMs: 1_000,
...timers,
});
assert.equal(timers.active.size, 1);
assert.equal([...timers.active][0].hasRef(), true);
const stream = new EventEmitter();
callback(null, stream);
assert.equal(await pending, stream);
assert.equal(timers.active.size, 0);
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
});
test("shell open caps each attempt to the remaining rate-limit retry window", async () => {
const timers = trackedTimerApi();
const controller = new AbortController();
let elapsedMs = 0;
let attempts = 0;
const client = {
shell(_window, _options, next) {
attempts += 1;
if (attempts === 1) {
next(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
}
},
};
const pending = openBoundedSshShell(client, {}, {}, {
signal: controller.signal,
timeoutMs: 1_000,
rateLimitRetryTimeoutMs: 5,
rateLimitBackoffMs: 1,
nowFn: () => elapsedMs,
sleepFn: async (ms) => { elapsedMs += ms; },
...timers,
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(attempts, 2);
assert.deepEqual(timers.delays, [1_000, 4]);
controller.abort(new Error("cancelled"));
await assert.rejects(pending, /cancelled/);
});
test("a retry-budget timeout preserves the transport and returns the rate-limit error", async () => {
let attempts = 0;
let invalidations = 0;
let elapsedMs = 0;
let lateCallback = null;
let abandoned = 0;
let abandonedSettled = 0;
const client = {
shell(_window, _options, next) {
attempts += 1;
if (attempts === 1) {
next(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
} else {
lateCallback = next;
}
},
destroy() { invalidations += 1; },
};
await assert.rejects(
openBoundedSshShell(client, {}, {}, {
timeoutMs: 1_000,
rateLimitRetryTimeoutMs: 5,
rateLimitBackoffMs: 1,
nowFn: () => elapsedMs,
sleepFn: async (ms) => { elapsedMs += ms; },
setTimeoutFn(callback, ms) {
if (ms < 1_000) {
elapsedMs += ms;
setImmediate(callback);
}
return { unref() {} };
},
clearTimeoutFn() {},
onAbandonedOpen: () => { abandoned += 1; },
onAbandonedOpenSettled: () => { abandonedSettled += 1; },
}),
/channelOpen too offen/,
);
assert.equal(attempts, 2);
assert.equal(invalidations, 0);
assert.equal(abandoned, 1);
assert.equal(abandonedSettled, 0);
const lateStream = new EventEmitter();
lateStream.closed = 0;
lateStream.close = () => { lateStream.closed += 1; };
lateCallback(null, lateStream);
assert.equal(lateStream.closed, 1);
assert.equal(abandonedSettled, 1);
});
test("detects bastion channelOpen rate-limit errors including the offen typo", () => {
assert.equal(
isSshChannelOpenRateLimitedError(
new Error("(SSH) Channel open failure: channelOpen too offen type=session"),
),
true,
);
assert.equal(
isSshChannelOpenRateLimitedError(
new Error("channel open failure: channelOpen too often type=session"),
),
true,
);
assert.equal(
isSshChannelOpenRateLimitedError(new Error("Permission denied")),
false,
);
});
test("shell open retries bastion rate-limit failures with short backoff", async () => {
const delays = [];
let attempts = 0;
const client = {
shell(_window, _options, next) {
attempts += 1;
if (attempts < 3) {
next(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
return;
}
next(null, new EventEmitter());
},
};
const stream = await openBoundedSshShell(client, {}, {}, {
rateLimitRetries: 3,
rateLimitBackoffMs: 5,
sleepFn: async (ms) => { delays.push(ms); },
});
assert.ok(stream);
assert.equal(attempts, 3);
assert.deepEqual(delays, [5, 10]);
});
test("shell open can use a bounded retry window for variable bastion cooldowns", async () => {
const delays = [];
let elapsedMs = 0;
let attempts = 0;
const client = {
shell(_window, _options, next) {
attempts += 1;
if (attempts <= 4) {
next(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
return;
}
next(null, new EventEmitter());
},
};
const stream = await openBoundedSshShell(client, {}, {}, {
rateLimitRetryTimeoutMs: 11,
rateLimitBackoffMs: 1,
nowFn: () => elapsedMs,
sleepFn: async (ms) => {
delays.push(ms);
elapsedMs += ms;
},
});
assert.ok(stream);
assert.equal(attempts, 5);
assert.deepEqual(delays, [1, 2, 3, 4]);
});
test("shell open stops retrying when the bastion retry window is exhausted", async () => {
const delays = [];
let elapsedMs = 0;
let attempts = 0;
const client = {
shell(_window, _options, next) {
attempts += 1;
next(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
},
};
await assert.rejects(
openBoundedSshShell(client, {}, {}, {
rateLimitRetryTimeoutMs: 5,
rateLimitBackoffMs: 2,
nowFn: () => elapsedMs,
sleepFn: async (ms) => {
delays.push(ms);
elapsedMs += ms;
},
}),
/channelOpen too offen/,
);
assert.equal(attempts, 2);
assert.deepEqual(delays, [2]);
});
test("shell open does not start another attempt after a delayed retry wakes past the deadline", async () => {
let elapsedMs = 0;
let attempts = 0;
const client = {
shell(_window, _options, next) {
attempts += 1;
next(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
},
};
await assert.rejects(
openBoundedSshShell(client, {}, {}, {
rateLimitRetryTimeoutMs: 5,
rateLimitBackoffMs: 1,
nowFn: () => elapsedMs,
sleepFn: async () => { elapsedMs = 10; },
}),
/channelOpen too offen/,
);
assert.equal(attempts, 1);
});
test("shell open does not retry unrelated channel open failures", async () => {
const delays = [];
let attempts = 0;
const client = {
shell(_window, _options, next) {
attempts += 1;
next(new Error("Channel open failure: administratively prohibited"));
},
};
await assert.rejects(
openBoundedSshShell(client, {}, {}, {
rateLimitRetryTimeoutMs: 10,
rateLimitBackoffMs: 5,
sleepFn: async (ms) => { delays.push(ms); },
}),
/administratively prohibited/,
);
assert.equal(attempts, 1);
assert.deepEqual(delays, []);
});

View File

@@ -0,0 +1,255 @@
"use strict";
const { StringDecoder } = require("node:string_decoder");
const { invalidateSshTransport } = require("./sshTransportInvalidation.cjs");
const DEFAULT_SSH_EXEC_OPEN_TIMEOUT_MS = 15_000;
const DEFAULT_SSH_EXEC_RUN_TIMEOUT_MS = 10 * 60_000;
const DEFAULT_SSH_EXEC_MAX_OUTPUT_BYTES = 64 * 1024;
function terminateSshExecStream(stream) {
if (!stream) return;
// Teardown can race with a final transport error after normal listeners have
// been removed. Keep that late event from becoming an uncaught main-process
// exception; the stream is terminal and will be collected with this listener.
try { stream.once?.("error", () => {}); } catch { /* ignore */ }
try { stream.stderr?.once?.("error", () => {}); } catch { /* ignore */ }
// Best-effort: ask the server to KILL the exec'd process before tearing the
// channel down. Servers that do not answer "signal" requests simply ignore
// it, so this only ever helps — some sshd builds otherwise leave the remote
// command running after the client gives up (#3187).
try { stream.signal?.("KILL"); } catch { /* ignore */ }
try { stream.close?.(); } catch { /* ignore */ }
try { stream.end?.(); } catch { /* ignore */ }
try { stream.destroy?.(); } catch { /* ignore */ }
}
function createAbortError(signal) {
const reason = signal?.reason;
const error = reason instanceof Error ? reason : new Error("SSH command was aborted");
if (!error.code) error.code = "ABORT_ERR";
return error;
}
function openBoundedSshExecStream(
sshClient,
command,
execOptions = {},
options = {},
) {
if (!sshClient || typeof sshClient.exec !== "function") {
return Promise.reject(new Error("SSH exec unavailable"));
}
const signal = options.signal || null;
const setTimeoutFn = options.setTimeoutFn || setTimeout;
const clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
const openingTimeoutMs = Math.max(
1,
Number(options.openingTimeoutMs) || DEFAULT_SSH_EXEC_OPEN_TIMEOUT_MS,
);
return new Promise((resolve, reject) => {
let settled = false;
let openingTimer = null;
const cleanup = () => {
if (openingTimer) clearTimeoutFn(openingTimer);
openingTimer = null;
signal?.removeEventListener?.("abort", onAbort);
};
const finish = (error, stream, { invalidateTransport = false } = {}) => {
if (settled) {
if (stream) terminateSshExecStream(stream);
return false;
}
settled = true;
cleanup();
if (error && stream) terminateSshExecStream(stream);
if (invalidateTransport) invalidateSshTransport(sshClient);
if (error) reject(error);
else if (!stream) reject(new Error("Failed to create SSH exec stream"));
else resolve(stream);
return true;
};
const onAbort = () => finish(createAbortError(signal), null, {
invalidateTransport: true,
});
if (signal?.aborted) {
finish(createAbortError(signal));
return;
}
signal?.addEventListener?.("abort", onAbort, { once: true });
// Correctness deadlines must stay referenced until settlement. Otherwise
// Node may exit while the SSH callback and this promise are still pending.
openingTimer = setTimeoutFn(() => {
const error = new Error(`SSH exec channel open timed out after ${openingTimeoutMs} ms`);
error.code = "SSH_EXEC_OPEN_TIMEOUT";
finish(error, null, { invalidateTransport: true });
}, openingTimeoutMs);
try {
sshClient.exec(command, execOptions, (error, stream) => finish(error, stream));
} catch (error) {
finish(error);
}
});
}
function openBoundedSshExecStreamCallback(
sshClient,
command,
execOptions,
callback,
options = {},
) {
void openBoundedSshExecStream(sshClient, command, execOptions, options).then(
(stream) => callback(null, stream),
(error) => callback(error),
);
}
function executeBoundedSshCommand(sshClient, command, options = {}) {
if (!sshClient || typeof sshClient.exec !== "function") {
return Promise.reject(new Error("SSH exec unavailable"));
}
const signal = options.signal || null;
const setTimeoutFn = options.setTimeoutFn || setTimeout;
const clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
const openingTimeoutMs = Math.max(1, Number(options.openingTimeoutMs) || DEFAULT_SSH_EXEC_OPEN_TIMEOUT_MS);
const runTimeoutMs = Math.max(1, Number(options.runTimeoutMs) || DEFAULT_SSH_EXEC_RUN_TIMEOUT_MS);
const maxOutputBytes = Math.max(1, Number(options.maxOutputBytes) || DEFAULT_SSH_EXEC_MAX_OUTPUT_BYTES);
const invalidateOnOpenTimeout = options.invalidateOnOpenTimeout !== false;
return new Promise((resolve, reject) => {
let settled = false;
let streamRef = null;
let openingTimer = null;
let runTimer = null;
let stdout = "";
let stderr = "";
let outputBytes = 0;
const stdoutDecoder = new StringDecoder("utf8");
const stderrDecoder = new StringDecoder("utf8");
let decodersEnded = false;
let cleanupStreamListeners = () => {};
const cleanup = () => {
if (openingTimer) clearTimeoutFn(openingTimer);
if (runTimer) clearTimeoutFn(runTimer);
openingTimer = null;
runTimer = null;
cleanupStreamListeners();
cleanupStreamListeners = () => {};
signal?.removeEventListener?.("abort", onAbort);
};
const finish = (error, code = null, { terminate = false, invalidateTransport = false } = {}) => {
if (settled) return false;
settled = true;
cleanup();
if (terminate) terminateSshExecStream(streamRef);
if (invalidateTransport) invalidateSshTransport(sshClient);
if (error) reject(error);
else {
if (!decodersEnded) {
decodersEnded = true;
stdout += stdoutDecoder.end();
stderr += stderrDecoder.end();
}
resolve({ stdout, stderr, code });
}
return true;
};
const onAbort = () => finish(createAbortError(signal), null, {
terminate: true,
// Before the callback arrives ssh2 owns an uncancellable channel-open
// request. Closing the physical transport is the only public cleanup.
invalidateTransport: !streamRef && options.invalidateTransportOnAbort !== false,
});
const append = (target, chunk) => {
if (settled) return;
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
const remaining = Math.max(0, maxOutputBytes - outputBytes);
if (remaining > 0) {
const accepted = buffer.length <= remaining ? buffer : buffer.subarray(0, remaining);
if (target === "stdout") stdout += stdoutDecoder.write(accepted);
else stderr += stderrDecoder.write(accepted);
outputBytes += accepted.length;
}
if (buffer.length > remaining) {
const error = new Error(`SSH command output exceeded ${maxOutputBytes} bytes`);
error.code = "SSH_EXEC_OUTPUT_LIMIT";
finish(error, null, { terminate: true });
}
};
if (signal?.aborted) {
finish(createAbortError(signal));
return;
}
signal?.addEventListener?.("abort", onAbort, { once: true });
openingTimer = setTimeoutFn(() => {
const error = new Error(`SSH exec channel open timed out after ${openingTimeoutMs} ms`);
error.code = "SSH_EXEC_OPEN_TIMEOUT";
finish(error, null, { terminate: true, invalidateTransport: invalidateOnOpenTimeout });
}, openingTimeoutMs);
try {
sshClient.exec(command, (error, stream) => {
if (openingTimer) clearTimeoutFn(openingTimer);
openingTimer = null;
if (settled) {
terminateSshExecStream(stream);
return;
}
if (error || !stream) {
if (stream) terminateSshExecStream(stream);
finish(error || new Error("Failed to create SSH exec stream"));
return;
}
streamRef = stream;
const onStdout = (chunk) => append("stdout", chunk);
const onStderr = (chunk) => append("stderr", chunk);
const onClose = (code) => finish(null, code);
const onError = (streamError) => finish(
streamError instanceof Error ? streamError : new Error(String(streamError || "SSH exec failed")),
null,
{ terminate: true },
);
cleanupStreamListeners = () => {
stream.removeListener?.("data", onStdout);
stream.removeListener?.("close", onClose);
stream.removeListener?.("error", onError);
stream.stderr?.removeListener?.("data", onStderr);
stream.stderr?.removeListener?.("error", onError);
};
stream.on("data", onStdout);
stream.on("close", onClose);
stream.on("error", onError);
stream.stderr?.on?.("data", onStderr);
stream.stderr?.on?.("error", onError);
runTimer = setTimeoutFn(() => {
const timeoutError = new Error(`SSH command execution timed out after ${runTimeoutMs} ms`);
timeoutError.code = "SSH_EXEC_RUN_TIMEOUT";
finish(timeoutError, null, { terminate: true });
}, runTimeoutMs);
try { options.onStream?.(stream); } catch (streamError) {
finish(streamError, null, { terminate: true });
return;
}
if (signal?.aborted) onAbort();
});
} catch (error) {
finish(error, null, { terminate: true });
}
});
}
module.exports = {
DEFAULT_SSH_EXEC_OPEN_TIMEOUT_MS,
DEFAULT_SSH_EXEC_RUN_TIMEOUT_MS,
DEFAULT_SSH_EXEC_MAX_OUTPUT_BYTES,
executeBoundedSshCommand,
openBoundedSshExecStream,
openBoundedSshExecStreamCallback,
terminateSshExecStream,
};

View File

@@ -0,0 +1,290 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const {
executeBoundedSshCommand,
openBoundedSshExecStream,
} = require("./boundedSshExec.cjs");
function createStream() {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.closed = 0;
stream.destroyed = 0;
stream.signals = [];
stream.signal = (name) => { stream.signals.push(name); };
stream.close = () => { stream.closed += 1; };
stream.destroy = () => { stream.destroyed += 1; };
return stream;
}
function trackedTimerApi() {
const active = new Set();
return {
active,
setTimeoutFn(callback, delay) {
let timer;
timer = setTimeout(() => {
active.delete(timer);
callback();
}, delay);
active.add(timer);
return timer;
},
clearTimeoutFn(timer) {
clearTimeout(timer);
active.delete(timer);
},
};
}
test("bounded SSH exec times out while opening and terminates a late stream", async () => {
let callback;
let endCalls = 0;
let destroyCalls = 0;
const timers = trackedTimerApi();
const sshClient = {
exec(_command, next) { callback = next; },
end() { endCalls += 1; },
destroy() { destroyCalls += 1; },
};
const result = executeBoundedSshCommand(sshClient, "true", {
openingTimeoutMs: 5,
...timers,
});
assert.equal(timers.active.size, 1);
assert.equal([...timers.active][0].hasRef(), true);
await assert.rejects(result, (error) => error.code === "SSH_EXEC_OPEN_TIMEOUT");
assert.equal(timers.active.size, 0);
assert.ok(endCalls > 0 || destroyCalls > 0, "open timeout must invalidate the physical transport");
const stream = createStream();
callback(new Error("late open failure"), stream);
assert.ok(stream.closed > 0 || stream.destroyed > 0);
assert.equal(timers.active.size, 0);
});
test("best-effort SSH exec open timeout can preserve a shared transport", async () => {
let callback;
let endCalls = 0;
let destroyCalls = 0;
const sshClient = {
exec(_command, next) { callback = next; },
end() { endCalls += 1; },
destroy() { destroyCalls += 1; },
};
await assert.rejects(
executeBoundedSshCommand(sshClient, "true", {
openingTimeoutMs: 2,
invalidateOnOpenTimeout: false,
}),
(error) => error.code === "SSH_EXEC_OPEN_TIMEOUT",
);
assert.equal(endCalls, 0);
assert.equal(destroyCalls, 0);
const stream = createStream();
callback(null, stream);
assert.ok(stream.closed > 0 || stream.destroyed > 0);
});
test("bounded raw exec stream preserves options and invalidates a hung open", async () => {
let callback;
let receivedOptions;
let invalidations = 0;
const timers = trackedTimerApi();
const sshClient = {
exec(_command, options, next) {
receivedOptions = options;
callback = next;
},
destroy() { invalidations += 1; },
};
const pending = openBoundedSshExecStream(
sshClient,
"sudo sftp-server",
{ pty: false },
{ openingTimeoutMs: 2, ...timers },
);
assert.equal(timers.active.size, 1);
assert.equal([...timers.active][0].hasRef(), true);
await assert.rejects(pending, (error) => error.code === "SSH_EXEC_OPEN_TIMEOUT");
assert.deepEqual(receivedOptions, { pty: false });
assert.equal(invalidations, 1);
assert.equal(timers.active.size, 0);
const lateStream = createStream();
callback(null, lateStream);
assert.ok(lateStream.closed > 0 || lateStream.destroyed > 0);
assert.equal(timers.active.size, 0);
});
test("bounded SSH exec times out a live command and removes data listeners", async () => {
const stream = createStream();
const timers = trackedTimerApi();
const sshClient = { exec(_command, next) { next(null, stream); } };
const result = executeBoundedSshCommand(sshClient, "sleep", {
runTimeoutMs: 5,
...timers,
});
assert.equal(timers.active.size, 1);
assert.equal([...timers.active][0].hasRef(), true);
await assert.rejects(
result,
(error) => error.code === "SSH_EXEC_RUN_TIMEOUT",
);
assert.equal(timers.active.size, 0);
assert.equal(stream.listenerCount("data"), 0);
assert.equal(stream.stderr.listenerCount("data"), 0);
assert.ok(stream.closed > 0 || stream.destroyed > 0);
// Best-effort remote kill: servers that answer "signal" channel requests
// stop the abandoned command instead of leaving it running (#3187).
assert.deepEqual(stream.signals, ["KILL"]);
});
test("bounded SSH exec caps combined stdout and stderr and terminates the stream", async () => {
const stream = createStream();
const sshClient = { exec(_command, next) { next(null, stream); } };
const result = executeBoundedSshCommand(sshClient, "flood", { maxOutputBytes: 16 });
stream.emit("data", Buffer.alloc(10, 97));
stream.stderr.emit("data", Buffer.alloc(10, 98));
await assert.rejects(result, (error) => error.code === "SSH_EXEC_OUTPUT_LIMIT");
assert.equal(stream.listenerCount("data"), 0);
assert.equal(stream.stderr.listenerCount("data"), 0);
assert.ok(stream.closed > 0 || stream.destroyed > 0);
});
test("bounded SSH exec settles on stdout and stderr stream errors", async () => {
for (const target of ["stdout", "stderr"]) {
const stream = createStream();
const sshClient = { exec(_command, next) { next(null, stream); } };
const result = executeBoundedSshCommand(sshClient, "fail");
const expected = new Error(`${target} failed`);
if (target === "stdout") stream.emit("error", expected);
else stream.stderr.emit("error", expected);
await assert.rejects(result, /failed/);
assert.equal(stream.listenerCount("data"), 0);
assert.equal(stream.stderr.listenerCount("data"), 0);
}
});
test("bounded SSH exec aborts before callback and terminates a late stream", async () => {
let callback;
let invalidations = 0;
const timers = trackedTimerApi();
const controller = new AbortController();
const sshClient = {
exec(_command, next) { callback = next; },
destroy() { invalidations += 1; },
};
const result = executeBoundedSshCommand(sshClient, "pending", {
signal: controller.signal,
...timers,
});
assert.equal(timers.active.size, 1);
assert.equal([...timers.active][0].hasRef(), true);
controller.abort(new Error("cancelled"));
await assert.rejects(result, /cancelled/);
assert.equal(invalidations, 1);
assert.equal(timers.active.size, 0);
const stream = createStream();
callback(null, stream);
assert.ok(stream.closed > 0 || stream.destroyed > 0);
assert.equal(timers.active.size, 0);
});
test("bounded SSH exec can abort a shared-channel probe without invalidating the transport", async () => {
let invalidations = 0;
const controller = new AbortController();
const sshClient = {
exec() {},
destroy() { invalidations += 1; },
};
const result = executeBoundedSshCommand(sshClient, "probe", {
signal: controller.signal,
invalidateTransportOnAbort: false,
});
controller.abort(new Error("cancelled"));
await assert.rejects(result, /cancelled/);
assert.equal(invalidations, 0);
});
test("bounded SSH exec clears its run deadline after normal completion", async () => {
const stream = createStream();
const timers = trackedTimerApi();
const sshClient = { exec(_command, next) { next(null, stream); } };
const result = executeBoundedSshCommand(sshClient, "true", {
runTimeoutMs: 1_000,
...timers,
});
assert.equal(timers.active.size, 1);
assert.equal([...timers.active][0].hasRef(), true);
stream.emit("close", 0);
assert.deepEqual(await result, { stdout: "", stderr: "", code: 0 });
assert.equal(timers.active.size, 0);
});
test("repeated unresponsive exec opens release channel callbacks and evict the shared transport", async () => {
const {
borrowTransport,
createTransport,
findTransportByEndpoint,
getTransportStats,
resetSshTransportRegistryForTests,
} = require("./sshConnectionPool.cjs");
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
const endpoint = { hostId: "host-1", hostname: "wedged.example", username: "root" };
const conn = new EventEmitter();
conn._sock = { destroyed: false };
conn.pendingChannelCallbacks = [];
conn.exec = (_command, callback) => {
if (conn._sock.destroyed) throw new Error("Not connected");
conn.pendingChannelCallbacks.push(callback);
};
conn.end = () => {};
conn.destroy = () => {
if (conn._sock.destroyed) return;
conn._sock.destroyed = true;
conn.pendingChannelCallbacks.length = 0;
conn.emit("close");
};
const transport = createTransport({ conn, endpoint });
borrowTransport(transport, { kind: "shell", holder: {} });
for (let attempt = 0; attempt < 3; attempt += 1) {
await assert.rejects(
executeBoundedSshCommand(conn, "stats", { openingTimeoutMs: 2 }),
/timed out|Not connected/,
);
}
assert.equal(conn.pendingChannelCallbacks.length, 0);
assert.equal(getTransportStats().transports, 0);
assert.equal(findTransportByEndpoint(endpoint), null);
const replacement = new EventEmitter();
replacement._sock = { destroyed: false };
replacement.end = () => {};
const replacementTransport = createTransport({ conn: replacement, endpoint });
assert.equal(findTransportByEndpoint(endpoint), replacementTransport, "a fresh reconnect must be reusable");
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
});
test("bounded SSH exec preserves UTF-8 split across stream chunks", async () => {
const stream = createStream();
const sshClient = { exec(_command, next) { next(null, stream); } };
const result = executeBoundedSshCommand(sshClient, "printf unicode");
const bytes = Buffer.from("你🙂", "utf8");
stream.emit("data", bytes.subarray(0, 2));
stream.emit("data", bytes.subarray(2, 5));
stream.emit("data", bytes.subarray(5));
stream.emit("close", 0);
assert.deepEqual(await result, { stdout: "你🙂", stderr: "", code: 0 });
});

Some files were not shown because too many files have changed in this diff Show More