@amenophis1er/foreman 0.1.8 → 0.1.9
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/README.md +1 -1
- package/package.json +1 -1
- package/src/orchestrator.test.ts +8 -1
- package/src/orchestrator.ts +12 -0
- package/src/policy.test.ts +25 -0
- package/src/policy.ts +53 -3
- package/src/server.ts +19 -0
- package/src/types.ts +6 -0
- package/ui/dist/assets/{index-h0osI3Nn.js → index--dvfX3Mo.js} +36 -36
- package/ui/dist/index.html +1 -1
package/README.md
CHANGED
|
@@ -230,7 +230,7 @@ result.
|
|
|
230
230
|
```sh
|
|
231
231
|
npm ci && npm run setup # dependencies, then the dashboard build
|
|
232
232
|
npm start # serves http://localhost:4177
|
|
233
|
-
npm test #
|
|
233
|
+
npm test # 363 tests, node:test
|
|
234
234
|
npm run typecheck # server and dashboard
|
|
235
235
|
npm run dev # API + Vite together
|
|
236
236
|
scripts/dev-restart.sh # restarts the server only when nothing would be lost
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amenophis1er/foreman",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Autonomous mission runner on the Claude Agent SDK: a director plans, delegates to workers, verifies, and reports — from one dashboard, your phone, or the CLI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/src/orchestrator.test.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
stalledWorkerReport, workerStatusBlock,
|
|
10
10
|
watchRepeats, watchSilence, REPEAT_EXEMPT, observeToolUse,
|
|
11
11
|
DEFAULT_ASK_TIMEOUT_MS, armAskTimeout, unattendedAnswer, unattendedDenyMessage,
|
|
12
|
-
tokenCapLabel,
|
|
12
|
+
tokenCapLabel, stopReasonOf,
|
|
13
13
|
} from './orchestrator.js';
|
|
14
14
|
import { makePolicy, type PendingPermission } from './policy.js';
|
|
15
15
|
import type { AgentEnv } from './provider.js';
|
|
@@ -1249,3 +1249,10 @@ test('tokenCapLabel rounds to a figure a director can hold in mind', () => {
|
|
|
1249
1249
|
assert.equal(tokenCapLabel(250_000), '250k tokens');
|
|
1250
1250
|
assert.equal(tokenCapLabel(400), '400 tokens');
|
|
1251
1251
|
});
|
|
1252
|
+
|
|
1253
|
+
test('stopReasonOf names the cap behind a capReached sentence', () => {
|
|
1254
|
+
assert.equal(stopReasonOf('BUDGET CAP REACHED: $10.02 of $10.00.'), 'budget');
|
|
1255
|
+
assert.equal(stopReasonOf('TURN CAP REACHED: 150 director turns.'), 'turns');
|
|
1256
|
+
assert.equal(stopReasonOf('TIME CAP REACHED: 240 minutes.'), 'time');
|
|
1257
|
+
assert.equal(stopReasonOf('TOKEN CAP REACHED: 20.1M tokens.'), 'tokens');
|
|
1258
|
+
});
|
package/src/orchestrator.ts
CHANGED
|
@@ -265,6 +265,14 @@ const DEFAULT_MAX_TURNS = 150;
|
|
|
265
265
|
*/
|
|
266
266
|
export const DEFAULT_MAX_TOKENS = 20_000_000;
|
|
267
267
|
|
|
268
|
+
/** The cap behind a capReached() sentence, as the run record stores it. */
|
|
269
|
+
export function stopReasonOf(cap: string): 'budget' | 'turns' | 'time' | 'tokens' {
|
|
270
|
+
if (cap.startsWith('TURN')) return 'turns';
|
|
271
|
+
if (cap.startsWith('TIME')) return 'time';
|
|
272
|
+
if (cap.startsWith('TOKEN')) return 'tokens';
|
|
273
|
+
return 'budget';
|
|
274
|
+
}
|
|
275
|
+
|
|
268
276
|
/**
|
|
269
277
|
* The token cap as a director should read it: "5M tokens", not "5000000".
|
|
270
278
|
* A budget is only useful if the agent it constrains can hold it in mind, and
|
|
@@ -1725,6 +1733,8 @@ export class MissionRun {
|
|
|
1725
1733
|
const maxSeconds = this.meta.maxSeconds ?? DEFAULT_MAX_SECONDS;
|
|
1726
1734
|
if (elapsed < maxSeconds + CAP_WATCHDOG_GRACE_MS / 1000) return;
|
|
1727
1735
|
this.hardStopped = true;
|
|
1736
|
+
this.meta.stopReason = 'time';
|
|
1737
|
+
this.saveMeta(this.meta);
|
|
1728
1738
|
this.emit('budget_stop', {
|
|
1729
1739
|
costUsd: this.meta.costUsd,
|
|
1730
1740
|
budgetUsd: this.meta.budgetUsd,
|
|
@@ -1797,6 +1807,8 @@ export class MissionRun {
|
|
|
1797
1807
|
const cap = this.capReached();
|
|
1798
1808
|
if (!cap || this.budgetStopped) return null;
|
|
1799
1809
|
this.budgetStopped = true;
|
|
1810
|
+
this.meta.stopReason = stopReasonOf(cap);
|
|
1811
|
+
this.saveMeta(this.meta);
|
|
1800
1812
|
this.emit('budget_stop', {
|
|
1801
1813
|
costUsd: this.meta.costUsd,
|
|
1802
1814
|
budgetUsd: this.meta.budgetUsd,
|
package/src/policy.test.ts
CHANGED
|
@@ -149,6 +149,31 @@ test('a file write under the root does not escape', () => {
|
|
|
149
149
|
assert.ok(bashEscapesFolder('echo hi > /tmp/out.txt', FOLDER, [ROOT]), 'outside the root still prompts');
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
+
test('operators inside quotes are text: a perl program with => is not a redirect', () => {
|
|
153
|
+
// The exact shape that put an approval card in front of an in-folder edit:
|
|
154
|
+
// `=>/` inside the single-quoted program read as `>` + the path `/…`.
|
|
155
|
+
assert.equal(bashEscapesFolder(`perl -0pi -e 's/\\.filter\\(\\(\\[a, b\\]\\) =>/.filter(([a]) =>/' ${ROOT}/x.tsx`, FOLDER, [ROOT]), null);
|
|
156
|
+
assert.equal(bashEscapesFolder(`set -e; D=src/f; perl -0pi -e 's/ a,\\n b,/ a,/' $D/one.tsx; perl -0pi -e 's/x =>/y/' $D/two.tsx`, FOLDER, [ROOT]), null);
|
|
157
|
+
assert.equal(bashEscapesFolder('echo "a > b; c | d & e" && echo \'x>/etc/passwd\'', FOLDER, [ROOT]), null);
|
|
158
|
+
assert.equal(bashEscapesFolder(`git commit -m "fix: handle a > b" ${ROOT}`, FOLDER, [ROOT]), null);
|
|
159
|
+
// A real redirect next to a quoted one is still seen, with its real text.
|
|
160
|
+
assert.deepEqual(bashEscape('echo "x > y" > /tmp/out.txt', FOLDER), {
|
|
161
|
+
reason: 'echo "x > y" > /tmp/out.txt — redirects output outside the mission folder', path: '/tmp/out.txt', grant: '/tmp',
|
|
162
|
+
});
|
|
163
|
+
// Quoted paths still resolve: the mask touches operators, not text.
|
|
164
|
+
assert.ok(bashEscapesFolder('rm -rf "/opt/homebrew"', FOLDER, [ROOT]));
|
|
165
|
+
// A quoted path with spaces is one token now, so it resolves and is judged.
|
|
166
|
+
assert.equal(bashEscape(`echo hi > "/tmp/o u t.txt"`, FOLDER)!.path, '/tmp/o u t.txt');
|
|
167
|
+
assert.equal(bashEscapesFolder(`echo hi > "${ROOT}/o u t.txt"`, FOLDER, [ROOT]), null);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('a shell -c body is analysed as a command, so quoting cannot hide a cd', () => {
|
|
171
|
+
assert.ok(bashEscapesFolder(`sh -c 'cd /tmp && npm install'`, FOLDER, [ROOT]));
|
|
172
|
+
assert.match(bashEscapesFolder(`bash -c "mkdir -p /tmp/x/y"`, FOLDER, [ROOT])!, /creates a path outside/);
|
|
173
|
+
assert.equal(bashEscapesFolder(`sh -c 'npm test && echo ok'`, FOLDER, [ROOT]), null);
|
|
174
|
+
assert.equal(bashEscape(`bash -c 'cd /tmp/x'`, FOLDER)!.grant, '/tmp/x');
|
|
175
|
+
});
|
|
176
|
+
|
|
152
177
|
test('bashEscape carries the offending path and the directory to grant', () => {
|
|
153
178
|
// Directory-ish targets: the path itself is the grant.
|
|
154
179
|
assert.deepEqual(bashEscape('cd /tmp/x && ls', FOLDER), {
|
package/src/policy.ts
CHANGED
|
@@ -153,15 +153,52 @@ const WRITE_VERBS: Record<string, { mode: ArgMode; opts?: string[]; does: string
|
|
|
153
153
|
const FILE_OPTS = new Set(['-o', '--output', '-O', '--output-document']);
|
|
154
154
|
/** Prefixes that merely wrap the real verb (`sudo rm`, `env FOO=1 mkdir`). */
|
|
155
155
|
const WRAPPERS = new Set(['sudo', 'env', 'command', 'exec', 'nohup', 'time', 'nice', 'builtin']);
|
|
156
|
+
/** Shells whose `-c` argument is a command in its own right. */
|
|
157
|
+
const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash']);
|
|
156
158
|
/** Pseudo-devices: writing to them touches nothing on disk. */
|
|
157
159
|
const DEV_SINKS = new Set(['/dev/null', '/dev/stdout', '/dev/stderr', '/dev/tty']);
|
|
158
160
|
const URL_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
159
161
|
/** `> f`, `>> f`, `2> f`, `&> f` — but not `2>&1` / `>&2` (fd duplication). */
|
|
160
162
|
const REDIRECT_RE = /(?:&>>?|\d*>>?)(?!&)\s*(\S+)/g;
|
|
161
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Shell operators inside quotes are text, not operators. The redirect scan
|
|
166
|
+
* and the segment split below work on the raw string, so a perl program in
|
|
167
|
+
* single quotes — `perl -pi -e 's/x =>/y/' f` — read as `>` followed by the
|
|
168
|
+
* path `/y/`, which resolved to the filesystem root and put an approval card
|
|
169
|
+
* in front of an ordinary in-folder edit. Operators inside a quoted region
|
|
170
|
+
* are swapped for private-use characters before the scan and swapped back by
|
|
171
|
+
* `bare()`, so tokens still carry their real text. Spaces inside quotes are
|
|
172
|
+
* masked too, so a quoted string is one token: `sh -c '…'` bodies and paths
|
|
173
|
+
* with spaces both come through whole. Quoting rules followed:
|
|
174
|
+
* single quotes take everything literally; double quotes honour backslash;
|
|
175
|
+
* a backslash outside quotes escapes the next character.
|
|
176
|
+
*/
|
|
177
|
+
const MASK: Record<string, string> = { '>': '\uE001', '<': '\uE002', '|': '\uE003', ';': '\uE004', '&': '\uE005', '\n': '\uE006', ' ': '\uE007', '\t': '\uE008' };
|
|
178
|
+
const UNMASK_RE = /[\uE001-\uE008]/g;
|
|
179
|
+
const UNMASK: Record<string, string> = Object.fromEntries(Object.entries(MASK).map(([k, v]) => [v, k]));
|
|
180
|
+
function maskQuoted(command: string): string {
|
|
181
|
+
let out = '';
|
|
182
|
+
let quote: '"' | "'" | null = null;
|
|
183
|
+
for (let i = 0; i < command.length; i++) {
|
|
184
|
+
const c = command[i];
|
|
185
|
+
if (quote === null) {
|
|
186
|
+
if (c === '\\' && i + 1 < command.length) { out += c + command[++i]; continue; }
|
|
187
|
+
if (c === '"' || c === "'") quote = c;
|
|
188
|
+
out += c;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (quote === '"' && c === '\\' && i + 1 < command.length) { out += c + command[++i]; continue; }
|
|
192
|
+
if (c === quote) { quote = null; out += c; continue; }
|
|
193
|
+
out += MASK[c] ?? c;
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
const unmask = (s: string): string => s.replace(UNMASK_RE, (ch) => UNMASK[ch] ?? ch);
|
|
198
|
+
|
|
162
199
|
/** Strips one layer of matching quotes and shell grouping punctuation. */
|
|
163
200
|
function bare(token: string): string {
|
|
164
|
-
let t = token.replace(/^[(\\]+|[)]+$/g, '');
|
|
201
|
+
let t = unmask(token).replace(/^[(\\]+|[)]+$/g, '');
|
|
165
202
|
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) t = t.slice(1, -1);
|
|
166
203
|
return t;
|
|
167
204
|
}
|
|
@@ -266,7 +303,7 @@ export function bashEscape(
|
|
|
266
303
|
// appended to while we walk, and one command must see one consistent view.
|
|
267
304
|
const roots = [root, ...extraRoots];
|
|
268
305
|
const outside = (p: string): boolean => !DEV_SINKS.has(p) && !underAnyRoot(p, roots);
|
|
269
|
-
const label = (
|
|
306
|
+
const label = (raw: string): string => { const seg = unmask(raw); return seg.length > 80 ? seg.slice(0, 77) + '…' : seg; };
|
|
270
307
|
const hit = (segment: string, does: string, p: string, isDir: boolean): BashEscape => ({
|
|
271
308
|
reason: `${label(segment)} — ${does} outside the mission folder`,
|
|
272
309
|
path: p,
|
|
@@ -275,7 +312,7 @@ export function bashEscape(
|
|
|
275
312
|
|
|
276
313
|
let cwd = root; // virtual cwd, so `cd sub && rm -rf ../../x` resolves correctly
|
|
277
314
|
|
|
278
|
-
const segments = command
|
|
315
|
+
const segments = maskQuoted(command)
|
|
279
316
|
.split(/&&|\|\||;|\n|\|(?!\|)|(?<![&>\d])&(?![&>])/)
|
|
280
317
|
.map((s) => s.trim())
|
|
281
318
|
.filter(Boolean);
|
|
@@ -295,6 +332,19 @@ export function bashEscape(
|
|
|
295
332
|
const args = words.slice(i + 1);
|
|
296
333
|
const flagless = args.filter((a) => !a.startsWith('-'));
|
|
297
334
|
|
|
335
|
+
// `sh -c '…'`: the quoted body is a command of its own, run from the
|
|
336
|
+
// current virtual cwd. Analysed as one, so quoting cannot hide a `cd /tmp`
|
|
337
|
+
// — masking made the body opaque to the split above, and this is where it
|
|
338
|
+
// is looked at instead.
|
|
339
|
+
if (SHELLS.has(verb)) {
|
|
340
|
+
const c = args.indexOf('-c');
|
|
341
|
+
if (c >= 0 && c + 1 < args.length) {
|
|
342
|
+
const inner = bashEscape(args[c + 1], cwd, roots);
|
|
343
|
+
if (inner) return { ...inner, reason: `${label(segment)} — ${inner.reason.replace(/^.*? — /, '')}` };
|
|
344
|
+
}
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
298
348
|
if (verb === 'cd' || verb === 'pushd') {
|
|
299
349
|
const target = flagless[0] ?? '~'; // bare `cd` goes home
|
|
300
350
|
const p = asPath(target, cwd) ?? path.resolve(cwd, target);
|
package/src/server.ts
CHANGED
|
@@ -1724,6 +1724,8 @@ async function startRun(
|
|
|
1724
1724
|
*/
|
|
1725
1725
|
async function resumeRun(projectId: string, meta: RunMeta, pick: {
|
|
1726
1726
|
directorModel?: string; directorProviderId?: string; workerModel?: string; workerProviderId?: string;
|
|
1727
|
+
/** A new cap for the resumed attempt — the answer to "stopped at its budget". */
|
|
1728
|
+
budgetUsd?: number;
|
|
1727
1729
|
} = {}): Promise<void> {
|
|
1728
1730
|
const sessionId = meta.directorSessionId;
|
|
1729
1731
|
// Resume re-reads Settings for tool policy and auto-allow, so a policy
|
|
@@ -1748,9 +1750,24 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
|
|
|
1748
1750
|
if (workerChanged) { meta.workerModel = modelChoice(pick.workerModel); meta.workerProviderId = pick.workerProviderId; }
|
|
1749
1751
|
meta.toolPolicy = settings.toolPolicy;
|
|
1750
1752
|
meta.autoAllowReadOnly = settings.autoAllowReadOnly;
|
|
1753
|
+
// A raised cap rides the resume: the run that stopped at $10 continues
|
|
1754
|
+
// under $15, and the transcript says who moved it and from where. Written
|
|
1755
|
+
// to the log as a settings change, which is what it is.
|
|
1756
|
+
const budgetWas = meta.budgetUsd;
|
|
1757
|
+
if (typeof pick.budgetUsd === 'number' && Number.isFinite(pick.budgetUsd) && pick.budgetUsd > 0 && pick.budgetUsd !== meta.budgetUsd) {
|
|
1758
|
+
meta.budgetUsd = pick.budgetUsd;
|
|
1759
|
+
}
|
|
1760
|
+
const stoppedAt = meta.stopReason;
|
|
1761
|
+
meta.stopReason = undefined;
|
|
1751
1762
|
meta.status = 'running';
|
|
1752
1763
|
meta.endedAt = undefined;
|
|
1753
1764
|
meta.resumes = (meta.resumes ?? 0) + 1;
|
|
1765
|
+
if (meta.budgetUsd !== budgetWas) {
|
|
1766
|
+
makeEmitter(meta.id, projectId)('settings_changed', {
|
|
1767
|
+
budgetUsd: meta.budgetUsd,
|
|
1768
|
+
changes: [`Budget ${meta.budgetUsd > budgetWas ? 'raised' : 'lowered'} to $${meta.budgetUsd.toFixed(2)} (was $${budgetWas.toFixed(2)}) before resume${stoppedAt === 'budget' ? ' — the last attempt stopped at its cap' : ''}.`],
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1754
1771
|
if (meta.git) {
|
|
1755
1772
|
const back = await ensureMissionBranch(meta.folder, meta.git.branch);
|
|
1756
1773
|
if (back) makeEmitter(meta.id, projectId)('git_note', { text: `Could not return to ${meta.git.branch} (${back}); the resumed mission runs on whatever is checked out.` });
|
|
@@ -2740,6 +2757,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
2740
2757
|
const overrides = {
|
|
2741
2758
|
directorModel: str(resumeBody.directorModel), directorProviderId: str(resumeBody.directorProviderId),
|
|
2742
2759
|
workerModel: str(resumeBody.workerModel), workerProviderId: str(resumeBody.workerProviderId),
|
|
2760
|
+
budgetUsd: typeof resumeBody.budgetUsd === 'number' && Number.isFinite(resumeBody.budgetUsd) && resumeBody.budgetUsd > 0
|
|
2761
|
+
? Math.round(resumeBody.budgetUsd * 100) / 100 : undefined,
|
|
2743
2762
|
};
|
|
2744
2763
|
void resumeRun(meta.projectId, meta, overrides);
|
|
2745
2764
|
json(res, 200, { ok: true });
|
package/src/types.ts
CHANGED
|
@@ -325,6 +325,12 @@ export interface RunMeta {
|
|
|
325
325
|
askTimeoutMs?: number;
|
|
326
326
|
/** Give agents a headless Playwright browser (navigate, click, screenshot). */
|
|
327
327
|
browserTools?: boolean;
|
|
328
|
+
/**
|
|
329
|
+
* Which cap ended the last attempt, when one did. The dashboard reads it
|
|
330
|
+
* to offer the one action that helps — raise the budget and resume — and
|
|
331
|
+
* a resume clears it. Absent when the run ended for any other reason.
|
|
332
|
+
*/
|
|
333
|
+
stopReason?: 'budget' | 'turns' | 'time' | 'tokens';
|
|
328
334
|
folder: string;
|
|
329
335
|
mission: string;
|
|
330
336
|
budgetUsd: number;
|