@amenophis1er/foreman 0.1.7 → 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 +9 -3
- package/package.json +1 -1
- package/src/browser.ts +188 -0
- package/src/cli.ts +6 -1
- package/src/clone.test.ts +35 -0
- package/src/clone.ts +129 -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 +111 -0
- package/src/orchestrator.ts +121 -47
- package/src/planner.ts +7 -2
- package/src/policy.test.ts +25 -0
- package/src/policy.ts +53 -3
- 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 +567 -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 +23 -0
- package/ui/dist/assets/index--dvfX3Mo.js +68 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-BPFc7O5V.js +0 -67
package/src/gitwork.ts
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git around a mission: which branch a folder is on, a branch of its own for
|
|
3
|
+
* each mission, and the commit that closes one.
|
|
4
|
+
*
|
|
5
|
+
* A mission is a unit of work; in a repository, a branch makes it one in
|
|
6
|
+
* git too — isolation, a diff that is exactly the mission, an easy revert,
|
|
7
|
+
* a pull request if there is a remote. Foreman creates the branch and
|
|
8
|
+
* commits on it. It never merges, and it pushes only when the human presses
|
|
9
|
+
* the button that says so — once, for that branch, to open the pull request.
|
|
10
|
+
*/
|
|
11
|
+
import { execFile } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
export interface GitInfo {
|
|
14
|
+
repo: boolean;
|
|
15
|
+
/** Current branch; a short sha when detached. */
|
|
16
|
+
branch?: string;
|
|
17
|
+
/** Uncommitted changes, tracked or untracked. */
|
|
18
|
+
dirty?: boolean;
|
|
19
|
+
head?: string | null;
|
|
20
|
+
/** `origin`'s URL when there is one, so the UI can say "has a remote". */
|
|
21
|
+
remote?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Recorded on a run that got a branch of its own. */
|
|
25
|
+
export interface MissionGit {
|
|
26
|
+
branch: string;
|
|
27
|
+
/** Where it was made from: the branch (or short sha) that was checked out. */
|
|
28
|
+
base: string;
|
|
29
|
+
baseHead: string | null;
|
|
30
|
+
/** Commits on the branch since `baseHead`, updated when the mission closes. */
|
|
31
|
+
commits?: number;
|
|
32
|
+
/** The closing commit, when the mission's work was committed. */
|
|
33
|
+
commit?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function git(args: string[], cwd: string, timeoutMs = 15_000): Promise<string> {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
execFile('git', args, {
|
|
39
|
+
cwd, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, encoding: 'utf8',
|
|
40
|
+
env: { ...process.env, GIT_PAGER: 'cat', GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0' },
|
|
41
|
+
}, (err, stdout, stderr) => {
|
|
42
|
+
if (err) reject(new Error(`git ${args[0]}: ${(stderr || err.message).trim()}`));
|
|
43
|
+
else resolve(String(stdout));
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What git says about a folder; `{ repo: false }` for anything that is not a work tree. Never throws. */
|
|
49
|
+
export async function gitInfo(folder: string): Promise<GitInfo> {
|
|
50
|
+
try {
|
|
51
|
+
const inside = (await git(['rev-parse', '--is-inside-work-tree'], folder)).trim();
|
|
52
|
+
if (inside !== 'true') return { repo: false };
|
|
53
|
+
} catch {
|
|
54
|
+
return { repo: false };
|
|
55
|
+
}
|
|
56
|
+
const info: GitInfo = { repo: true };
|
|
57
|
+
try {
|
|
58
|
+
const ref = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], folder)).trim();
|
|
59
|
+
info.branch = ref === 'HEAD' ? (await git(['rev-parse', '--short', 'HEAD'], folder)).trim() : ref;
|
|
60
|
+
} catch {
|
|
61
|
+
// An empty repository: HEAD names a branch with no commits yet.
|
|
62
|
+
try { info.branch = (await git(['symbolic-ref', '--short', 'HEAD'], folder)).trim(); } catch { /* leave unset */ }
|
|
63
|
+
}
|
|
64
|
+
try { info.head = (await git(['rev-parse', '--verify', 'HEAD'], folder)).trim() || null; } catch { info.head = null; }
|
|
65
|
+
try { info.dirty = (await git(['status', '--porcelain', '--untracked-files=normal'], folder)).trim().length > 0; } catch { /* unknown */ }
|
|
66
|
+
try { info.remote = (await git(['remote', 'get-url', 'origin'], folder)).trim() || undefined; } catch { /* no remote */ }
|
|
67
|
+
return info;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `foreman/<first words of the brief>-<id tail>`: readable in `git branch`, unique per run. */
|
|
71
|
+
export function missionBranchName(mission: string, runId: string): string {
|
|
72
|
+
const first = mission.split('\n').find((l) => l.trim())?.trim() ?? 'mission';
|
|
73
|
+
const slug = first.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').split('-').filter(Boolean).slice(0, 6).join('-').slice(0, 40).replace(/-+$/, '') || 'mission';
|
|
74
|
+
const tail = runId.replace(/[^a-z0-9]/gi, '').slice(-4).toLowerCase();
|
|
75
|
+
return `foreman/${slug}-${tail}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A branch of the mission's own, made from what is checked out now.
|
|
80
|
+
* Uncommitted changes come along, as `checkout -b` always does. Resolves to
|
|
81
|
+
* the record for the run, or to one sentence on why it could not.
|
|
82
|
+
*/
|
|
83
|
+
export async function startMissionBranch(folder: string, mission: string, runId: string): Promise<MissionGit | { error: string }> {
|
|
84
|
+
const info = await gitInfo(folder);
|
|
85
|
+
if (!info.repo) return { error: 'not a git repository' };
|
|
86
|
+
const branch = missionBranchName(mission, runId);
|
|
87
|
+
try {
|
|
88
|
+
await git(['checkout', '-b', branch], folder);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
91
|
+
}
|
|
92
|
+
return { branch, base: info.branch ?? 'HEAD', baseHead: info.head ?? null };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Back on the mission's branch for a resume. Returns null when already or
|
|
97
|
+
* now there, else why not — a dirty tree that would be clobbered, typically.
|
|
98
|
+
*/
|
|
99
|
+
export async function ensureMissionBranch(folder: string, branch: string): Promise<string | null> {
|
|
100
|
+
const info = await gitInfo(folder);
|
|
101
|
+
if (!info.repo) return 'the folder is no longer a git repository';
|
|
102
|
+
if (info.branch === branch) return null;
|
|
103
|
+
try {
|
|
104
|
+
await git(['checkout', branch], folder);
|
|
105
|
+
return null;
|
|
106
|
+
} catch (err) {
|
|
107
|
+
return err instanceof Error ? err.message : String(err);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Commits whatever the mission left uncommitted, on its branch, as the user
|
|
113
|
+
* (their git config), falling back to a Foreman identity only when the
|
|
114
|
+
* repository has none. Then counts the branch's commits since its base.
|
|
115
|
+
*/
|
|
116
|
+
export async function closeMissionBranch(folder: string, g: MissionGit, message: string): Promise<MissionGit & { committed: boolean; error?: string }> {
|
|
117
|
+
const out: MissionGit & { committed: boolean; error?: string } = { ...g, committed: false };
|
|
118
|
+
try {
|
|
119
|
+
const info = await gitInfo(folder);
|
|
120
|
+
if (!info.repo) return { ...out, error: 'not a git repository' };
|
|
121
|
+
if (info.branch !== g.branch) return { ...out, error: `the folder is on ${info.branch ?? 'another branch'}, not ${g.branch}; nothing committed` };
|
|
122
|
+
if (info.dirty) {
|
|
123
|
+
await git(['add', '-A', '--', '.'], folder);
|
|
124
|
+
const identity = ['-c', 'user.name=Foreman', '-c', 'user.email=foreman@localhost'];
|
|
125
|
+
const hasName = await git(['config', 'user.name'], folder).then((v) => v.trim().length > 0).catch(() => false);
|
|
126
|
+
await git([...(hasName ? [] : identity), 'commit', '-q', '-m', message], folder);
|
|
127
|
+
out.commit = (await git(['rev-parse', '--short', 'HEAD'], folder)).trim();
|
|
128
|
+
out.committed = true;
|
|
129
|
+
}
|
|
130
|
+
if (g.baseHead) {
|
|
131
|
+
out.commits = Number((await git(['rev-list', '--count', `${g.baseHead}..HEAD`], folder)).trim()) || 0;
|
|
132
|
+
} else {
|
|
133
|
+
out.commits = Number((await git(['rev-list', '--count', 'HEAD'], folder)).trim()) || 0;
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
out.error = err instanceof Error ? err.message : String(err);
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// The pull request: the one outward-facing act, and only ever on a button
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
/** `git@github.com:o/r.git` or `https://host/o/r(.git)` → the repository's web page. Null for anything else. */
|
|
146
|
+
export function remoteWebUrl(remote: string | undefined): { host: string; web: string; path: string } | null {
|
|
147
|
+
if (!remote) return null;
|
|
148
|
+
let m = /^git@([^:]+):(.+?)(?:\.git)?\/?$/.exec(remote.trim());
|
|
149
|
+
if (m) return { host: m[1].toLowerCase(), path: m[2], web: `https://${m[1]}/${m[2]}` };
|
|
150
|
+
m = /^ssh:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/.exec(remote.trim());
|
|
151
|
+
if (m) return { host: m[1].toLowerCase(), path: m[2], web: `https://${m[1]}/${m[2]}` };
|
|
152
|
+
m = /^https?:\/\/(?:[^@/]+@)?([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(remote.trim());
|
|
153
|
+
if (m) return { host: m[1].toLowerCase(), path: m[2], web: `https://${m[1]}/${m[2]}` };
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Where a human finishes the pull request in a browser when `gh` is not around: GitHub, GitLab and Bitbucket forms; null elsewhere. */
|
|
158
|
+
export function compareUrl(remote: string | undefined, base: string, branch: string): string | null {
|
|
159
|
+
const r = remoteWebUrl(remote);
|
|
160
|
+
if (!r) return null;
|
|
161
|
+
const enc = encodeURIComponent;
|
|
162
|
+
if (r.host === 'github.com' || r.host.endsWith('.github.com')) return `${r.web}/compare/${enc(base)}...${enc(branch)}?expand=1`;
|
|
163
|
+
if (r.host.includes('gitlab')) return `${r.web}/-/merge_requests/new?merge_request%5Bsource_branch%5D=${enc(branch)}&merge_request%5Btarget_branch%5D=${enc(base)}`;
|
|
164
|
+
if (r.host.includes('bitbucket')) return `${r.web}/pull-requests/new?source=${enc(branch)}&dest=${enc(base)}`;
|
|
165
|
+
return `${r.web}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The pull request as Foreman drafts it: the run's title, and a body a reviewer can read without opening Foreman. */
|
|
169
|
+
export function prDraft(run: { title?: string; mission: string; costUsd: number; costBasis?: string; git?: MissionGit }, missionDoc: string | null): { title: string; body: string } {
|
|
170
|
+
const first = run.mission.split('\n').find((l) => l.trim())?.trim() ?? 'Mission';
|
|
171
|
+
const title = (run.title || first).slice(0, 120);
|
|
172
|
+
const boxes = (missionDoc ?? '').split('\n').filter((l) => /^\s*[-*] \[[ xX]\]/.test(l)).map((l) => l.trim());
|
|
173
|
+
const spend = run.costBasis === 'priced' || run.costBasis === undefined ? `$${run.costUsd.toFixed(2)}` : run.costBasis;
|
|
174
|
+
const parts = [
|
|
175
|
+
'## Mission', '', run.mission.trim(), '',
|
|
176
|
+
];
|
|
177
|
+
if (boxes.length) parts.push('## Done when', '', ...boxes, '');
|
|
178
|
+
parts.push('---', `Run by [Foreman](https://github.com/amenophis1er/foreman) on branch \`${run.git?.branch ?? ''}\` from \`${run.git?.base ?? ''}\` · spend ${spend}.`);
|
|
179
|
+
return { title, body: parts.join('\n') };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** `git push -u origin <branch>`, as the user. Null on success, git's reason otherwise. */
|
|
183
|
+
export async function pushBranch(folder: string, branch: string): Promise<string | null> {
|
|
184
|
+
try {
|
|
185
|
+
await git(['push', '-u', 'origin', branch], folder, 5 * 60_000);
|
|
186
|
+
return null;
|
|
187
|
+
} catch (err) {
|
|
188
|
+
const t = err instanceof Error ? err.message : String(err);
|
|
189
|
+
if (/could not read Username|Authentication failed|Permission denied|terminal prompts disabled/i.test(t)) {
|
|
190
|
+
return 'Git could not authenticate to the remote. Set up a credential helper or gh auth, or use an SSH remote with a key this machine has.';
|
|
191
|
+
}
|
|
192
|
+
return t.replace(/^git push:\s*/, 'git push failed: ');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Is GitHub's CLI here and signed in? Best effort, a few seconds at most. */
|
|
197
|
+
export async function ghReady(): Promise<{ present: boolean; authed: boolean }> {
|
|
198
|
+
return new Promise((resolve) => {
|
|
199
|
+
execFile('gh', ['auth', 'status'], { timeout: 8_000 }, (err) => {
|
|
200
|
+
if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') return resolve({ present: false, authed: false });
|
|
201
|
+
resolve({ present: true, authed: !err });
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** What became of a pull request, from gh: open, merged or closed. Null when gh cannot say. */
|
|
207
|
+
export function pullRequestState(folder: string, url: string): Promise<{ state: 'open' | 'merged' | 'closed'; mergedAt?: string; number?: number } | null> {
|
|
208
|
+
return new Promise((resolve) => {
|
|
209
|
+
execFile('gh', ['pr', 'view', url, '--json', 'state,mergedAt,number'], {
|
|
210
|
+
cwd: folder, timeout: 15_000, env: { ...process.env, GH_PROMPT_DISABLED: '1' },
|
|
211
|
+
}, (err, stdout) => {
|
|
212
|
+
if (err) return resolve(null);
|
|
213
|
+
try {
|
|
214
|
+
const d = JSON.parse(String(stdout)) as { state?: string; mergedAt?: string | null; number?: number };
|
|
215
|
+
const state = d.state === 'MERGED' ? 'merged' : d.state === 'CLOSED' ? 'closed' : d.state === 'OPEN' ? 'open' : null;
|
|
216
|
+
resolve(state ? { state, mergedAt: d.mergedAt ?? undefined, number: d.number } : null);
|
|
217
|
+
} catch { resolve(null); }
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** `gh pr create`, as the user. Resolves to the PR's URL, or to why not. */
|
|
223
|
+
export function createPullRequest(folder: string, opts: { base: string; branch: string; title: string; body: string }): Promise<{ url?: string; error?: string }> {
|
|
224
|
+
return new Promise((resolve) => {
|
|
225
|
+
execFile('gh', ['pr', 'create', '--base', opts.base, '--head', opts.branch, '--title', opts.title, '--body', opts.body], {
|
|
226
|
+
cwd: folder, timeout: 60_000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, GH_PROMPT_DISABLED: '1', GIT_TERMINAL_PROMPT: '0' },
|
|
227
|
+
}, (err, stdout, stderr) => {
|
|
228
|
+
if (err) {
|
|
229
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return resolve({ error: 'gh is not installed' });
|
|
230
|
+
const t = String(stderr || err.message).trim();
|
|
231
|
+
const existing = /already exists:\s*(https?:\S+)/.exec(t);
|
|
232
|
+
if (existing) return resolve({ url: existing[1] });
|
|
233
|
+
return resolve({ error: `gh pr create: ${t.split('\n').filter(Boolean).pop() ?? t}` });
|
|
234
|
+
}
|
|
235
|
+
const url = String(stdout).split('\n').map((l) => l.trim()).find((l) => /^https?:\/\//.test(l));
|
|
236
|
+
resolve(url ? { url } : { error: 'gh did not return a pull request URL' });
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import type http from 'node:http';
|
|
4
|
+
import { requestAllowed, pathPermitted } from './guard.js';
|
|
5
|
+
|
|
6
|
+
const PORT = 4177;
|
|
7
|
+
const TAILNET = { ip: '100.94.221.98', dnsName: 'laptop.tail1234.ts.net' };
|
|
8
|
+
const OPTS = { port: PORT, tailnet: TAILNET };
|
|
9
|
+
|
|
10
|
+
/** A request as the guard sees one: a method and a bag of headers. */
|
|
11
|
+
function req(method: string, headers: Record<string, string>) {
|
|
12
|
+
return { method, headers: headers as http.IncomingHttpHeaders };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test('requestAllowed: the hosts Foreman answers to', () => {
|
|
16
|
+
assert.deepEqual(requestAllowed(req('GET', { host: `localhost:${PORT}` }), OPTS), { ok: true });
|
|
17
|
+
assert.deepEqual(requestAllowed(req('GET', { host: `127.0.0.1:${PORT}` }), OPTS), { ok: true });
|
|
18
|
+
assert.deepEqual(requestAllowed(req('GET', { host: `[::1]:${PORT}` }), OPTS), { ok: true });
|
|
19
|
+
assert.deepEqual(requestAllowed(req('GET', { host: `100.94.221.98:${PORT}` }), OPTS), { ok: true });
|
|
20
|
+
// The MagicDNS name with Foreman's port, and with whatever port
|
|
21
|
+
// `tailscale serve` put in front of it — including none at all (443).
|
|
22
|
+
assert.deepEqual(requestAllowed(req('GET', { host: `laptop.tail1234.ts.net:${PORT}` }), OPTS), { ok: true });
|
|
23
|
+
assert.deepEqual(requestAllowed(req('GET', { host: 'laptop.tail1234.ts.net' }), OPTS), { ok: true });
|
|
24
|
+
assert.deepEqual(requestAllowed(req('GET', { host: 'laptop.tail1234.ts.net:8443' }), OPTS), { ok: true });
|
|
25
|
+
// Case never matters in a host name.
|
|
26
|
+
assert.deepEqual(requestAllowed(req('GET', { host: 'LapTop.Tail1234.TS.net' }), OPTS), { ok: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('requestAllowed: an unrecognised or missing Host is 421 (DNS rebinding)', () => {
|
|
30
|
+
assert.deepEqual(requestAllowed(req('GET', { host: `evil.example:${PORT}` }), OPTS), {
|
|
31
|
+
ok: false, status: 421, error: 'unrecognised Host',
|
|
32
|
+
});
|
|
33
|
+
assert.deepEqual(requestAllowed(req('GET', {}), OPTS), {
|
|
34
|
+
ok: false, status: 421, error: 'unrecognised Host',
|
|
35
|
+
});
|
|
36
|
+
// A neighbouring name is not the tailnet name.
|
|
37
|
+
assert.equal(requestAllowed(req('GET', { host: 'notlaptop.tail1234.ts.net' }), OPTS).ok, false);
|
|
38
|
+
// No tailnet: its address is not special.
|
|
39
|
+
assert.equal(requestAllowed(req('GET', { host: `100.94.221.98:${PORT}` }), { port: PORT, tailnet: null }).ok, false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('requestAllowed: safe methods pass on Host alone, whatever the Origin', () => {
|
|
43
|
+
const ok = req('GET', { host: `localhost:${PORT}`, origin: 'http://evil.example' });
|
|
44
|
+
assert.deepEqual(requestAllowed(ok, OPTS), { ok: true });
|
|
45
|
+
assert.deepEqual(requestAllowed(req('HEAD', { host: `localhost:${PORT}`, origin: 'http://evil.example' }), OPTS), { ok: true });
|
|
46
|
+
assert.deepEqual(requestAllowed(req('OPTIONS', { host: `localhost:${PORT}`, origin: 'http://evil.example' }), OPTS), { ok: true });
|
|
47
|
+
// Including the event stream, which is how the dashboard stays live.
|
|
48
|
+
assert.deepEqual(requestAllowed(req('GET', { host: `127.0.0.1:${PORT}`, 'sec-fetch-site': 'cross-site' }), OPTS), { ok: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('requestAllowed: an unsafe method with a foreign Origin is refused', () => {
|
|
52
|
+
assert.deepEqual(requestAllowed(req('POST', { host: `localhost:${PORT}`, origin: 'http://evil.example' }), OPTS), {
|
|
53
|
+
ok: false, status: 403, error: 'cross-site request refused',
|
|
54
|
+
});
|
|
55
|
+
// Right name, wrong port: a different origin to the browser and to us.
|
|
56
|
+
assert.equal(requestAllowed(req('POST', { host: `localhost:${PORT}`, origin: 'http://localhost:9999' }), OPTS).ok, false);
|
|
57
|
+
// Not a web origin at all.
|
|
58
|
+
assert.equal(requestAllowed(req('POST', { host: `localhost:${PORT}`, origin: 'null' }), OPTS).ok, false);
|
|
59
|
+
assert.equal(requestAllowed(req('POST', { host: `localhost:${PORT}`, origin: 'file://' }), OPTS).ok, false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('requestAllowed: an unsafe method from Foreman’s own page is allowed', () => {
|
|
63
|
+
assert.deepEqual(requestAllowed(req('POST', { host: `localhost:${PORT}`, origin: `http://localhost:${PORT}` }), OPTS), { ok: true });
|
|
64
|
+
assert.deepEqual(requestAllowed(req('DELETE', { host: `127.0.0.1:${PORT}`, origin: `http://127.0.0.1:${PORT}` }), OPTS), { ok: true });
|
|
65
|
+
// The phone, through `tailscale serve` on 443: HTTPS, no port in the Origin.
|
|
66
|
+
assert.deepEqual(requestAllowed(req('POST', { host: 'laptop.tail1234.ts.net', origin: 'https://laptop.tail1234.ts.net' }), OPTS), { ok: true });
|
|
67
|
+
assert.deepEqual(requestAllowed(req('POST', { host: `100.94.221.98:${PORT}`, origin: `http://100.94.221.98:${PORT}` }), OPTS), { ok: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('requestAllowed: with no Origin, Sec-Fetch-Site decides', () => {
|
|
71
|
+
// curl, src/cli.ts, the Telegram paths and the tests: neither header.
|
|
72
|
+
assert.deepEqual(requestAllowed(req('POST', { host: `127.0.0.1:${PORT}` }), OPTS), { ok: true });
|
|
73
|
+
assert.deepEqual(requestAllowed(req('POST', { host: `localhost:${PORT}`, 'sec-fetch-site': 'same-origin' }), OPTS), { ok: true });
|
|
74
|
+
assert.deepEqual(requestAllowed(req('POST', { host: `localhost:${PORT}`, 'sec-fetch-site': 'none' }), OPTS), { ok: true });
|
|
75
|
+
assert.deepEqual(requestAllowed(req('POST', { host: `localhost:${PORT}`, 'sec-fetch-site': 'cross-site' }), OPTS), {
|
|
76
|
+
ok: false, status: 403, error: 'cross-site request refused',
|
|
77
|
+
});
|
|
78
|
+
assert.equal(requestAllowed(req('POST', { host: `localhost:${PORT}`, 'sec-fetch-site': 'same-site' }), OPTS).ok, false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('requestAllowed: FOREMAN_BIND=all takes any Host, and Origin must match it', () => {
|
|
82
|
+
const all = { port: PORT, tailnet: null, bindAll: true };
|
|
83
|
+
assert.deepEqual(requestAllowed(req('GET', { host: '192.168.1.20:4177' }), all), { ok: true });
|
|
84
|
+
assert.deepEqual(requestAllowed(req('GET', { host: 'foreman.lan' }), all), { ok: true });
|
|
85
|
+
assert.deepEqual(requestAllowed(req('POST', { host: 'foreman.lan', origin: 'http://foreman.lan' }), all), { ok: true });
|
|
86
|
+
assert.equal(requestAllowed(req('POST', { host: 'foreman.lan', origin: 'http://evil.example' }), all).ok, false);
|
|
87
|
+
// Even wide open, a request with no Host at all is not one we can place.
|
|
88
|
+
assert.equal(requestAllowed(req('GET', {}), all).ok, false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('pathPermitted: at a root, under one, and never through ..', () => {
|
|
92
|
+
const roots = ['/Users/me/Projects', '/Users/me/work'];
|
|
93
|
+
assert.equal(pathPermitted('/Users/me/Projects', roots), true);
|
|
94
|
+
assert.equal(pathPermitted('/Users/me/Projects/foreman/src', roots), true);
|
|
95
|
+
assert.equal(pathPermitted('/Users/me/work', roots), true);
|
|
96
|
+
assert.equal(pathPermitted('/etc', roots), false);
|
|
97
|
+
assert.equal(pathPermitted('/Users/me', roots), false);
|
|
98
|
+
// A neighbour that merely shares a prefix is outside.
|
|
99
|
+
assert.equal(pathPermitted('/Users/me/Projects-backup', roots), false);
|
|
100
|
+
// Traversal is resolved before it is judged.
|
|
101
|
+
assert.equal(pathPermitted('/Users/me/Projects/../../../etc/passwd', roots), false);
|
|
102
|
+
assert.equal(pathPermitted('/Users/me/Projects/foreman/../other', roots), true);
|
|
103
|
+
assert.equal(pathPermitted('/Users/me/Projects/foreman', []), false);
|
|
104
|
+
});
|
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
|
+
});
|