[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:
10
electron/shared/approvalConstants.cjs
Normal file
10
electron/shared/approvalConstants.cjs
Normal file
@@ -0,0 +1,10 @@
|
||||
"use strict";
|
||||
|
||||
/** Shared approval timeout constants for main-process bridges. */
|
||||
const CATTY_APPROVAL_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const MCP_APPROVAL_TIMEOUT_MS = 110 * 1000;
|
||||
|
||||
module.exports = {
|
||||
CATTY_APPROVAL_TIMEOUT_MS,
|
||||
MCP_APPROVAL_TIMEOUT_MS,
|
||||
};
|
||||
18
electron/shared/pathLikeCommand.cjs
Normal file
18
electron/shared/pathLikeCommand.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
|
||||
function isPathLikeCommand(command) {
|
||||
const normalized = String(command || "").trim();
|
||||
return normalized.includes("/") || normalized.includes("\\") || /^[a-z]:/i.test(normalized);
|
||||
}
|
||||
|
||||
function getCommandBasename(command) {
|
||||
const normalized = String(command || "").trim();
|
||||
if (!normalized) return "";
|
||||
const parts = normalized.split(/[\\/]/);
|
||||
return (parts.pop() || "").toLowerCase();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isPathLikeCommand,
|
||||
getCommandBasename,
|
||||
};
|
||||
609
electron/shared/permissionGrants.cjs
Normal file
609
electron/shared/permissionGrants.cjs
Normal file
@@ -0,0 +1,609 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Permission grant pattern matching — shared between main (MCP) and renderer.
|
||||
*/
|
||||
|
||||
function patternMatches(pattern, value) {
|
||||
if (typeof pattern !== "string" || pattern.length === 0) return false;
|
||||
if (pattern === "*") return true;
|
||||
if (typeof value !== "string") return false;
|
||||
|
||||
if (pattern.startsWith("host:")) {
|
||||
const hostPattern = pattern.slice("host:".length);
|
||||
return globOrRegexMatch(hostPattern, value);
|
||||
}
|
||||
|
||||
return globOrRegexMatch(pattern, value);
|
||||
}
|
||||
|
||||
function globOrRegexMatch(pattern, value) {
|
||||
if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
|
||||
const lastSlash = pattern.lastIndexOf("/");
|
||||
const body = pattern.slice(1, lastSlash);
|
||||
const flags = pattern.slice(lastSlash + 1);
|
||||
try {
|
||||
return new RegExp(body, flags).test(value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pattern.includes("*") && !pattern.includes("?")) {
|
||||
return value === pattern;
|
||||
}
|
||||
|
||||
// OpenCode Wildcard.match semantics (trailing " *" allows optional args).
|
||||
let escaped = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/\?/g, ".");
|
||||
if (escaped.endsWith(" .*")) {
|
||||
escaped = `${escaped.slice(0, -3)}( .*)?`;
|
||||
}
|
||||
|
||||
return new RegExp(`^${escaped}$`, "s").test(value);
|
||||
}
|
||||
|
||||
function argsPatternMatches(argsPattern, args) {
|
||||
if (!argsPattern || typeof argsPattern !== "object") return true;
|
||||
if (!args || typeof args !== "object") return false;
|
||||
|
||||
for (const [key, pattern] of Object.entries(argsPattern)) {
|
||||
const argValue = args[key];
|
||||
if (typeof argValue === "undefined") return false;
|
||||
if (!patternMatches(String(pattern), String(argValue))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const CWD_COMMANDS = new Set([
|
||||
"cd",
|
||||
"chdir",
|
||||
"popd",
|
||||
"pushd",
|
||||
"push-location",
|
||||
"set-location",
|
||||
]);
|
||||
|
||||
function unquoteShellToken(token) {
|
||||
if (token.length >= 2) {
|
||||
const first = token[0];
|
||||
const last = token[token.length - 1];
|
||||
if ((first === "\"" || first === "'") && first === last) {
|
||||
return token.slice(1, -1);
|
||||
}
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function tokenizeShellCommand(command) {
|
||||
const matches = command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
|
||||
return matches.map(unquoteShellToken);
|
||||
}
|
||||
|
||||
function lastNonWhitespaceChar(value) {
|
||||
return value.match(/\S(?=\s*$)/)?.[0];
|
||||
}
|
||||
|
||||
function readArithmeticExpansionEnd(segment, startIndex) {
|
||||
if (segment[startIndex] !== "$" || segment[startIndex + 1] !== "(" || segment[startIndex + 2] !== "(") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let depth = 1;
|
||||
let quote = null;
|
||||
|
||||
for (let index = startIndex + 3; index < segment.length; index += 1) {
|
||||
const char = segment[index];
|
||||
const next = segment[index + 1];
|
||||
|
||||
if (quote) {
|
||||
if (char === "\\" && quote !== "'" && next) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "\\" && next) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "\"" || char === "'" || char === "`") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "(") {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ")") {
|
||||
if (depth === 1 && next === ")") return index + 2;
|
||||
if (depth > 1) depth -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
return segment.length;
|
||||
}
|
||||
|
||||
function hasExecutableShellExpansion(segment) {
|
||||
let quote = null;
|
||||
|
||||
for (let index = 0; index < segment.length; index += 1) {
|
||||
const char = segment[index];
|
||||
const next = segment[index + 1];
|
||||
|
||||
if (quote) {
|
||||
if (char === "\\" && quote === "\"" && next) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) {
|
||||
quote = null;
|
||||
continue;
|
||||
}
|
||||
if (quote === "\"" && char === "$" && next === "(") return true;
|
||||
if (quote === "\"" && char === "`") return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "\\" && next) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "\"") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "`") return true;
|
||||
if (char === "$" && next === "(") return true;
|
||||
if ((char === "<" || char === ">") && next === "(") return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function readEscapeDigits(value, startIndex, maxLength, pattern) {
|
||||
let endIndex = startIndex;
|
||||
while (
|
||||
endIndex < value.length
|
||||
&& endIndex < startIndex + maxLength
|
||||
&& pattern.test(value[endIndex])
|
||||
) {
|
||||
endIndex += 1;
|
||||
}
|
||||
|
||||
if (endIndex === startIndex) return null;
|
||||
return { digits: value.slice(startIndex, endIndex), endIndex: endIndex - 1 };
|
||||
}
|
||||
|
||||
function codePointToString(codePoint) {
|
||||
try {
|
||||
return String.fromCodePoint(codePoint);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function readAnsiCEscape(value, backslashIndex) {
|
||||
const escapeIndex = backslashIndex + 1;
|
||||
const char = value[escapeIndex];
|
||||
if (!char) return { text: "\\", endIndex: backslashIndex };
|
||||
|
||||
const simpleEscapes = {
|
||||
a: "\x07",
|
||||
b: "\b",
|
||||
e: "\x1B",
|
||||
E: "\x1B",
|
||||
f: "\f",
|
||||
n: "\n",
|
||||
r: "\r",
|
||||
t: "\t",
|
||||
v: "\v",
|
||||
"\\": "\\",
|
||||
"'": "'",
|
||||
"\"": "\"",
|
||||
"?": "?",
|
||||
};
|
||||
|
||||
const simple = simpleEscapes[char];
|
||||
if (simple !== undefined) return { text: simple, endIndex: escapeIndex };
|
||||
|
||||
if (char === "x") {
|
||||
const digits = readEscapeDigits(value, escapeIndex + 1, 2, /[0-9a-fA-F]/);
|
||||
if (!digits) return { text: "\\x", endIndex: escapeIndex };
|
||||
return {
|
||||
text: codePointToString(Number.parseInt(digits.digits, 16)),
|
||||
endIndex: digits.endIndex,
|
||||
};
|
||||
}
|
||||
|
||||
if (char === "u" || char === "U") {
|
||||
const digits = readEscapeDigits(value, escapeIndex + 1, char === "u" ? 4 : 8, /[0-9a-fA-F]/);
|
||||
if (!digits) return { text: `\\${char}`, endIndex: escapeIndex };
|
||||
return {
|
||||
text: codePointToString(Number.parseInt(digits.digits, 16)),
|
||||
endIndex: digits.endIndex,
|
||||
};
|
||||
}
|
||||
|
||||
if (/[0-7]/.test(char)) {
|
||||
const digits = readEscapeDigits(value, escapeIndex, 3, /[0-7]/);
|
||||
return {
|
||||
text: codePointToString(Number.parseInt(digits.digits, 8)),
|
||||
endIndex: digits.endIndex,
|
||||
};
|
||||
}
|
||||
|
||||
return { text: `\\${char}`, endIndex: escapeIndex };
|
||||
}
|
||||
|
||||
function readHereDocDelimiterWord(segment, startIndex) {
|
||||
let index = startIndex;
|
||||
while (index < segment.length && /\s/.test(segment[index])) index += 1;
|
||||
|
||||
let text = "";
|
||||
let quote = null;
|
||||
let ansiQuote = false;
|
||||
|
||||
for (; index < segment.length; index += 1) {
|
||||
const char = segment[index];
|
||||
const next = segment[index + 1];
|
||||
|
||||
if (quote) {
|
||||
if (ansiQuote && char === "\\") {
|
||||
const escape = readAnsiCEscape(segment, index);
|
||||
text += escape.text;
|
||||
index = escape.endIndex;
|
||||
continue;
|
||||
}
|
||||
if (char === "\\" && quote !== "'" && next) {
|
||||
text += next;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) {
|
||||
quote = null;
|
||||
ansiQuote = false;
|
||||
continue;
|
||||
}
|
||||
text += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\s/.test(char) || char === ";" || char === "|" || char === "&") break;
|
||||
|
||||
if (char === "\\" && next) {
|
||||
text += next;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "$" && (next === "'" || next === "\"")) {
|
||||
quote = next;
|
||||
ansiQuote = next === "'";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "\"" || char === "'" || char === "`") {
|
||||
quote = char;
|
||||
ansiQuote = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
text += char;
|
||||
}
|
||||
|
||||
return text ? { text, endIndex: index } : null;
|
||||
}
|
||||
|
||||
function extractHereDocTerminators(segment) {
|
||||
const terminators = [];
|
||||
let quote = null;
|
||||
|
||||
for (let index = 0; index < segment.length; index += 1) {
|
||||
const char = segment[index];
|
||||
const next = segment[index + 1];
|
||||
|
||||
if (quote) {
|
||||
if (char === "\\" && quote !== "'" && next) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const arithmeticEnd = readArithmeticExpansionEnd(segment, index);
|
||||
if (arithmeticEnd !== null) {
|
||||
index = arithmeticEnd - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "\\" && next) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "\"" || char === "'" || char === "`") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char !== "<" || next !== "<") continue;
|
||||
if (segment[index + 2] === "<") {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
const stripLeadingTabs = segment[index + 2] === "-";
|
||||
const delimiterStart = index + (stripLeadingTabs ? 3 : 2);
|
||||
const delimiter = readHereDocDelimiterWord(segment, delimiterStart);
|
||||
if (!delimiter) continue;
|
||||
terminators.push({ text: delimiter.text, stripLeadingTabs });
|
||||
index = delimiter.endIndex - 1;
|
||||
}
|
||||
|
||||
return terminators;
|
||||
}
|
||||
|
||||
function skipHereDocBodies(command, startIndex, terminators) {
|
||||
let cursor = startIndex;
|
||||
|
||||
for (const terminator of terminators) {
|
||||
while (cursor < command.length) {
|
||||
const lineEnd = command.indexOf("\n", cursor);
|
||||
const end = lineEnd === -1 ? command.length : lineEnd;
|
||||
const rawLine = command.slice(cursor, end);
|
||||
const line = terminator.stripLeadingTabs ? rawLine.replace(/^\t+/, "") : rawLine;
|
||||
cursor = lineEnd === -1 ? command.length : lineEnd + 1;
|
||||
|
||||
if (line === terminator.text) break;
|
||||
}
|
||||
}
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function splitShellCommandSegments(command) {
|
||||
const segments = [];
|
||||
let current = "";
|
||||
let quote = null;
|
||||
let inComment = false;
|
||||
let lineHereDocTerminators = [];
|
||||
|
||||
const flush = () => {
|
||||
const segment = current.trim();
|
||||
if (segment) segments.push(segment);
|
||||
current = "";
|
||||
};
|
||||
|
||||
const flushCommandSegment = () => {
|
||||
lineHereDocTerminators.push(...extractHereDocTerminators(current));
|
||||
flush();
|
||||
};
|
||||
|
||||
const finishLine = (bodyStartIndex) => {
|
||||
flushCommandSegment();
|
||||
const hereDocTerminators = lineHereDocTerminators;
|
||||
lineHereDocTerminators = [];
|
||||
if (hereDocTerminators.length === 0) return bodyStartIndex;
|
||||
return skipHereDocBodies(command, bodyStartIndex, hereDocTerminators);
|
||||
};
|
||||
|
||||
for (let index = 0; index < command.length; index += 1) {
|
||||
const char = command[index];
|
||||
const next = command[index + 1];
|
||||
|
||||
if (inComment) {
|
||||
if (char === "\n") {
|
||||
inComment = false;
|
||||
index = finishLine(index + 1) - 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
current += char;
|
||||
if (char === "\\" && quote !== "'" && next) {
|
||||
current += next;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "\\" && next === "\n") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "\\" && next) {
|
||||
current += char + next;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "\"" || char === "'" || char === "`") {
|
||||
quote = char;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
const arithmeticEnd = readArithmeticExpansionEnd(command, index);
|
||||
if (arithmeticEnd !== null) {
|
||||
current += command.slice(index, arithmeticEnd);
|
||||
index = arithmeticEnd - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "#") {
|
||||
const previous = current[current.length - 1];
|
||||
if (!previous || /\s/.test(previous)) {
|
||||
inComment = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (char === "\n") {
|
||||
index = finishLine(index + 1) - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ";") {
|
||||
flushCommandSegment();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "&" && next === "&") {
|
||||
flushCommandSegment();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "|" && next === "|") {
|
||||
flushCommandSegment();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "&") {
|
||||
const previous = lastNonWhitespaceChar(current);
|
||||
if (next === ">" || previous === ">" || previous === "<") {
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
flushCommandSegment();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "|") {
|
||||
flushCommandSegment();
|
||||
if (next === "&") index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
flush();
|
||||
return segments;
|
||||
}
|
||||
|
||||
function extractGrantableShellCommandSegments(command) {
|
||||
return splitShellCommandSegments(command).filter((segment) => {
|
||||
const tokens = tokenizeShellCommand(segment);
|
||||
const cmd = tokens[0] && tokens[0].toLowerCase();
|
||||
return tokens.length > 0 && !(cmd && CWD_COMMANDS.has(cmd) && !hasExecutableShellExpansion(segment));
|
||||
});
|
||||
}
|
||||
|
||||
function matchCommandPatternGrants(rules, ctx, command, args) {
|
||||
const commandSegments = extractGrantableShellCommandSegments(command);
|
||||
if (commandSegments.length === 0) return null;
|
||||
|
||||
const eligibleRules = rules.filter((rule) => (
|
||||
rule
|
||||
&& rule.capabilityId === ctx?.capabilityId
|
||||
&& rule.commandPattern
|
||||
&& argsPatternMatches(rule.argsPattern, args)
|
||||
));
|
||||
if (eligibleRules.length === 0) return null;
|
||||
|
||||
let firstMatch = null;
|
||||
for (const segment of commandSegments) {
|
||||
const matched = eligibleRules.find((rule) => patternMatches(rule.commandPattern, segment));
|
||||
if (!matched) return null;
|
||||
if (!firstMatch) firstMatch = matched;
|
||||
}
|
||||
|
||||
return firstMatch;
|
||||
}
|
||||
|
||||
function matchPermissionGrant(rules, ctx) {
|
||||
if (!Array.isArray(rules) || rules.length === 0) return null;
|
||||
|
||||
const args = ctx?.args && typeof ctx.args === "object" ? ctx.args : {};
|
||||
const command = typeof args.command === "string" ? args.command : "";
|
||||
const commandGrantMatch = command ? matchCommandPatternGrants(rules, ctx, command, args) : null;
|
||||
if (commandGrantMatch) return commandGrantMatch;
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!rule || typeof rule.capabilityId !== "string") continue;
|
||||
|
||||
if (rule.capabilityId !== ctx?.capabilityId) continue;
|
||||
if (rule.commandPattern) continue;
|
||||
|
||||
if (!argsPatternMatches(rule.argsPattern, args)) continue;
|
||||
|
||||
return rule;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function sanitizePermissionGrants(raw) {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
const result = [];
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const capabilityId = typeof entry.capabilityId === "string" ? entry.capabilityId.trim() : "";
|
||||
if (!capabilityId) continue;
|
||||
|
||||
const rule = {
|
||||
id: typeof entry.id === "string" && entry.id.trim()
|
||||
? entry.id.trim().slice(0, 64)
|
||||
: `grant_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
capabilityId,
|
||||
sessionPattern: typeof entry.sessionPattern === "string" && entry.sessionPattern.trim()
|
||||
? entry.sessionPattern.trim()
|
||||
: "*",
|
||||
createdAt: typeof entry.createdAt === "number" && Number.isFinite(entry.createdAt)
|
||||
? entry.createdAt
|
||||
: Date.now(),
|
||||
};
|
||||
|
||||
if (typeof entry.commandPattern === "string" && entry.commandPattern.trim()) {
|
||||
rule.commandPattern = entry.commandPattern.trim();
|
||||
}
|
||||
if (entry.argsPattern && typeof entry.argsPattern === "object" && !Array.isArray(entry.argsPattern)) {
|
||||
const argsPattern = {};
|
||||
for (const [key, value] of Object.entries(entry.argsPattern)) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
argsPattern[key] = value.trim();
|
||||
}
|
||||
}
|
||||
if (Object.keys(argsPattern).length > 0) {
|
||||
rule.argsPattern = argsPattern;
|
||||
}
|
||||
}
|
||||
if (typeof entry.note === "string" && entry.note.trim()) {
|
||||
rule.note = entry.note.trim().slice(0, 240);
|
||||
}
|
||||
|
||||
result.push(rule);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
patternMatches,
|
||||
matchPermissionGrant,
|
||||
sanitizePermissionGrants,
|
||||
};
|
||||
64
electron/shared/sdkSessionIdentity.cjs
Normal file
64
electron/shared/sdkSessionIdentity.cjs
Normal file
@@ -0,0 +1,64 @@
|
||||
"use strict";
|
||||
|
||||
const SDK_SESSION_ID_PREFIX = "netcatty-sdk-session:";
|
||||
|
||||
function normalizeCursorAuthMode(authMode) {
|
||||
return authMode === "cli-login" ? "cli-login" : authMode === "api-key" ? "api-key" : undefined;
|
||||
}
|
||||
|
||||
function normalizeCursorCliMode(cliMode) {
|
||||
return cliMode === "ask" ? "ask" : cliMode === "agent" ? "agent" : undefined;
|
||||
}
|
||||
|
||||
/** Codex app-server | Grok acp/streaming-json | default sdk. */
|
||||
function normalizeSdkRuntime(runtime) {
|
||||
const raw = String(runtime || "").trim().toLowerCase();
|
||||
if (raw === "app-server") return "app-server";
|
||||
if (raw === "acp") return "acp";
|
||||
if (raw === "streaming-json" || raw === "cli" || raw === "headless") return "streaming-json";
|
||||
return "sdk";
|
||||
}
|
||||
|
||||
function encodeSdkSessionIdentity(sessionId, sdkBackend, binPath, runtime = "sdk", authMode, cliMode) {
|
||||
if (!sessionId || !sdkBackend) return sessionId;
|
||||
const payload = {
|
||||
v: 1,
|
||||
id: sessionId,
|
||||
backend: sdkBackend,
|
||||
binPath: binPath || "",
|
||||
runtime: normalizeSdkRuntime(runtime),
|
||||
};
|
||||
const normalizedAuthMode = normalizeCursorAuthMode(authMode);
|
||||
if (normalizedAuthMode) payload.authMode = normalizedAuthMode;
|
||||
const normalizedCliMode = normalizeCursorCliMode(cliMode);
|
||||
if (normalizedCliMode) payload.cliMode = normalizedCliMode;
|
||||
return `${SDK_SESSION_ID_PREFIX}${encodeURIComponent(JSON.stringify(payload))}`;
|
||||
}
|
||||
|
||||
function parseSdkSessionIdentity(value) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw.startsWith(SDK_SESSION_ID_PREFIX)) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(decodeURIComponent(raw.slice(SDK_SESSION_ID_PREFIX.length)));
|
||||
if (!parsed || parsed.v !== 1 || !parsed.id || !parsed.backend) return null;
|
||||
const authMode = normalizeCursorAuthMode(parsed.authMode);
|
||||
const cliMode = normalizeCursorCliMode(parsed.cliMode);
|
||||
return {
|
||||
...parsed,
|
||||
runtime: normalizeSdkRuntime(parsed.runtime),
|
||||
...(authMode ? { authMode } : {}),
|
||||
...(cliMode ? { cliMode } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SDK_SESSION_ID_PREFIX,
|
||||
encodeSdkSessionIdentity,
|
||||
normalizeCursorAuthMode,
|
||||
normalizeCursorCliMode,
|
||||
normalizeSdkRuntime,
|
||||
parseSdkSessionIdentity,
|
||||
};
|
||||
45
electron/shared/vaultAgentGuidance.cjs
Normal file
45
electron/shared/vaultAgentGuidance.cjs
Normal file
@@ -0,0 +1,45 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Shared Vault tool-selection guidance for MCP external agents and get_environment.
|
||||
* Keep in sync with infrastructure/ai/cattyAgent/systemPrompt.ts (Catty sidebar).
|
||||
*/
|
||||
const VAULT_HOSTS_VS_NOTES_GUIDANCE =
|
||||
"Vault → Hosts vs Vault → Notes: When the user asks to add/create/import a host "
|
||||
+ "(创建主机、添加主机、保存 SSH 连接凭据), use vault_hosts_create with dryRun=true first, "
|
||||
+ "or vault_hosts_import for known export formats (PuTTY, MobaXterm, CSV, SecureCRT, ssh_config) — "
|
||||
+ "NOT vault_notes_create. For attached host files, use vault_hosts_import only when the attachment is a known export format; "
|
||||
+ "for unknown attached host/server text, read the attachment content, extract hostname, username, password, port, group, tags, and label yourself, "
|
||||
+ "then call vault_hosts_create with dryRun=true first. Extract hostname, username, password or local keyPath, port, group, tags, and label from the user's text; "
|
||||
+ "put long admin tables or remarks in the host notes field (host_notes_set / Host Details metadata), "
|
||||
+ "not Vault sidebar Notes. Use vault_notes_create or vault_notes_update ONLY when the user explicitly wants "
|
||||
+ "markdown documentation in Vault → Notes (保险箱笔记 sidebar). "
|
||||
+ "Use vault_hosts_list to resolve hostId before vault_hosts_update or vault_hosts_delete. "
|
||||
+ "If vault_hosts_create or vault_hosts_import fails, report the error — do not silently create a Vault note instead.";
|
||||
|
||||
const VAULT_SCRIPTS_GUIDANCE =
|
||||
"Snippets vs automation scripts: snippets_* for shell command text (optional {{variables}}). "
|
||||
+ "scripts_* for nct JavaScript automation (await nct.screen.sendLine, waitForText/waitForRegex, dialogs). "
|
||||
+ "Call scripts_reference before authoring scripts. scripts_run with wait=true blocks until completion; "
|
||||
+ "use scripts_runs_list / scripts_run_stop / scripts_run_pause / scripts_run_resume for lifecycle. "
|
||||
+ "Triggers: manual, onConnect (runs after connect), onOutput (regex triggerPattern). "
|
||||
+ "Host/group linking: scripts_targets_set supports targets and dynamic targetGroups; "
|
||||
+ "per-host connect order: host_connect_scripts_list / host_connect_scripts_set.";
|
||||
|
||||
function appendVaultAgentGuidance(description) {
|
||||
const base = typeof description === "string" ? description.trim() : "";
|
||||
let result = base;
|
||||
if (!result.includes("Vault → Hosts vs Vault → Notes")) {
|
||||
result = result ? `${result} ${VAULT_HOSTS_VS_NOTES_GUIDANCE}` : VAULT_HOSTS_VS_NOTES_GUIDANCE;
|
||||
}
|
||||
if (!result.includes("Snippets vs automation scripts")) {
|
||||
result = result ? `${result} ${VAULT_SCRIPTS_GUIDANCE}` : VAULT_SCRIPTS_GUIDANCE;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
VAULT_HOSTS_VS_NOTES_GUIDANCE,
|
||||
VAULT_SCRIPTS_GUIDANCE,
|
||||
appendVaultAgentGuidance,
|
||||
};
|
||||
37
electron/shared/vaultAgentGuidance.test.cjs
Normal file
37
electron/shared/vaultAgentGuidance.test.cjs
Normal file
@@ -0,0 +1,37 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
VAULT_HOSTS_VS_NOTES_GUIDANCE,
|
||||
VAULT_SCRIPTS_GUIDANCE,
|
||||
appendVaultAgentGuidance,
|
||||
} = require("./vaultAgentGuidance.cjs");
|
||||
|
||||
test("VAULT_HOSTS_VS_NOTES_GUIDANCE forbids note fallback for host creation", () => {
|
||||
assert.match(VAULT_HOSTS_VS_NOTES_GUIDANCE, /vault_hosts_create/i);
|
||||
assert.match(VAULT_HOSTS_VS_NOTES_GUIDANCE, /NOT vault_notes_create/i);
|
||||
assert.match(VAULT_HOSTS_VS_NOTES_GUIDANCE, /do not silently create a Vault note/i);
|
||||
});
|
||||
|
||||
test("VAULT_HOSTS_VS_NOTES_GUIDANCE routes unknown attached host files through AI extraction", () => {
|
||||
assert.match(VAULT_HOSTS_VS_NOTES_GUIDANCE, /attached/i);
|
||||
assert.match(VAULT_HOSTS_VS_NOTES_GUIDANCE, /unknown/i);
|
||||
assert.match(VAULT_HOSTS_VS_NOTES_GUIDANCE, /extract/i);
|
||||
assert.match(VAULT_HOSTS_VS_NOTES_GUIDANCE, /vault_hosts_create/i);
|
||||
});
|
||||
|
||||
test("appendVaultAgentGuidance appends guidance once", () => {
|
||||
const once = appendVaultAgentGuidance("Netcatty terminal manager.");
|
||||
assert.match(once, /Netcatty terminal manager/);
|
||||
assert.match(once, /Vault → Hosts vs Vault → Notes/);
|
||||
|
||||
const twice = appendVaultAgentGuidance(once);
|
||||
assert.equal(twice, once);
|
||||
});
|
||||
|
||||
test("VAULT_SCRIPTS_GUIDANCE prefers explicit wait APIs", () => {
|
||||
assert.match(VAULT_SCRIPTS_GUIDANCE, /waitForText\/waitForRegex/);
|
||||
assert.doesNotMatch(VAULT_SCRIPTS_GUIDANCE, /sendLine,\s*waitFor,\s*dialogs/);
|
||||
});
|
||||
Reference in New Issue
Block a user