@datename/fastcode-termux 0.1.6-beta → 0.1.8-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +4 -0
- package/README.md +50 -17
- package/dist/agent/approval-json.d.ts +43 -0
- package/dist/agent/approval-json.js +69 -0
- package/dist/agent/approval-native.d.ts +3 -0
- package/dist/agent/approval-native.js +165 -0
- package/dist/agent/approval-store.d.ts +16 -0
- package/dist/agent/approval-store.js +21 -0
- package/dist/agent/approval.d.ts +33 -0
- package/dist/agent/approval.js +40 -5
- package/dist/agent/compaction.d.ts +2 -0
- package/dist/agent/compaction.js +60 -17
- package/dist/agent/guards.d.ts +16 -16
- package/dist/agent/guards.js +978 -24
- package/dist/agent/policy/defaults.d.ts +16 -0
- package/dist/agent/policy/defaults.js +17 -0
- package/dist/agent/policy/egress.d.ts +32 -0
- package/dist/agent/policy/egress.js +280 -0
- package/dist/agent/process-tree.d.ts +20 -0
- package/dist/agent/process-tree.js +81 -10
- package/dist/agent/protocol.d.ts +20 -1
- package/dist/agent/protocol.js +165 -38
- package/dist/agent/registry.d.ts +3 -1
- package/dist/agent/registry.js +168 -11
- package/dist/agent/runner.d.ts +21 -0
- package/dist/agent/runner.js +44 -5
- package/dist/agent/shell-ast.d.ts +11 -0
- package/dist/agent/shell-ast.js +204 -0
- package/dist/agent/shell-session.d.ts +14 -0
- package/dist/agent/shell-session.js +251 -40
- package/dist/agent/tool-schemas.js +26 -7
- package/dist/agent/tools/append_file.js +3 -1
- package/dist/agent/tools/apply_patch.js +44 -4
- package/dist/agent/tools/copy_file.js +26 -3
- package/dist/agent/tools/delete_file.js +22 -3
- package/dist/agent/tools/grep.js +35 -4
- package/dist/agent/tools/make_dir.js +3 -1
- package/dist/agent/tools/move_path.d.ts +7 -0
- package/dist/agent/tools/move_path.js +31 -5
- package/dist/agent/tools/read_file.js +63 -3
- package/dist/agent/tools/read_many.js +21 -3
- package/dist/agent/tools/readers.d.ts +53 -1
- package/dist/agent/tools/readers.js +236 -26
- package/dist/agent/tools/replace_in_file.js +38 -5
- package/dist/agent/tools/shell.js +2 -2
- package/dist/agent/tools/types.d.ts +26 -0
- package/dist/agent/tools/validate.d.ts +15 -2
- package/dist/agent/tools/validate.js +66 -3
- package/dist/agent/tools/write_file.js +3 -1
- package/dist/app.d.ts +2 -1
- package/dist/app.js +526 -85
- package/dist/cli-main.js +363 -0
- package/dist/cli.js +397 -337
- package/dist/commands/about.js +1 -1
- package/dist/commands/clear.d.ts +1 -0
- package/dist/commands/clear.js +1 -1
- package/dist/commands/compact.d.ts +12 -0
- package/dist/commands/compact.js +14 -2
- package/dist/commands/context.js +5 -4
- package/dist/commands/index.js +2 -0
- package/dist/commands/jobs.js +3 -2
- package/dist/commands/kill.js +3 -1
- package/dist/commands/login.d.ts +2 -2
- package/dist/commands/login.js +15 -15
- package/dist/commands/model.d.ts +1 -13
- package/dist/commands/model.js +6 -38
- package/dist/commands/provider.d.ts +1 -15
- package/dist/commands/provider.js +4 -22
- package/dist/commands/skills.js +34 -4
- package/dist/commands/threads.d.ts +9 -0
- package/dist/commands/threads.js +26 -0
- package/dist/config/store.d.ts +21 -0
- package/dist/config/store.js +47 -9
- package/dist/config/trust.d.ts +9 -0
- package/dist/config/trust.js +41 -2
- package/dist/headless.d.ts +7 -0
- package/dist/headless.js +213 -0
- package/dist/index.js +19 -3
- package/dist/lib/debug.d.ts +2 -0
- package/dist/lib/debug.js +22 -0
- package/dist/lib/diagnostics.d.ts +19 -0
- package/dist/lib/diagnostics.js +32 -6
- package/dist/lib/highlight-subset.d.ts +21 -0
- package/dist/lib/highlight-subset.js +88 -0
- package/dist/lib/lifecycle.d.ts +5 -0
- package/dist/lib/lifecycle.js +62 -0
- package/dist/lib/markdown.js +22 -4
- package/dist/providers/deepseek.js +1 -1
- package/dist/providers/openaiCompatible.d.ts +24 -2
- package/dist/providers/openaiCompatible.js +20 -6
- package/dist/providers/spec.d.ts +12 -0
- package/dist/providers/spec.js +29 -0
- package/dist/session/log.d.ts +14 -2
- package/dist/session/log.js +56 -27
- package/dist/session/projection.d.ts +34 -1
- package/dist/session/projection.js +315 -46
- package/dist/skills/manager.d.ts +15 -0
- package/dist/skills/manager.js +135 -18
- package/dist/types.d.ts +15 -0
- package/dist/ui/StatusBar.js +1 -1
- package/package.json +3 -2
- package/src/agent/approval-json.ts +103 -0
- package/src/agent/approval-native.ts +168 -0
- package/src/agent/approval-store.ts +38 -0
- package/src/agent/approval.ts +58 -11
- package/src/agent/compaction.ts +62 -17
- package/src/agent/guards.ts +1049 -25
- package/src/agent/policy/defaults.ts +34 -0
- package/src/agent/policy/egress.ts +335 -0
- package/src/agent/process-tree.ts +105 -23
- package/src/agent/protocol.ts +187 -39
- package/src/agent/registry.ts +186 -14
- package/src/agent/runner.ts +50 -7
- package/src/agent/shell-ast.ts +227 -0
- package/src/agent/shell-session.ts +265 -41
- package/src/agent/tool-schemas.ts +26 -7
- package/src/agent/tools/append_file.ts +3 -1
- package/src/agent/tools/apply_patch.ts +45 -4
- package/src/agent/tools/copy_file.ts +27 -3
- package/src/agent/tools/delete_file.ts +22 -3
- package/src/agent/tools/grep.ts +36 -4
- package/src/agent/tools/make_dir.ts +3 -1
- package/src/agent/tools/move_path.ts +38 -5
- package/src/agent/tools/read_file.ts +64 -3
- package/src/agent/tools/read_many.ts +21 -3
- package/src/agent/tools/readers.ts +289 -26
- package/src/agent/tools/replace_in_file.ts +39 -5
- package/src/agent/tools/shell.ts +2 -2
- package/src/agent/tools/types.ts +43 -1
- package/src/agent/tools/validate.ts +73 -4
- package/src/agent/tools/write_file.ts +3 -1
- package/src/app.ts +533 -82
- package/src/{cli.ts → cli-main.ts} +34 -6
- package/src/commands/about.ts +1 -1
- package/src/commands/clear.ts +1 -1
- package/src/commands/compact.ts +14 -2
- package/src/commands/context.ts +5 -4
- package/src/commands/index.ts +2 -0
- package/src/commands/jobs.ts +3 -2
- package/src/commands/kill.ts +3 -1
- package/src/commands/login.ts +15 -15
- package/src/commands/model.ts +6 -38
- package/src/commands/provider.ts +4 -22
- package/src/commands/skills.ts +34 -4
- package/src/commands/threads.ts +28 -0
- package/src/config/store.ts +47 -9
- package/src/config/trust.ts +45 -2
- package/src/headless.ts +225 -0
- package/src/index.ts +19 -3
- package/src/lib/debug.ts +24 -0
- package/src/lib/diagnostics.ts +32 -6
- package/src/lib/highlight-subset.ts +93 -0
- package/src/lib/lifecycle.ts +69 -0
- package/src/lib/markdown.ts +22 -4
- package/src/providers/deepseek.ts +1 -1
- package/src/providers/openaiCompatible.ts +47 -10
- package/src/providers/spec.ts +42 -0
- package/src/session/log.ts +70 -27
- package/src/session/projection.ts +360 -47
- package/src/skills/manager.ts +140 -18
- package/src/types/highlight-shims.d.ts +14 -0
- package/src/types.ts +16 -1
- package/src/ui/StatusBar.ts +1 -1
- package/tsconfig.json +1 -0
- /package/dist/{cli.d.ts → cli-main.d.ts} +0 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Limited bash AST second opinion for Guardrails.
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately not a replacement for the shape matcher. It only
|
|
5
|
+
* extracts the three entry classes we can safely re-feed into the existing
|
|
6
|
+
* classifier:
|
|
7
|
+
*
|
|
8
|
+
* 1. shell `-c` literal strings
|
|
9
|
+
* 2. `find ... -exec ... ;/+`
|
|
10
|
+
* 3. eval / source / `.` literal command strings
|
|
11
|
+
*
|
|
12
|
+
* plus one same-scope assignment (`X=rm; $X -rf /`). It only reports Hard
|
|
13
|
+
* Block upgrades; parse failures return no findings so the shape matcher's
|
|
14
|
+
* result is never downgraded.
|
|
15
|
+
*/
|
|
16
|
+
import parse from "bash-parser";
|
|
17
|
+
|
|
18
|
+
export interface ShellAstHard {
|
|
19
|
+
code: string;
|
|
20
|
+
target: string;
|
|
21
|
+
reason: string;
|
|
22
|
+
alternatives: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ShellAstAnalysis {
|
|
26
|
+
derived: string[];
|
|
27
|
+
hard?: ShellAstHard;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MAX_DEPTH = 4;
|
|
31
|
+
|
|
32
|
+
export function analyzeShellAst(command: string): ShellAstAnalysis {
|
|
33
|
+
return analyze(command, 0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function analyze(command: string, depth: number): ShellAstAnalysis {
|
|
37
|
+
if (depth > MAX_DEPTH)
|
|
38
|
+
return { derived: [] };
|
|
39
|
+
let ast: any;
|
|
40
|
+
try {
|
|
41
|
+
ast = parse(command, { mode: "bash" });
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return { derived: [] };
|
|
45
|
+
}
|
|
46
|
+
const derived: string[] = [];
|
|
47
|
+
let hard: ShellAstHard | undefined;
|
|
48
|
+
const scope = new Map<string, string>();
|
|
49
|
+
|
|
50
|
+
const addDerived = (value: string) => {
|
|
51
|
+
const trimmed = value.trim();
|
|
52
|
+
if (trimmed && !derived.includes(trimmed))
|
|
53
|
+
derived.push(trimmed);
|
|
54
|
+
};
|
|
55
|
+
const addHard = (finding: ShellAstHard) => {
|
|
56
|
+
if (!hard)
|
|
57
|
+
hard = finding;
|
|
58
|
+
};
|
|
59
|
+
const wordText = (word: any): string => typeof word?.text === "string" ? word.text : "";
|
|
60
|
+
const wordIsDynamic = (word: any): boolean => {
|
|
61
|
+
if (Array.isArray(word?.expansion) && word.expansion.length > 0)
|
|
62
|
+
return true;
|
|
63
|
+
const text = wordText(word);
|
|
64
|
+
return /\$|`/.test(text);
|
|
65
|
+
};
|
|
66
|
+
const words = (value: any): any[] => Array.isArray(value) ? value : [];
|
|
67
|
+
const walkWord = (word: any) => {
|
|
68
|
+
for (const expansion of words(word?.expansion)) {
|
|
69
|
+
if (expansion?.type === "CommandExpansion") {
|
|
70
|
+
if (typeof expansion.command === "string")
|
|
71
|
+
addDerived(expansion.command);
|
|
72
|
+
if (expansion.commandAST)
|
|
73
|
+
walkNode(expansion.commandAST, scope, depth + 1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const walkList = (commands: any[], assignments: Map<string, string>, level: number) => {
|
|
78
|
+
for (const node of commands)
|
|
79
|
+
walkNode(node, assignments, level);
|
|
80
|
+
};
|
|
81
|
+
const handleCommand = (node: any, assignments: Map<string, string>) => {
|
|
82
|
+
const prefix = words(node?.prefix);
|
|
83
|
+
for (const assignment of prefix) {
|
|
84
|
+
const text = wordText(assignment);
|
|
85
|
+
const eq = text.indexOf("=");
|
|
86
|
+
if (eq <= 0)
|
|
87
|
+
continue;
|
|
88
|
+
const name = text.slice(0, eq);
|
|
89
|
+
const value = text.slice(eq + 1);
|
|
90
|
+
// One same-scope assignment, literal values only. Nested command
|
|
91
|
+
// substitution / variable values are intentionally not tracked.
|
|
92
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && value && !/[$`]/.test(value))
|
|
93
|
+
assignments.set(name, value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const nameWord = node?.name;
|
|
97
|
+
const name = wordText(nameWord);
|
|
98
|
+
const suffix = words(node?.suffix);
|
|
99
|
+
const parameter = words(nameWord?.expansion).find((entry: any) => entry?.type === "ParameterExpansion")?.parameter;
|
|
100
|
+
if (parameter && assignments.has(parameter) && (name === `$${parameter}` || name === `\${${parameter}}`)) {
|
|
101
|
+
const suffixText = suffix.map(wordText);
|
|
102
|
+
// bash-parser strips double quotes; a suffix that looks like a
|
|
103
|
+
// redirect/operator may be a quoted literal argument. Do not turn
|
|
104
|
+
// it into a false Hard Block.
|
|
105
|
+
if (suffixText.every((text) => !/[<>|;&]/.test(text))) {
|
|
106
|
+
const resolved = `${assignments.get(parameter)} ${suffixText.join(" ")}`.trim();
|
|
107
|
+
if (resolved)
|
|
108
|
+
addDerived(resolved);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (name === "eval" || name === "source" || name === ".") {
|
|
113
|
+
if (suffix.length === 0)
|
|
114
|
+
return;
|
|
115
|
+
if (suffix.some(wordIsDynamic)) {
|
|
116
|
+
addHard({
|
|
117
|
+
code: "dynamic_execution",
|
|
118
|
+
target: name,
|
|
119
|
+
reason: "non-literal-eval-source",
|
|
120
|
+
alternatives: [
|
|
121
|
+
"Use a literal, reviewed command string; dynamic eval/source cannot be audited.",
|
|
122
|
+
],
|
|
123
|
+
});
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
addDerived(suffix.map(wordText).join(" "));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (SHELL_COMMAND_NAMES.has(name)) {
|
|
130
|
+
const cIndex = suffix.findIndex((word: any) => /^-[a-z]*c$/i.test(wordText(word)));
|
|
131
|
+
if (cIndex >= 0 && suffix[cIndex + 1]) {
|
|
132
|
+
const script = suffix[cIndex + 1];
|
|
133
|
+
if (wordIsDynamic(script)) {
|
|
134
|
+
addHard({
|
|
135
|
+
code: "dynamic_execution",
|
|
136
|
+
target: `${name} -c`,
|
|
137
|
+
reason: "non-literal-shell-command",
|
|
138
|
+
alternatives: [
|
|
139
|
+
"Pass a literal, reviewed script string instead of a variable/command substitution.",
|
|
140
|
+
],
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
addDerived(wordText(script));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (name === "env") {
|
|
149
|
+
const start = suffix.findIndex((word: any) => {
|
|
150
|
+
const value = wordText(word);
|
|
151
|
+
return value && !value.startsWith("-") && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(value);
|
|
152
|
+
});
|
|
153
|
+
if (start >= 0)
|
|
154
|
+
addDerived(suffix.slice(start).map(wordText).join(" "));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (name === "find") {
|
|
158
|
+
const execIndex = suffix.findIndex((word: any) => {
|
|
159
|
+
const value = wordText(word);
|
|
160
|
+
return value === "-exec" || value === "-execdir";
|
|
161
|
+
});
|
|
162
|
+
if (execIndex >= 0) {
|
|
163
|
+
const inner: string[] = [];
|
|
164
|
+
for (const word of suffix.slice(execIndex + 1)) {
|
|
165
|
+
const value = wordText(word);
|
|
166
|
+
if (value === ";" || value === "+")
|
|
167
|
+
break;
|
|
168
|
+
inner.push(value);
|
|
169
|
+
}
|
|
170
|
+
const executable = (inner[0] ?? "").replace(/\\/g, "/").split("/").pop()?.toLowerCase() ?? "";
|
|
171
|
+
if (inner.length > 0 && INSPECT_EXECUTABLES.has(executable))
|
|
172
|
+
addDerived(inner.join(" "));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const word of words(node?.prefix))
|
|
176
|
+
walkWord(word);
|
|
177
|
+
for (const word of words(node?.suffix))
|
|
178
|
+
walkWord(word);
|
|
179
|
+
};
|
|
180
|
+
const walkNode = (node: any, assignments: Map<string, string>, level: number): void => {
|
|
181
|
+
if (!node || typeof node !== "object" || level > MAX_DEPTH)
|
|
182
|
+
return;
|
|
183
|
+
if (Array.isArray(node)) {
|
|
184
|
+
for (const item of node)
|
|
185
|
+
walkNode(item, assignments, level);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
switch (node.type) {
|
|
189
|
+
case "Command":
|
|
190
|
+
handleCommand(node, assignments);
|
|
191
|
+
return;
|
|
192
|
+
case "Script":
|
|
193
|
+
case "CompoundList":
|
|
194
|
+
walkList(words(node.commands), new Map(assignments), level);
|
|
195
|
+
return;
|
|
196
|
+
case "Pipeline":
|
|
197
|
+
walkList(words(node.commands), assignments, level);
|
|
198
|
+
return;
|
|
199
|
+
case "LogicalExpression":
|
|
200
|
+
walkNode(node.left, assignments, level);
|
|
201
|
+
walkNode(node.right, assignments, level);
|
|
202
|
+
return;
|
|
203
|
+
case "Subshell":
|
|
204
|
+
walkNode(node.list, new Map(assignments), level + 1);
|
|
205
|
+
return;
|
|
206
|
+
case "CommandExpansion":
|
|
207
|
+
walkNode(node.commandAST, assignments, level + 1);
|
|
208
|
+
return;
|
|
209
|
+
default:
|
|
210
|
+
for (const value of Object.values(node))
|
|
211
|
+
walkNode(value, assignments, level);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
walkNode(ast, scope, depth);
|
|
216
|
+
return hard ? { derived, hard } : { derived };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const SHELL_COMMAND_NAMES = new Set(["sh", "bash", "zsh", "dash", "ksh", "fish"]);
|
|
220
|
+
const INSPECT_EXECUTABLES = new Set([
|
|
221
|
+
"sh", "bash", "zsh", "dash", "ksh", "fish", "eval", "source", ".",
|
|
222
|
+
"rm", "rmdir", "rd", "del", "erase", "unlink", "shred", "truncate",
|
|
223
|
+
"git", "dd", "mkfs", "mke2fs", "fdisk", "diskpart", "format",
|
|
224
|
+
"docker", "kubectl", "chmod", "chown", "chgrp", "shutdown", "reboot",
|
|
225
|
+
"poweroff", "halt", "systemctl", "find", "curl", "wget", "base64",
|
|
226
|
+
"nc", "ncat", "netcat", "socat", "mv", "cp", "install", "tar", "rsync",
|
|
227
|
+
]);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
-
import
|
|
2
|
+
import parse from "bash-parser";
|
|
3
|
+
import { terminateProcessTree } from "./process-tree.js";
|
|
4
|
+
import { normalizeTimeoutMs } from "./tools/validate.js";
|
|
3
5
|
|
|
4
6
|
export type ShellJobStatus = "running" | "done" | "failed" | "killed" | "timeout";
|
|
5
7
|
|
|
@@ -21,6 +23,9 @@ export interface ShellJobView {
|
|
|
21
23
|
bytes: number;
|
|
22
24
|
startedAt: number;
|
|
23
25
|
pid?: number;
|
|
26
|
+
/** Lifecycle visibility for /jobs and regression tests. */
|
|
27
|
+
timeoutArmed?: boolean;
|
|
28
|
+
foregroundAttached?: boolean;
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
interface ExecOptions {
|
|
@@ -33,7 +38,9 @@ interface ExecOptions {
|
|
|
33
38
|
const OUTPUT_LIMIT = 1024 * 1024;
|
|
34
39
|
const DEFAULT_BACKGROUND_MS = 2000;
|
|
35
40
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
36
|
-
const
|
|
41
|
+
export const DEFAULT_SHELL_POOL_SIZE = 2;
|
|
42
|
+
export const MAX_SHELL_POOL_SIZE = 6;
|
|
43
|
+
let poolLimit = DEFAULT_SHELL_POOL_SIZE;
|
|
37
44
|
const jobs = new Map<string, ShellJob>();
|
|
38
45
|
const pool: ResidentShell[] = [];
|
|
39
46
|
let spawns = 0;
|
|
@@ -56,6 +63,10 @@ class ShellJob {
|
|
|
56
63
|
private readonly donePromise: Promise<void>;
|
|
57
64
|
private resolveDone!: () => void;
|
|
58
65
|
private waiters: Array<() => void> = [];
|
|
66
|
+
private timeoutTimer?: ReturnType<typeof setTimeout>;
|
|
67
|
+
private abortSignal?: AbortSignal;
|
|
68
|
+
private abortListener?: () => void;
|
|
69
|
+
private cleaned = false;
|
|
59
70
|
constructor(command: string, cwd: string) {
|
|
60
71
|
this.id = "sh_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 7);
|
|
61
72
|
this.key = this.id.replace(/[^a-z0-9]/gi, "");
|
|
@@ -89,9 +100,45 @@ class ShellJob {
|
|
|
89
100
|
return;
|
|
90
101
|
this.status = status;
|
|
91
102
|
this.exitCode = exitCode;
|
|
103
|
+
this.cleanupLifecycle();
|
|
92
104
|
this.resolveDone();
|
|
93
105
|
this.notify();
|
|
94
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Task-level timeout lives until the job actually finishes. Returning to
|
|
109
|
+
* the foreground only detaches the AbortSignal; it must not disarm this.
|
|
110
|
+
*/
|
|
111
|
+
armTimeout(ms: number): void {
|
|
112
|
+
if (this.timeoutTimer || this.status !== "running")
|
|
113
|
+
return;
|
|
114
|
+
this.timeoutTimer = setTimeout(() => this.kill("timeout"), ms);
|
|
115
|
+
this.timeoutTimer.unref?.();
|
|
116
|
+
}
|
|
117
|
+
attachAbort(signal: AbortSignal | undefined): void {
|
|
118
|
+
if (!signal || this.abortListener)
|
|
119
|
+
return;
|
|
120
|
+
const listener = () => this.kill("killed");
|
|
121
|
+
this.abortSignal = signal;
|
|
122
|
+
this.abortListener = listener;
|
|
123
|
+
signal.addEventListener("abort", listener, { once: true });
|
|
124
|
+
}
|
|
125
|
+
/** Detach Esc/foreground cancellation but keep the timeout armed. */
|
|
126
|
+
detachForeground(): void {
|
|
127
|
+
if (this.abortSignal && this.abortListener)
|
|
128
|
+
this.abortSignal.removeEventListener("abort", this.abortListener);
|
|
129
|
+
this.abortSignal = undefined;
|
|
130
|
+
this.abortListener = undefined;
|
|
131
|
+
}
|
|
132
|
+
private cleanupLifecycle(): void {
|
|
133
|
+
if (this.cleaned)
|
|
134
|
+
return;
|
|
135
|
+
this.cleaned = true;
|
|
136
|
+
if (this.timeoutTimer) {
|
|
137
|
+
clearTimeout(this.timeoutTimer);
|
|
138
|
+
this.timeoutTimer = undefined;
|
|
139
|
+
}
|
|
140
|
+
this.detachForeground();
|
|
141
|
+
}
|
|
95
142
|
waitForChange(ms: number): Promise<void> {
|
|
96
143
|
if (this.status !== "running" || this.delivered < this.output.length)
|
|
97
144
|
return Promise.resolve();
|
|
@@ -124,18 +171,23 @@ class ShellJob {
|
|
|
124
171
|
exitCode: this.exitCode,
|
|
125
172
|
bytes: this.output.length,
|
|
126
173
|
startedAt: this.startedAt,
|
|
127
|
-
pid: this.child?.pid,
|
|
174
|
+
pid: this.child?.pid ?? this.resident?.child.pid,
|
|
175
|
+
timeoutArmed: Boolean(this.timeoutTimer),
|
|
176
|
+
foregroundAttached: Boolean(this.abortListener),
|
|
128
177
|
};
|
|
129
178
|
}
|
|
130
179
|
kill(reason: "killed" | "timeout"): void {
|
|
131
180
|
if (this.status !== "running")
|
|
132
181
|
return;
|
|
133
|
-
this.append(reason === "timeout"
|
|
182
|
+
this.append(reason === "timeout"
|
|
183
|
+
? "\n[command timed out: process group killed; setsid descendants may escape]"
|
|
184
|
+
: "\n[command aborted: process group killed; setsid descendants may escape]");
|
|
134
185
|
this.status = reason;
|
|
186
|
+
this.cleanupLifecycle();
|
|
135
187
|
if (this.resident)
|
|
136
188
|
this.resident.kill();
|
|
137
189
|
else if (this.child)
|
|
138
|
-
|
|
190
|
+
terminateProcessTree(this.child);
|
|
139
191
|
this.resolveDone();
|
|
140
192
|
this.notify();
|
|
141
193
|
}
|
|
@@ -146,6 +198,8 @@ class ResidentShell {
|
|
|
146
198
|
job?: ShellJob;
|
|
147
199
|
dead = false;
|
|
148
200
|
private buffer = "";
|
|
201
|
+
private background = false;
|
|
202
|
+
private claimed = false;
|
|
149
203
|
constructor(cwd: string) {
|
|
150
204
|
const env = { ...process.env, FASTCODE_SANDBOX: "1", FASTCODE_SANDBOX_ROOT: cwd };
|
|
151
205
|
this.child = spawn("sh", ["-s"], {
|
|
@@ -169,13 +223,23 @@ class ResidentShell {
|
|
|
169
223
|
pool.splice(index, 1);
|
|
170
224
|
});
|
|
171
225
|
}
|
|
172
|
-
|
|
226
|
+
claimIfIdle(): boolean {
|
|
227
|
+
if (this.dead || this.job || this.claimed)
|
|
228
|
+
return false;
|
|
229
|
+
this.claimed = true;
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
run(job: ShellJob, timeoutMs: number, background = false): void {
|
|
233
|
+
this.claimed = false;
|
|
173
234
|
this.job = job;
|
|
235
|
+
this.background = background;
|
|
236
|
+
this.buffer = "";
|
|
174
237
|
job.resident = this;
|
|
175
238
|
const marker = `__FC_END_${job.key}__`;
|
|
239
|
+
const body = job.command.trim() ? ["(", job.command, ")"] : [":"];
|
|
176
240
|
const script = [
|
|
177
241
|
`if cd ${shellQuote(job.cwd)}; then`,
|
|
178
|
-
|
|
242
|
+
...body,
|
|
179
243
|
"__fc_status=$?",
|
|
180
244
|
"else",
|
|
181
245
|
"__fc_status=1",
|
|
@@ -195,9 +259,13 @@ class ResidentShell {
|
|
|
195
259
|
private consume(chunk: string): void {
|
|
196
260
|
if (this.dead)
|
|
197
261
|
return;
|
|
198
|
-
|
|
199
|
-
|
|
262
|
+
// Output that arrives while no job owns the shell belongs to an
|
|
263
|
+
// already-finished background child. Never carry it into the next job.
|
|
264
|
+
if (!this.job) {
|
|
265
|
+
this.buffer = "";
|
|
200
266
|
return;
|
|
267
|
+
}
|
|
268
|
+
this.buffer += chunk;
|
|
201
269
|
const marker = `__FC_END_${this.job.key}__:`;
|
|
202
270
|
const index = this.buffer.indexOf(marker);
|
|
203
271
|
if (index < 0) {
|
|
@@ -226,20 +294,39 @@ class ResidentShell {
|
|
|
226
294
|
const code = Number(rest.slice(0, newline).trim());
|
|
227
295
|
this.buffer = rest.slice(newline + 1);
|
|
228
296
|
this.job = undefined;
|
|
297
|
+
const background = this.background;
|
|
298
|
+
this.background = false;
|
|
229
299
|
job.finish(Number.isFinite(code) && code === 0 ? "done" : "failed", Number.isFinite(code) ? code : 1);
|
|
300
|
+
// A background operator leaves a detached child writing into this
|
|
301
|
+
// shell's stdout pipe. Retire the resident so late output cannot be
|
|
302
|
+
// attributed to the next command.
|
|
303
|
+
if (background)
|
|
304
|
+
this.kill();
|
|
230
305
|
}
|
|
231
306
|
private fail(message: string, code?: number | null): void {
|
|
307
|
+
this.claimed = false;
|
|
232
308
|
if (!this.job)
|
|
233
309
|
return;
|
|
234
310
|
const job = this.job;
|
|
235
311
|
this.job = undefined;
|
|
312
|
+
this.background = false;
|
|
313
|
+
if (this.buffer) {
|
|
314
|
+
job.append(this.buffer);
|
|
315
|
+
this.buffer = "";
|
|
316
|
+
}
|
|
317
|
+
if (code === 0) {
|
|
318
|
+
job.finish("done", 0);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
236
321
|
job.append(`\n${message}`);
|
|
237
322
|
job.finish("failed", code ?? 1);
|
|
238
323
|
}
|
|
239
324
|
kill(): void {
|
|
240
325
|
this.dead = true;
|
|
241
|
-
|
|
242
|
-
|
|
326
|
+
this.claimed = false;
|
|
327
|
+
this.background = false;
|
|
328
|
+
this.buffer = "";
|
|
329
|
+
terminateProcessTree(this.child);
|
|
243
330
|
const index = pool.indexOf(this);
|
|
244
331
|
if (index >= 0)
|
|
245
332
|
pool.splice(index, 1);
|
|
@@ -250,18 +337,21 @@ export async function executeShellCommand(command: string, cwd: string, options:
|
|
|
250
337
|
pruneJobs();
|
|
251
338
|
if (options.signal?.aborted)
|
|
252
339
|
return { ok: false, status: "killed", output: "command aborted before start" };
|
|
253
|
-
const timeoutMs = options.timeoutMs
|
|
340
|
+
const timeoutMs = normalizeTimeoutMs(options.timeoutMs, DEFAULT_TIMEOUT_MS);
|
|
254
341
|
const backgroundAfterMs = options.backgroundAfterMs ?? DEFAULT_BACKGROUND_MS;
|
|
255
|
-
const
|
|
342
|
+
const shape = analyzeShellShape(command);
|
|
343
|
+
const useResident = process.platform !== "win32" && !shape.oneShot;
|
|
256
344
|
let job: ShellJob | undefined;
|
|
257
345
|
if (useResident) {
|
|
258
|
-
const resident = acquireResident(options.turn, cwd);
|
|
259
|
-
if (resident === "busy")
|
|
260
|
-
return { ok: false, status: "failed", output: "both pool shells are busy; poll or kill a session first" };
|
|
346
|
+
const resident = await acquireResident(options.turn, cwd, timeoutMs, options.signal);
|
|
261
347
|
if (resident === "limit")
|
|
262
348
|
return { ok: false, status: "failed", output: spawnLimitMessage() };
|
|
349
|
+
if (resident === "timeout")
|
|
350
|
+
return { ok: false, status: "timeout", output: "shell pool is busy; timed out waiting for a free resident shell" };
|
|
351
|
+
if (resident === "killed")
|
|
352
|
+
return { ok: false, status: "killed", output: "command aborted before start" };
|
|
263
353
|
job = new ShellJob(command, cwd);
|
|
264
|
-
resident.run(job, timeoutMs);
|
|
354
|
+
resident.run(job, timeoutMs, shape.background);
|
|
265
355
|
}
|
|
266
356
|
else {
|
|
267
357
|
if (!maySpawn(options.turn))
|
|
@@ -269,16 +359,15 @@ export async function executeShellCommand(command: string, cwd: string, options:
|
|
|
269
359
|
job = startOneShot(command, cwd);
|
|
270
360
|
}
|
|
271
361
|
jobs.set(job.id, job);
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
options.signal?.
|
|
275
|
-
|
|
276
|
-
clearTimeout(timer);
|
|
277
|
-
options.signal?.removeEventListener("abort", onAbort);
|
|
278
|
-
};
|
|
362
|
+
job.armTimeout(timeoutMs);
|
|
363
|
+
job.attachAbort(options.signal);
|
|
364
|
+
if (options.signal?.aborted)
|
|
365
|
+
job.kill("killed");
|
|
279
366
|
await Promise.race([job.done(), delay(backgroundAfterMs)]);
|
|
280
367
|
if (job.status === "running") {
|
|
281
|
-
|
|
368
|
+
// Task keeps running: only the foreground Esc/abort association is
|
|
369
|
+
// released. The timeout stays armed until the job really finishes.
|
|
370
|
+
job.detachForeground();
|
|
282
371
|
return {
|
|
283
372
|
ok: true,
|
|
284
373
|
status: "running",
|
|
@@ -287,7 +376,6 @@ export async function executeShellCommand(command: string, cwd: string, options:
|
|
|
287
376
|
nextPollMs: job.nextPollMs,
|
|
288
377
|
};
|
|
289
378
|
}
|
|
290
|
-
cleanup();
|
|
291
379
|
return syncResult(job);
|
|
292
380
|
}
|
|
293
381
|
|
|
@@ -342,11 +430,74 @@ export function shutdownShells(): void {
|
|
|
342
430
|
jobs.clear();
|
|
343
431
|
}
|
|
344
432
|
|
|
345
|
-
|
|
433
|
+
/**
|
|
434
|
+
* Exit-safe stop: send SIGTERM, wait briefly for close, then force SIGKILL.
|
|
435
|
+
* Use this when the process is about to exit and must not leave children.
|
|
436
|
+
*/
|
|
437
|
+
export async function shutdownShellsAsync(graceMs = 400): Promise<void> {
|
|
438
|
+
const running = Array.from(jobs.values()).filter((job) => job.status === "running");
|
|
439
|
+
const residents = pool.filter((resident) => !resident.dead);
|
|
440
|
+
for (const job of running)
|
|
441
|
+
job.kill("killed");
|
|
442
|
+
for (const resident of residents)
|
|
443
|
+
resident.kill();
|
|
444
|
+
pool.length = 0;
|
|
445
|
+
jobs.clear();
|
|
446
|
+
const children: ChildProcess[] = [];
|
|
447
|
+
for (const job of running) {
|
|
448
|
+
if (job.child)
|
|
449
|
+
children.push(job.child);
|
|
450
|
+
}
|
|
451
|
+
for (const resident of residents)
|
|
452
|
+
children.push(resident.child);
|
|
453
|
+
if (children.length === 0)
|
|
454
|
+
return;
|
|
455
|
+
await Promise.race([
|
|
456
|
+
Promise.all(children.map((child) => waitForChildClose(child, graceMs))),
|
|
457
|
+
delay(graceMs + 50),
|
|
458
|
+
]);
|
|
459
|
+
for (const child of children) {
|
|
460
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
461
|
+
terminateProcessTree(child, { graceMs: 0 });
|
|
462
|
+
}
|
|
463
|
+
await delay(50);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function waitForChildClose(child: ChildProcess, ms: number): Promise<void> {
|
|
467
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
468
|
+
return Promise.resolve();
|
|
469
|
+
return new Promise((resolve) => {
|
|
470
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
471
|
+
const done = () => {
|
|
472
|
+
if (timer)
|
|
473
|
+
clearTimeout(timer);
|
|
474
|
+
child.removeListener("close", done);
|
|
475
|
+
resolve();
|
|
476
|
+
};
|
|
477
|
+
timer = setTimeout(done, ms);
|
|
478
|
+
child.once("close", done);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function shellPoolSize(): number {
|
|
483
|
+
return poolLimit;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Clamp and apply the resident shell concurrency (1..6, default 2). */
|
|
487
|
+
export function setShellPoolSize(size: number): number {
|
|
488
|
+
const parsed = Number(size);
|
|
489
|
+
poolLimit = Number.isFinite(parsed)
|
|
490
|
+
? Math.min(MAX_SHELL_POOL_SIZE, Math.max(1, Math.floor(parsed)))
|
|
491
|
+
: DEFAULT_SHELL_POOL_SIZE;
|
|
492
|
+
return poolLimit;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function shellPoolStats(): { spawns: number; active: number; pool: number; maxPool: number; maxSpawnPerTurn: number } {
|
|
346
496
|
return {
|
|
347
497
|
spawns,
|
|
348
498
|
active: Array.from(jobs.values()).filter((job) => job.status === "running").length,
|
|
349
499
|
pool: pool.filter((resident) => !resident.dead).length,
|
|
500
|
+
maxPool: poolLimit,
|
|
350
501
|
maxSpawnPerTurn: maxSpawnPerTurn(),
|
|
351
502
|
};
|
|
352
503
|
}
|
|
@@ -374,22 +525,95 @@ function startOneShot(command: string, cwd: string): ShellJob {
|
|
|
374
525
|
return job;
|
|
375
526
|
}
|
|
376
527
|
|
|
377
|
-
function acquireResident(turn: number | undefined, cwd: string): ResidentShell | "
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
528
|
+
async function acquireResident(turn: number | undefined, cwd: string, waitMs: number, signal?: AbortSignal): Promise<ResidentShell | "limit" | "timeout" | "killed"> {
|
|
529
|
+
const deadline = Date.now() + waitMs;
|
|
530
|
+
while (true) {
|
|
531
|
+
for (const resident of pool) {
|
|
532
|
+
if (resident.claimIfIdle())
|
|
533
|
+
return resident;
|
|
534
|
+
}
|
|
535
|
+
const live = pool.filter((resident) => !resident.dead);
|
|
536
|
+
if (live.length < poolLimit && maySpawn(turn)) {
|
|
537
|
+
const resident = new ResidentShell(cwd);
|
|
538
|
+
resident.claimIfIdle();
|
|
539
|
+
pool.push(resident);
|
|
540
|
+
return resident;
|
|
541
|
+
}
|
|
542
|
+
// No free shell and no spawn budget: wait for a running command to
|
|
543
|
+
// finish instead of failing the third call with "both shells busy".
|
|
544
|
+
if (live.length === 0)
|
|
545
|
+
return "limit";
|
|
546
|
+
if (signal?.aborted)
|
|
547
|
+
return "killed";
|
|
548
|
+
const remaining = deadline - Date.now();
|
|
549
|
+
if (remaining <= 0)
|
|
550
|
+
return "timeout";
|
|
551
|
+
await delay(Math.min(remaining, 50));
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const INTERACTIVE_COMMANDS = new Set(["vim", "nano", "less", "more", "ssh", "su", "login", "read", "top", "htop", "tmux", "screen"]);
|
|
556
|
+
const REPL_COMMANDS = new Set(["python", "python3", "node", "bash", "zsh", "fish", "irb", "sqlite3"]);
|
|
557
|
+
const WRAPPER_COMMANDS = new Set(["sudo", "env", "nohup", "command", "time", "exec"]);
|
|
558
|
+
|
|
559
|
+
interface ShellShape {
|
|
560
|
+
oneShot: boolean;
|
|
561
|
+
background: boolean;
|
|
388
562
|
}
|
|
389
563
|
|
|
390
|
-
function
|
|
391
|
-
|
|
392
|
-
|
|
564
|
+
function analyzeShellShape(command: string): ShellShape {
|
|
565
|
+
let ast: any;
|
|
566
|
+
try {
|
|
567
|
+
ast = parse(command, { mode: "bash" });
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
// Fall back to the historical heuristic only when the parser cannot
|
|
571
|
+
// classify the command at all.
|
|
572
|
+
return {
|
|
573
|
+
oneShot: /(^|\s)(vim|nano|less|more|ssh|su|login|read|top|htop|tmux|screen)(\s|$)/.test(command)
|
|
574
|
+
|| /(^|\s)(python|python3|node|bash|zsh|fish|irb|sqlite3)(\s|$)/.test(command) && !/\s-(c|e|m)\s/.test(command),
|
|
575
|
+
background: /(^|[^&>])&([^&>]|$)/.test(command),
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
let oneShot = false;
|
|
579
|
+
let background = false;
|
|
580
|
+
const walk = (node: any, depth = 0): void => {
|
|
581
|
+
if (!node || typeof node !== "object" || depth > 32)
|
|
582
|
+
return;
|
|
583
|
+
if (Array.isArray(node)) {
|
|
584
|
+
for (const entry of node)
|
|
585
|
+
walk(entry, depth + 1);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
// bash-parser also marks `cmd &> file` as async; only retire the
|
|
589
|
+
// resident for a real background operator, not a redirect.
|
|
590
|
+
if (node.async === true && /(^|[^&>])&([^&>]|$)/.test(command))
|
|
591
|
+
background = true;
|
|
592
|
+
if (node.type === "Command" && typeof node.name?.text === "string") {
|
|
593
|
+
const name = node.name.text;
|
|
594
|
+
const args = Array.isArray(node.suffix)
|
|
595
|
+
? node.suffix.map((word: any) => typeof word?.text === "string" ? word.text : "").filter(Boolean)
|
|
596
|
+
: [];
|
|
597
|
+
const replOptions = (values: string[]) => values.some((arg: string) => /^(-c|-e|-m|-p|--eval|--print|--command)$/.test(arg));
|
|
598
|
+
if (INTERACTIVE_COMMANDS.has(name) || REPL_COMMANDS.has(name) && !replOptions(args))
|
|
599
|
+
oneShot = true;
|
|
600
|
+
if (WRAPPER_COMMANDS.has(name)) {
|
|
601
|
+
const wrappedIndex = args.findIndex((arg: string) => INTERACTIVE_COMMANDS.has(arg) || REPL_COMMANDS.has(arg));
|
|
602
|
+
if (wrappedIndex >= 0) {
|
|
603
|
+
const wrappedName = args[wrappedIndex];
|
|
604
|
+
if (INTERACTIVE_COMMANDS.has(wrappedName) ||
|
|
605
|
+
REPL_COMMANDS.has(wrappedName) && !replOptions(args.slice(wrappedIndex + 1)))
|
|
606
|
+
oneShot = true;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
for (const value of Object.values(node)) {
|
|
611
|
+
if (value && typeof value === "object")
|
|
612
|
+
walk(value, depth + 1);
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
walk(ast);
|
|
616
|
+
return { oneShot, background };
|
|
393
617
|
}
|
|
394
618
|
|
|
395
619
|
function maySpawn(turn: number | undefined): boolean {
|