[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
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:
50
electron/bridges/ai/claudeAuth.cjs
Normal file
50
electron/bridges/ai/claudeAuth.cjs
Normal 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 };
|
||||
56
electron/bridges/ai/claudeAuth.test.cjs
Normal file
56
electron/bridges/ai/claudeAuth.test.cjs
Normal 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");
|
||||
});
|
||||
477
electron/bridges/ai/codexHelpers.cjs
Normal file
477
electron/bridges/ai/codexHelpers.cjs
Normal 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,
|
||||
};
|
||||
179
electron/bridges/ai/codexHelpers.test.cjs
Normal file
179
electron/bridges/ai/codexHelpers.test.cjs
Normal 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]"}');
|
||||
});
|
||||
171
electron/bridges/ai/commandSafety.cjs
Normal file
171
electron/bridges/ai/commandSafety.cjs
Normal 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,
|
||||
};
|
||||
81
electron/bridges/ai/commandSafety.test.cjs
Normal file
81
electron/bridges/ai/commandSafety.test.cjs
Normal 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), "");
|
||||
});
|
||||
37
electron/bridges/ai/liveShellProbe.cjs
Normal file
37
electron/bridges/ai/liveShellProbe.cjs
Normal 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 };
|
||||
419
electron/bridges/ai/liveShellProbe.test.cjs
Normal file
419
electron/bridges/ai/liveShellProbe.test.cjs
Normal 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')));
|
||||
});
|
||||
}
|
||||
1251
electron/bridges/ai/ptyExec.cjs
Normal file
1251
electron/bridges/ai/ptyExec.cjs
Normal file
File diff suppressed because it is too large
Load Diff
1378
electron/bridges/ai/ptyExec.test.cjs
Normal file
1378
electron/bridges/ai/ptyExec.test.cjs
Normal file
File diff suppressed because it is too large
Load Diff
510
electron/bridges/ai/ptyExecHelpers.cjs
Normal file
510
electron/bridges/ai/ptyExecHelpers.cjs
Normal 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,
|
||||
};
|
||||
62
electron/bridges/ai/ptyExecPacedTimeout.test.cjs
Normal file
62
electron/bridges/ai/ptyExecPacedTimeout.test.cjs
Normal 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'));
|
||||
});
|
||||
460
electron/bridges/ai/sessionShellKind.cjs
Normal file
460
electron/bridges/ai/sessionShellKind.cjs
Normal 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,
|
||||
};
|
||||
875
electron/bridges/ai/sessionShellKind.test.cjs
Normal file
875
electron/bridges/ai/sessionShellKind.test.cjs
Normal 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`));
|
||||
},
|
||||
);
|
||||
1000
electron/bridges/ai/shellUtils.cjs
Normal file
1000
electron/bridges/ai/shellUtils.cjs
Normal file
File diff suppressed because it is too large
Load Diff
33
electron/bridges/ai/shellUtils.shellPath.test.cjs
Normal file
33
electron/bridges/ai/shellUtils.shellPath.test.cjs
Normal 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");
|
||||
});
|
||||
747
electron/bridges/ai/shellUtils.test.cjs
Normal file
747
electron/bridges/ai/shellUtils.test.cjs
Normal 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\n[32mPS C:\\Users\\alice>[0m",
|
||||
};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
19
electron/bridges/ai/sseDataLine.cjs
Normal file
19
electron/bridges/ai/sseDataLine.cjs
Normal 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 };
|
||||
26
electron/bridges/ai/sseDataLine.test.cjs
Normal file
26
electron/bridges/ai/sseDataLine.test.cjs
Normal 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);
|
||||
});
|
||||
530
electron/bridges/ai/userSkills.cjs
Normal file
530
electron/bridges/ai/userSkills.cjs
Normal 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,
|
||||
};
|
||||
403
electron/bridges/ai/userSkills.test.cjs
Normal file
403
electron/bridges/ai/userSkills.test.cjs
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user