[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,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);
},
);
});