@amenophis1er/foreman 0.1.6 → 0.1.8
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 +9 -3
- package/bin/foreman.mjs +2 -1
- package/package.json +1 -1
- package/src/browser.ts +188 -0
- package/src/cli.ts +19 -1
- package/src/clone.test.ts +35 -0
- package/src/clone.ts +129 -0
- package/src/completion.test.ts +30 -0
- package/src/completion.ts +113 -0
- package/src/deck.test.ts +2 -2
- package/src/deck.ts +32 -6
- package/src/fleet-planner.ts +5 -4
- package/src/gitwork.test.ts +95 -0
- package/src/gitwork.ts +239 -0
- package/src/guard.test.ts +104 -0
- package/src/guard.ts +131 -0
- package/src/http-body.test.ts +42 -0
- package/src/http-body.ts +57 -0
- package/src/memory.test.ts +46 -0
- package/src/memory.ts +107 -0
- package/src/notify/commands.ts +1 -1
- package/src/notify/telegram.ts +1 -1
- package/src/notify.ts +5 -0
- package/src/orchestrator.test.ts +104 -0
- package/src/orchestrator.ts +109 -47
- package/src/planner.ts +7 -2
- package/src/preflight.ts +29 -43
- package/src/role-provider.test.ts +55 -0
- package/src/role-provider.ts +65 -0
- package/src/server.ts +569 -63
- package/src/services.test.ts +36 -1
- package/src/services.ts +60 -3
- package/src/store.test.ts +18 -0
- package/src/store.ts +16 -1
- package/src/track-record.test.ts +46 -0
- package/src/track-record.ts +149 -0
- package/src/types.ts +17 -0
- package/ui/dist/assets/index-h0osI3Nn.js +68 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-btSPOnoZ.js +0 -67
package/src/guard.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who is allowed to talk to Foreman, and from where.
|
|
3
|
+
*
|
|
4
|
+
* Foreman has no login: it trusts the network it listens on (loopback, and
|
|
5
|
+
* the tailnet when there is one). That trust is only worth anything if a
|
|
6
|
+
* request really comes from there, and two ordinary browser behaviours break
|
|
7
|
+
* it without any network access at all:
|
|
8
|
+
*
|
|
9
|
+
* - DNS rebinding. A page on `evil.example` can point its own name at
|
|
10
|
+
* 127.0.0.1 and then fetch `http://evil.example:4177/runs` — same-origin
|
|
11
|
+
* as far as the browser is concerned, so no CORS preflight, and the
|
|
12
|
+
* response goes back to the attacker. The packets do arrive on loopback;
|
|
13
|
+
* what gives it away is the Host header, which still says
|
|
14
|
+
* `evil.example:4177`. Refusing every Host that is not a name Foreman
|
|
15
|
+
* actually answers to closes it — hence 421 Misdirected Request.
|
|
16
|
+
*
|
|
17
|
+
* - Cross-site writes. Any page the operator happens to visit can POST a
|
|
18
|
+
* form or `fetch(..., {mode:'no-cors'})` at `http://localhost:4177/mkdir`.
|
|
19
|
+
* The browser labels those: an `Origin` header on the request, or
|
|
20
|
+
* `Sec-Fetch-Site: cross-site`. Unsafe methods are refused unless that
|
|
21
|
+
* label says the request came from Foreman's own page.
|
|
22
|
+
*
|
|
23
|
+
* Non-browser callers — curl, `src/cli.ts`, the Telegram code paths, the
|
|
24
|
+
* tests — send neither header, and are left alone. This is a guard against
|
|
25
|
+
* the browser, not an authentication scheme; it does not pretend to keep out
|
|
26
|
+
* anyone who can already open a socket to the port.
|
|
27
|
+
*/
|
|
28
|
+
import type http from 'node:http';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { underAnyRoot } from './policy.js';
|
|
31
|
+
|
|
32
|
+
/** What a Host header may say, given the port and the tailnet. */
|
|
33
|
+
interface GuardOpts {
|
|
34
|
+
port: number;
|
|
35
|
+
tailnet: { ip: string; dnsName?: string } | null;
|
|
36
|
+
/** `FOREMAN_BIND=all`: the operator opened it wide on purpose, so any Host is theirs. */
|
|
37
|
+
bindAll?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type GuardVerdict = { ok: true } | { ok: false; status: 421 | 403; error: string };
|
|
41
|
+
|
|
42
|
+
/** Methods that cannot change anything, so a cross-site one is harmless. */
|
|
43
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The `host:port` strings Foreman answers to. The MagicDNS name is kept
|
|
47
|
+
* separately because `tailscale serve` may front it on 443 (or any other
|
|
48
|
+
* HTTPS port), so its port is whatever the proxy chose and cannot be checked.
|
|
49
|
+
*/
|
|
50
|
+
function expectedHosts(opts: GuardOpts): { exact: Set<string>; anyPort: Set<string> } {
|
|
51
|
+
const exact = new Set([
|
|
52
|
+
`localhost:${opts.port}`,
|
|
53
|
+
`127.0.0.1:${opts.port}`,
|
|
54
|
+
`[::1]:${opts.port}`,
|
|
55
|
+
]);
|
|
56
|
+
if (opts.tailnet?.ip) exact.add(`${opts.tailnet.ip}:${opts.port}`);
|
|
57
|
+
const anyPort = new Set<string>();
|
|
58
|
+
if (opts.tailnet?.dnsName) anyPort.add(opts.tailnet.dnsName.toLowerCase());
|
|
59
|
+
return { exact, anyPort };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `laptop.ts.net:8443` → `laptop.ts.net`; `[::1]:4177` → `[::1]`. */
|
|
63
|
+
function hostOnly(hostHeader: string): string {
|
|
64
|
+
if (hostHeader.startsWith('[')) return hostHeader.slice(0, hostHeader.indexOf(']') + 1);
|
|
65
|
+
const i = hostHeader.lastIndexOf(':');
|
|
66
|
+
return i > 0 ? hostHeader.slice(0, i) : hostHeader;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function hostRecognised(host: string, opts: GuardOpts): boolean {
|
|
70
|
+
const h = host.toLowerCase();
|
|
71
|
+
const { exact, anyPort } = expectedHosts(opts);
|
|
72
|
+
return exact.has(h) || anyPort.has(hostOnly(h));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Is this request one Foreman should answer? Host first (rebinding), then —
|
|
77
|
+
* for anything that can change state — the browser's own account of where the
|
|
78
|
+
* request came from.
|
|
79
|
+
*/
|
|
80
|
+
export function requestAllowed(
|
|
81
|
+
req: { method?: string; headers: http.IncomingHttpHeaders },
|
|
82
|
+
opts: GuardOpts,
|
|
83
|
+
): GuardVerdict {
|
|
84
|
+
const host = typeof req.headers.host === 'string' ? req.headers.host.trim() : '';
|
|
85
|
+
if (!opts.bindAll) {
|
|
86
|
+
if (!host || !hostRecognised(host, opts)) return { ok: false, status: 421, error: 'unrecognised Host' };
|
|
87
|
+
} else if (!host) {
|
|
88
|
+
return { ok: false, status: 421, error: 'unrecognised Host' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const method = (req.method ?? 'GET').toUpperCase();
|
|
92
|
+
if (SAFE_METHODS.has(method)) return { ok: true };
|
|
93
|
+
|
|
94
|
+
const origin = typeof req.headers.origin === 'string' ? req.headers.origin.trim() : '';
|
|
95
|
+
if (origin) {
|
|
96
|
+
return originAllowed(origin, host, opts)
|
|
97
|
+
? { ok: true }
|
|
98
|
+
: { ok: false, status: 403, error: 'cross-site request refused' };
|
|
99
|
+
}
|
|
100
|
+
// No Origin: either a non-browser caller (curl, the CLI, the tests), or a
|
|
101
|
+
// browser that told us where it came from the other way. `same-origin` and
|
|
102
|
+
// `none` (typed in the address bar) are ours; `cross-site` and `same-site`
|
|
103
|
+
// are not.
|
|
104
|
+
const fetchSite = String(req.headers['sec-fetch-site'] ?? '').trim().toLowerCase();
|
|
105
|
+
if (!fetchSite || fetchSite === 'same-origin' || fetchSite === 'none') return { ok: true };
|
|
106
|
+
return { ok: false, status: 403, error: 'cross-site request refused' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** An Origin is ours when it is http/https at a host we answer to. */
|
|
110
|
+
function originAllowed(origin: string, host: string, opts: GuardOpts): boolean {
|
|
111
|
+
let u: URL;
|
|
112
|
+
try { u = new URL(origin); } catch { return false; }
|
|
113
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
|
114
|
+
// With bindAll there is no list to check against, so the standard
|
|
115
|
+
// same-origin rule is the honest one: the Origin must be the Host.
|
|
116
|
+
if (opts.bindAll) {
|
|
117
|
+
const authority = u.host.toLowerCase();
|
|
118
|
+
return authority === host.toLowerCase();
|
|
119
|
+
}
|
|
120
|
+
return hostRecognised(u.host.toLowerCase(), opts);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* May Foreman look at (or write into) `target`? True when it resolves to one
|
|
125
|
+
* of `roots` or somewhere beneath one. `path.resolve` collapses `..` first,
|
|
126
|
+
* so `~/Projects/../../etc` is judged as `/etc` and refused; containment
|
|
127
|
+
* itself is `underAnyRoot`, which is already the rule the permission cards use.
|
|
128
|
+
*/
|
|
129
|
+
export function pathPermitted(target: string, roots: Iterable<string>): boolean {
|
|
130
|
+
return underAnyRoot(path.resolve(target), roots);
|
|
131
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
ATTACHMENT_BODY_LIMIT, BodyError, DEFAULT_BODY_LIMIT, bodyLimitFor, parseBody,
|
|
5
|
+
} from './http-body.js';
|
|
6
|
+
|
|
7
|
+
test('bodyLimitFor: only POST /attachments gets the large cap', () => {
|
|
8
|
+
assert.equal(bodyLimitFor('POST', '/attachments'), ATTACHMENT_BODY_LIMIT);
|
|
9
|
+
assert.equal(bodyLimitFor('post', '/attachments'), ATTACHMENT_BODY_LIMIT);
|
|
10
|
+
assert.equal(bodyLimitFor('GET', '/attachments'), DEFAULT_BODY_LIMIT);
|
|
11
|
+
assert.equal(bodyLimitFor('POST', '/mkdir'), DEFAULT_BODY_LIMIT);
|
|
12
|
+
assert.equal(bodyLimitFor('POST', '/attachments/extra'), DEFAULT_BODY_LIMIT);
|
|
13
|
+
assert.equal(bodyLimitFor(undefined, '/runs'), DEFAULT_BODY_LIMIT);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('parseBody: an empty body is an empty object', () => {
|
|
17
|
+
assert.deepEqual(parseBody(''), {});
|
|
18
|
+
assert.deepEqual(parseBody(' '), {});
|
|
19
|
+
assert.deepEqual(parseBody(Buffer.alloc(0)), {});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('parseBody: an object comes back as itself', () => {
|
|
23
|
+
assert.deepEqual(parseBody('{"a":1,"b":"x"}'), { a: 1, b: 'x' });
|
|
24
|
+
assert.deepEqual(parseBody(Buffer.from('{"nested":{"k":true}}')), { nested: { k: true } });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('parseBody: JSON that is not an object has no fields to read', () => {
|
|
28
|
+
assert.deepEqual(parseBody('[1,2,3]'), {});
|
|
29
|
+
assert.deepEqual(parseBody('42'), {});
|
|
30
|
+
assert.deepEqual(parseBody('null'), {});
|
|
31
|
+
assert.deepEqual(parseBody('"hello"'), {});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('parseBody: junk is the client’s fault, not a 500', () => {
|
|
35
|
+
assert.throws(() => parseBody('{bad'), (err: unknown) => {
|
|
36
|
+
assert.ok(err instanceof BodyError);
|
|
37
|
+
assert.equal(err.status, 400);
|
|
38
|
+
assert.equal(err.message, 'invalid JSON body');
|
|
39
|
+
return true;
|
|
40
|
+
});
|
|
41
|
+
assert.throws(() => parseBody('{"a":}'), BodyError);
|
|
42
|
+
});
|
package/src/http-body.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How much request body Foreman will read, and what a bad one costs.
|
|
3
|
+
*
|
|
4
|
+
* Every route reads its JSON into memory. Without a cap, one request with a
|
|
5
|
+
* long-running body holds the process's whole heap hostage — no authentication
|
|
6
|
+
* stands between a stray script and that, and the operator's other missions
|
|
7
|
+
* die with the server. So there is a cap, and exceeding it is a 413 rather
|
|
8
|
+
* than an OOM.
|
|
9
|
+
*
|
|
10
|
+
* The cap is per route rather than global because one route is legitimately
|
|
11
|
+
* huge: attachments arrive base64-encoded inside the JSON body, and
|
|
12
|
+
* `src/attachments.ts` already allows ten files of 10 MiB each — roughly
|
|
13
|
+
* 133 MiB on the wire once base64 has added its third. Every other route
|
|
14
|
+
* carries a mission brief or a settings object, which 1 MiB covers many times
|
|
15
|
+
* over.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Enough for any brief, settings blob or model list; small enough to be free. */
|
|
19
|
+
export const DEFAULT_BODY_LIMIT = 1 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
/** Ten 10 MiB files, base64-inflated, plus room for the JSON around them. */
|
|
22
|
+
export const ATTACHMENT_BODY_LIMIT = 128 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
/** A body problem with the answer already decided: 400 for junk, 413 for too much. */
|
|
25
|
+
export class BodyError extends Error {
|
|
26
|
+
constructor(public status: number, message: string) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'BodyError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The cap for this route: the large one only where large bodies are the point. */
|
|
33
|
+
export function bodyLimitFor(method: string | undefined, pathname: string): number {
|
|
34
|
+
const m = (method ?? '').toUpperCase();
|
|
35
|
+
return m === 'POST' && pathname === '/attachments' ? ATTACHMENT_BODY_LIMIT : DEFAULT_BODY_LIMIT;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The body as an object. An empty body is `{}` (routes read optional fields
|
|
40
|
+
* off it and validate for themselves), and so is valid JSON that is not one —
|
|
41
|
+
* an array, a number or a bare string has none of the named fields the
|
|
42
|
+
* handlers destructure, so it is the empty object as far as they can tell.
|
|
43
|
+
* Only unparseable input is an error, and it is the client's: 400, not a 500
|
|
44
|
+
* whose body is the text of a `SyntaxError`.
|
|
45
|
+
*/
|
|
46
|
+
export function parseBody(buf: Buffer | string): Record<string, unknown> {
|
|
47
|
+
const text = typeof buf === 'string' ? buf : buf.toString();
|
|
48
|
+
if (!text.trim()) return {};
|
|
49
|
+
let parsed: unknown;
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(text);
|
|
52
|
+
} catch {
|
|
53
|
+
throw new BodyError(400, 'invalid JSON body');
|
|
54
|
+
}
|
|
55
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
56
|
+
? (parsed as Record<string, unknown>) : {};
|
|
57
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { mkdtemp, readFile } from 'node:fs/promises';
|
|
6
|
+
import { MEMORY_CAP_BYTES, memorySection, readMemory, scrubSecrets, writeMemory } from './memory.js';
|
|
7
|
+
|
|
8
|
+
test('scrubSecrets removes what looks like a credential and keeps the line readable', () => {
|
|
9
|
+
const r = scrubSecrets([
|
|
10
|
+
'Run tests with: npm test',
|
|
11
|
+
'ANTHROPIC_API_KEY=sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123456789',
|
|
12
|
+
'GitHub token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345 is in the CI',
|
|
13
|
+
'aws: AKIAIOSFODNN7EXAMPLE',
|
|
14
|
+
'Bearer: abcdefghijklmnopqrstuvwxyz1234567890',
|
|
15
|
+
'The port 8420 is taken by the preview server',
|
|
16
|
+
].join('\n'));
|
|
17
|
+
assert.ok(r.redacted >= 4, String(r.redacted));
|
|
18
|
+
assert.match(r.text, /^Run tests with: npm test$/m);
|
|
19
|
+
assert.match(r.text, /^ANTHROPIC_API_KEY=\[redacted\]$/m);
|
|
20
|
+
assert.doesNotMatch(r.text, /ghp_ABCDEF/);
|
|
21
|
+
assert.doesNotMatch(r.text, /AKIAIOSFODNN7EXAMPLE/);
|
|
22
|
+
assert.match(r.text, /port 8420 is taken/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('writeMemory: full-page replace, secrets stripped, capped at a page or two, then readMemory sees it', async () => {
|
|
26
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), 'memory-'));
|
|
27
|
+
const w = await writeMemory(dir, '# Notes\n- tests: npm test\n- API_TOKEN=abcdefghijklmnop1234\n');
|
|
28
|
+
assert.equal(w.redacted, 1); assert.equal(w.trimmed, false);
|
|
29
|
+
const m = await readMemory(dir);
|
|
30
|
+
assert.match(m.text, /- tests: npm test\n- API_TOKEN=\[redacted\]\n$/);
|
|
31
|
+
assert.ok(m.updatedAt);
|
|
32
|
+
const big = await writeMemory(dir, Array.from({ length: 2000 }, (_, i) => `- line ${i} about the project`).join('\n'));
|
|
33
|
+
assert.equal(big.trimmed, true);
|
|
34
|
+
assert.ok(big.bytes <= MEMORY_CAP_BYTES);
|
|
35
|
+
assert.ok((await readFile(path.join(dir, '.foreman', 'MEMORY.md'), 'utf8')).endsWith('\n'));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('memorySection: the director is told to write an empty memory; workers and the planner get nothing for empty; all get the page otherwise', () => {
|
|
39
|
+
assert.match(memorySection('', 'director'), /empty\. Before you finish, write it with mcp__foreman__remember/);
|
|
40
|
+
assert.equal(memorySection('', 'worker'), '');
|
|
41
|
+
assert.equal(memorySection('', 'planner'), '');
|
|
42
|
+
const s = memorySection('- port 8420 is taken', 'worker');
|
|
43
|
+
assert.match(s, /PROJECT MEMORY — what earlier crews learned/);
|
|
44
|
+
assert.match(s, /---\n- port 8420 is taken\n---/);
|
|
45
|
+
assert.match(memorySection('x', 'planner'), /Ground your advice in it/);
|
|
46
|
+
});
|
package/src/memory.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project memory: the page of notes a good contractor keeps about a site.
|
|
3
|
+
*
|
|
4
|
+
* `.foreman/MEMORY.md` — one file per project, human-readable, a page or two
|
|
5
|
+
* at most. How to run the tests, which port is taken, where the CSS lives
|
|
6
|
+
* and why, what the client hates. Read by the director, the workers and the
|
|
7
|
+
* planner at the start of every turn; written by the director at the end of
|
|
8
|
+
* a mission through one tool; visible in the rail so the human can see what
|
|
9
|
+
* their project "believes", and edit it with any text editor.
|
|
10
|
+
*
|
|
11
|
+
* Memory makes the crew better informed, never more powerful: it is prose
|
|
12
|
+
* in a prompt, and everything the crew may do is still exactly what the
|
|
13
|
+
* tool policy and the budget allow. It also never carries a secret — the
|
|
14
|
+
* server strips anything that looks like one before writing.
|
|
15
|
+
*/
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
18
|
+
|
|
19
|
+
export const MEMORY_FILE = '.foreman/MEMORY.md';
|
|
20
|
+
/** Bytes kept in the file. Past this the director is asked to prune, not append. */
|
|
21
|
+
export const MEMORY_CAP_BYTES = 8 * 1024;
|
|
22
|
+
/** Bytes injected into a prompt; the tail is dropped with a note if the file is larger. */
|
|
23
|
+
const INJECT_CAP_BYTES = 6 * 1024;
|
|
24
|
+
|
|
25
|
+
export function memoryPath(folder: string): string {
|
|
26
|
+
return path.join(folder, MEMORY_FILE);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function readMemory(folder: string): Promise<{ text: string; updatedAt?: number }> {
|
|
30
|
+
const file = memoryPath(folder);
|
|
31
|
+
const text = await readFile(file, 'utf8').catch(() => '');
|
|
32
|
+
const st = text ? await stat(file).catch(() => null) : null;
|
|
33
|
+
return { text, updatedAt: st?.mtimeMs };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Things that must not live in a page of notes, whatever the model meant:
|
|
38
|
+
* API keys, tokens, private keys, and `KEY=value` lines for secret-looking
|
|
39
|
+
* names. Replaced, not dropped, so the line still reads as a line.
|
|
40
|
+
*/
|
|
41
|
+
const SECRET_PATTERNS: RegExp[] = [
|
|
42
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
43
|
+
/\b(sk|rk|pk)-(?:live|test|proj|ant)?-?[A-Za-z0-9_-]{16,}\b/g,
|
|
44
|
+
/\b(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}\b/g,
|
|
45
|
+
/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g,
|
|
46
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
47
|
+
/\bAIza[0-9A-Za-z_-]{30,}\b/g,
|
|
48
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
|
|
49
|
+
/\b([A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API_KEY|APIKEY|PRIVATE_KEY)[A-Z0-9_]*)\s*[=:]\s*["']?[^\s"']{8,}["']?/gi,
|
|
50
|
+
/\b(?:bearer|token|password|secret|api[_-]?key)\s*[:=]\s*["']?[A-Za-z0-9_\-./+=]{16,}["']?/gi,
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** Strip what looks like a credential. Returns the text and how many replacements were made. */
|
|
54
|
+
export function scrubSecrets(text: string): { text: string; redacted: number } {
|
|
55
|
+
let redacted = 0;
|
|
56
|
+
let out = text;
|
|
57
|
+
for (const re of SECRET_PATTERNS) {
|
|
58
|
+
out = out.replace(re, (m, name?: string) => {
|
|
59
|
+
redacted += 1;
|
|
60
|
+
// Keep the variable name when there was one, so the line still explains itself.
|
|
61
|
+
return typeof name === 'string' && /^[A-Z0-9_]+$/.test(name) && m.startsWith(name) ? `${name}=[redacted]` : '[redacted]';
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return { text: out, redacted };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Write the whole memory. The director hands over the full page, not a
|
|
69
|
+
* delta, because "rewrite the stale line" is the behaviour we want and
|
|
70
|
+
* append-only files only grow. Trimmed to the cap; the caller is told.
|
|
71
|
+
*/
|
|
72
|
+
export async function writeMemory(folder: string, text: string): Promise<{ bytes: number; redacted: number; trimmed: boolean }> {
|
|
73
|
+
const scrubbed = scrubSecrets(text.replace(/\r\n/g, '\n').trim());
|
|
74
|
+
let body = scrubbed.text;
|
|
75
|
+
let trimmed = false;
|
|
76
|
+
if (Buffer.byteLength(body, 'utf8') > MEMORY_CAP_BYTES) {
|
|
77
|
+
body = Buffer.from(body, 'utf8').subarray(0, MEMORY_CAP_BYTES).toString('utf8').replace(/[^\n]*$/, '').trimEnd();
|
|
78
|
+
trimmed = true;
|
|
79
|
+
}
|
|
80
|
+
await mkdir(path.dirname(memoryPath(folder)), { recursive: true });
|
|
81
|
+
await writeFile(memoryPath(folder), body ? `${body}\n` : '');
|
|
82
|
+
return { bytes: Buffer.byteLength(body, 'utf8'), redacted: scrubbed.redacted, trimmed };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The memory as a prompt section, for the director, the workers and the
|
|
87
|
+
* planner. Empty memory says so in one line rather than vanishing, so the
|
|
88
|
+
* crew knows the file exists to be written.
|
|
89
|
+
*/
|
|
90
|
+
export function memorySection(text: string, role: 'director' | 'worker' | 'planner'): string {
|
|
91
|
+
const t = text.trim();
|
|
92
|
+
if (!t) {
|
|
93
|
+
return role === 'director'
|
|
94
|
+
? `\nPROJECT MEMORY (${MEMORY_FILE}): empty. Before you finish, write it with mcp__foreman__remember — a page at most of facts the next crew needs here.\n`
|
|
95
|
+
: '';
|
|
96
|
+
}
|
|
97
|
+
let body = t;
|
|
98
|
+
if (Buffer.byteLength(body, 'utf8') > INJECT_CAP_BYTES) {
|
|
99
|
+
body = Buffer.from(body, 'utf8').subarray(0, INJECT_CAP_BYTES).toString('utf8').replace(/[^\n]*$/, '') + '\n[… memory is longer than a page; the rest was not shown. Prune it.]';
|
|
100
|
+
}
|
|
101
|
+
const lead = role === 'director'
|
|
102
|
+
? `PROJECT MEMORY (${MEMORY_FILE}) — what earlier crews learned about this project. Trust it over guessing; correct it when it is wrong. Before you finish, rewrite it with mcp__foreman__remember so the next crew starts where you end.`
|
|
103
|
+
: role === 'worker'
|
|
104
|
+
? 'PROJECT MEMORY — what earlier crews learned about this project. Trust it over guessing; tell the director if it is wrong.'
|
|
105
|
+
: `PROJECT MEMORY (${MEMORY_FILE}) — what the crew learned about this project on earlier missions. Ground your advice in it, and say when a proposal would change something it records.`;
|
|
106
|
+
return `\n${lead}\n---\n${body}\n---\n`;
|
|
107
|
+
}
|
package/src/notify/commands.ts
CHANGED
|
@@ -66,7 +66,7 @@ export const HELP_TEXT = [
|
|
|
66
66
|
'',
|
|
67
67
|
'/projects — the fleet, with what is running',
|
|
68
68
|
'/status — the runs in flight and what they need',
|
|
69
|
-
'/new <name> — create a project under your projects root and link it',
|
|
69
|
+
'/new <name> — create a project under your projects root and link it; /new <git url> clones it there first',
|
|
70
70
|
'/plan <project> <what you want> — talk to that project\'s planner',
|
|
71
71
|
'/run <project> <brief> — skip the talk: start a mission at the project\'s default cap',
|
|
72
72
|
'/stop [project] — stop the planner reply in flight',
|
package/src/notify/telegram.ts
CHANGED
|
@@ -94,7 +94,7 @@ export function telegramTransport(token: string, chatId: string, apiBase = TELEG
|
|
|
94
94
|
export const BOT_COMMANDS: Array<{ command: string; description: string }> = [
|
|
95
95
|
{ command: 'projects', description: 'The fleet, with what is running' },
|
|
96
96
|
{ command: 'status', description: 'Runs in flight, spend, what needs you' },
|
|
97
|
-
{ command: 'new', description: 'Create a project: /new <name>' },
|
|
97
|
+
{ command: 'new', description: 'Create a project: /new <name>, or clone one: /new <git url>' },
|
|
98
98
|
{ command: 'plan', description: 'Talk to a planner: /plan <project> <what you want>' },
|
|
99
99
|
{ command: 'run', description: 'Skip the talk: /run <project> <brief>' },
|
|
100
100
|
{ command: 'stop', description: 'Stop the planner reply in flight' },
|
package/src/notify.ts
CHANGED
|
@@ -241,6 +241,11 @@ export function shape(env: Envelope, ctx: NotifyContext): Shaped | null {
|
|
|
241
241
|
case 'mission_incomplete':
|
|
242
242
|
return { key: `incomplete:${env.runId}`, gate: 'done',
|
|
243
243
|
text: `${head('Not done')}${runLine}\n${esc(clip(d.text))}${foot}` };
|
|
244
|
+
case 'pull_request': {
|
|
245
|
+
if (d.error || !d.url) return null;
|
|
246
|
+
return { key: `pr:${env.runId}`, gate: 'done',
|
|
247
|
+
text: `${head(d.method === 'gh' ? 'Pull request opened' : 'Branch pushed')}${runLine}\n<a href="${esc(String(d.url))}">${esc(String(d.url))}</a>` };
|
|
248
|
+
}
|
|
244
249
|
case 'service_exposed': {
|
|
245
250
|
// Informational, and worth a tap: the crew put something on the air.
|
|
246
251
|
const url = String(d.url ?? '');
|
package/src/orchestrator.test.ts
CHANGED
|
@@ -9,6 +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
13
|
} from './orchestrator.js';
|
|
13
14
|
import { makePolicy, type PendingPermission } from './policy.js';
|
|
14
15
|
import type { AgentEnv } from './provider.js';
|
|
@@ -1145,3 +1146,106 @@ test('pendingAsks exposes an open question with its text and options, and forget
|
|
|
1145
1146
|
await p;
|
|
1146
1147
|
assert.deepEqual(run.pendingAsks(), []);
|
|
1147
1148
|
});
|
|
1149
|
+
|
|
1150
|
+
// ---------------------------------------------------------------------------
|
|
1151
|
+
// The token cap: the bound that holds where dollars cannot
|
|
1152
|
+
// ---------------------------------------------------------------------------
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* A run whose usage is whatever the test says it is. Usage is read through
|
|
1156
|
+
* liveUsage(), so setting `meta.usage` is enough — the confirmed half of the
|
|
1157
|
+
* figure the cap actually consults.
|
|
1158
|
+
*/
|
|
1159
|
+
function cappedRun(over: Partial<RunMeta> = {}, usage?: Partial<{
|
|
1160
|
+
inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number;
|
|
1161
|
+
}>) {
|
|
1162
|
+
const run = new MissionRun(
|
|
1163
|
+
meta({ costBasis: 'free', ...over }), () => {}, () => {}, noopAgentEnv,
|
|
1164
|
+
) as unknown as {
|
|
1165
|
+
meta: RunMeta; turns: number;
|
|
1166
|
+
capReached(): string | null;
|
|
1167
|
+
budgetLine(): string;
|
|
1168
|
+
};
|
|
1169
|
+
if (usage) {
|
|
1170
|
+
run.meta.usage = {
|
|
1171
|
+
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, ...usage,
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
return run;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
test('capReached says nothing while the token total is under the cap', () => {
|
|
1178
|
+
const run = cappedRun({}, {
|
|
1179
|
+
inputTokens: 1_000_000, outputTokens: 100_000,
|
|
1180
|
+
cacheReadTokens: 1_000_000, cacheWriteTokens: 500_000,
|
|
1181
|
+
});
|
|
1182
|
+
assert.equal(run.capReached(), null);
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
test('capReached counts cache tokens too, and fires at the default 20M', () => {
|
|
1186
|
+
// Under on input and output alone; over once cache is counted — which is
|
|
1187
|
+
// exactly the run the cap exists for, since cache reads are most of the
|
|
1188
|
+
// traffic on a long director loop.
|
|
1189
|
+
const run = cappedRun({}, {
|
|
1190
|
+
inputTokens: 2_000_000, outputTokens: 400_000,
|
|
1191
|
+
cacheReadTokens: 16_000_000, cacheWriteTokens: 1_600_000,
|
|
1192
|
+
});
|
|
1193
|
+
const cap = run.capReached();
|
|
1194
|
+
assert.ok(cap?.startsWith('TOKEN CAP REACHED'), `got ${cap}`);
|
|
1195
|
+
assert.match(cap!, /20\.0M tokens/);
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
test('capReached: the ledger\'s largest finished missions stay under the default', () => {
|
|
1199
|
+
// 15.9M tokens, done, $8.55 — a real Fable run from this machine. A cap
|
|
1200
|
+
// that would have ended it is a cap set wrong.
|
|
1201
|
+
const run = cappedRun({}, { inputTokens: 300_000, outputTokens: 200_000, cacheReadTokens: 15_000_000, cacheWriteTokens: 400_000 });
|
|
1202
|
+
assert.equal(run.capReached(), null);
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
test('capReached honours an explicit maxTokens over the default', () => {
|
|
1206
|
+
const run = cappedRun({ maxTokens: 1_000_000 }, { inputTokens: 1_200_000 });
|
|
1207
|
+
assert.ok(run.capReached()?.startsWith('TOKEN CAP REACHED'));
|
|
1208
|
+
const roomy = cappedRun({ maxTokens: 40_000_000 }, { inputTokens: 25_000_000 });
|
|
1209
|
+
assert.equal(roomy.capReached(), null);
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
test('the turn cap still wins when turns and tokens are both past their caps', () => {
|
|
1213
|
+
const run = cappedRun({ maxTurns: 10, maxTokens: 1_000 }, { inputTokens: 9_000_000 });
|
|
1214
|
+
run.turns = 50;
|
|
1215
|
+
assert.match(run.capReached()!, /^TURN CAP REACHED/);
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
test('the token cap binds free and unpriced runs, and never a priced one', () => {
|
|
1219
|
+
for (const basis of ['free', 'unpriced'] as const) {
|
|
1220
|
+
const run = cappedRun({ costBasis: basis }, { inputTokens: 25_000_000 });
|
|
1221
|
+
assert.ok(run.capReached()?.startsWith('TOKEN CAP REACHED'), `basis ${basis}`);
|
|
1222
|
+
}
|
|
1223
|
+
// Priced: dollars are the cap. Under budget, tokens alone end nothing;
|
|
1224
|
+
// over budget, it is the budget that speaks.
|
|
1225
|
+
const priced = cappedRun({ costBasis: 'priced', budgetUsd: 5 }, { inputTokens: 25_000_000 });
|
|
1226
|
+
priced.meta.costUsd = 1;
|
|
1227
|
+
assert.equal(priced.capReached(), null);
|
|
1228
|
+
priced.meta.costUsd = 5;
|
|
1229
|
+
assert.match(priced.capReached()!, /^BUDGET CAP REACHED/);
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
test('budgetLine tells an unmetered director its turn AND token bounds', () => {
|
|
1233
|
+
const free = cappedRun({ costBasis: 'free' }).budgetLine();
|
|
1234
|
+
assert.match(free, /150 director turns and 20M tokens/);
|
|
1235
|
+
const unpriced = cappedRun({ costBasis: 'unpriced' }).budgetLine();
|
|
1236
|
+
assert.match(unpriced, /150 director turns and 20M tokens/);
|
|
1237
|
+
// A custom cap is quoted as set, not as the default.
|
|
1238
|
+
assert.match(cappedRun({ maxTokens: 2_000_000 }).budgetLine(), /2M tokens/);
|
|
1239
|
+
// A priced run still speaks in dollars, and says nothing about tokens.
|
|
1240
|
+
const priced = cappedRun({ costBasis: 'priced' }).budgetLine();
|
|
1241
|
+
assert.match(priced, /^Budget: \$5\.00/);
|
|
1242
|
+
assert.doesNotMatch(priced, /tokens/);
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
test('tokenCapLabel rounds to a figure a director can hold in mind', () => {
|
|
1246
|
+
assert.equal(tokenCapLabel(20_000_000), '20M tokens');
|
|
1247
|
+
assert.equal(tokenCapLabel(5_000_000), '5M tokens');
|
|
1248
|
+
assert.equal(tokenCapLabel(1_500_000), '1.5M tokens');
|
|
1249
|
+
assert.equal(tokenCapLabel(250_000), '250k tokens');
|
|
1250
|
+
assert.equal(tokenCapLabel(400), '400 tokens');
|
|
1251
|
+
});
|