atris 3.73.1 → 3.74.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/commands/member.js +141 -7
- package/commands/mission.js +156 -65
- package/commands/slop.js +8 -2
- package/lib/falsifier-probe.js +2 -1
- package/lib/gm-bet.js +84 -25
- package/lib/gm-phone-page.js +386 -98
- package/lib/gm-phone.js +27 -3
- package/lib/handoff-wake.js +180 -0
- package/lib/member-up.js +18 -3
- package/lib/mission-protected-lane.js +38 -6
- package/lib/spawn-env.js +92 -3
- package/lib/task-proof.js +2 -1
- package/lib/task-receipt.js +3 -2
- package/lib/yes-lock.js +633 -81
- package/package.json +1 -1
package/lib/gm-phone.js
CHANGED
|
@@ -16,6 +16,10 @@ const crypto = require('crypto');
|
|
|
16
16
|
|
|
17
17
|
const CARD_LIMIT = 5;
|
|
18
18
|
const CONFIRM_MS = 5000;
|
|
19
|
+
// a typed send's id, and how many and how long sends are remembered
|
|
20
|
+
const SEND_ID = /^[a-z0-9]{8,32}$/;
|
|
21
|
+
const SENDS_KEPT = 50;
|
|
22
|
+
const SENDS_MS = 10 * 60 * 1000;
|
|
19
23
|
const UNDO_MS = 5000;
|
|
20
24
|
// the server keeps an undo a little past the toast so a slow tap still counts
|
|
21
25
|
const UNDO_GRACE_MS = 3000;
|
|
@@ -505,11 +509,11 @@ function createPhoneHandler(deps) {
|
|
|
505
509
|
async function betAction(p, body) {
|
|
506
510
|
let out;
|
|
507
511
|
if (p === '/bet/think') out = bet.next({ sharper: body.sharper === true });
|
|
508
|
-
else if (p === '/bet/answer') out = bet.answer({ choice: String(body.choice || ''), text: String(body.text || '') });
|
|
512
|
+
else if (p === '/bet/answer') out = bet.answer({ choice: String(body.choice || ''), text: String(body.text || ''), card: String(body.card || '') });
|
|
509
513
|
else if (p === '/bet/report') out = bet.report({ value: body.value });
|
|
510
514
|
else if (p === '/bet/pick') out = bet.pick({ id: String(body.id || '') });
|
|
511
515
|
else if (p === '/bet/steer') out = await bet.talk({ member: String(body.member || ''), text: String(body.text || ''), confirm: body.confirm === true, note: body.note === true });
|
|
512
|
-
else if (p === '/bet/say') out = await bet.say({ text: String(body.text || ''), confirm: body.confirm === true, note: body.note === true });
|
|
516
|
+
else if (p === '/bet/say') out = await bet.say({ text: String(body.text || ''), confirm: body.confirm === true, note: body.note === true, card: String(body.card || '') });
|
|
513
517
|
else if (p === '/bet/skip') out = bet.skip();
|
|
514
518
|
else if (p === '/bet/do') {
|
|
515
519
|
if (body.confirm !== true) return { status: 409, body: { ok: false, reason: 'needs_confirm', confirm_ms: CONFIRM_MS } };
|
|
@@ -524,6 +528,26 @@ function createPhoneHandler(deps) {
|
|
|
524
528
|
return { status: out.ok ? 200 : 409, body: out };
|
|
525
529
|
}
|
|
526
530
|
|
|
531
|
+
// A typed send carries its own id, made on the phone when the words are
|
|
532
|
+
// first sent and the same on every retry of those words. A repeat of a
|
|
533
|
+
// send that went through, or is still going, gets the first answer and
|
|
534
|
+
// nothing is sent again; one that did nothing is tried again. The newest
|
|
535
|
+
// SENDS_KEPT are remembered for SENDS_MS.
|
|
536
|
+
const sends = new Map();
|
|
537
|
+
function sendOnce(p, body) {
|
|
538
|
+
const id = typeof body.send === 'string' && SEND_ID.test(body.send) ? `${p} ${body.send}` : null;
|
|
539
|
+
if (!id) return betAction(p, body);
|
|
540
|
+
const seen = sends.get(id);
|
|
541
|
+
if (seen && now() - seen.at < SENDS_MS) return seen.out;
|
|
542
|
+
sends.delete(id);
|
|
543
|
+
const out = betAction(p, body);
|
|
544
|
+
sends.set(id, { at: now(), out });
|
|
545
|
+
const forget = () => { if (sends.get(id) && sends.get(id).out === out) sends.delete(id); };
|
|
546
|
+
out.then(r => { if (!r.body || r.body.ok !== true) forget(); }, forget);
|
|
547
|
+
while (sends.size > SENDS_KEPT) sends.delete(sends.keys().next().value);
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
|
|
527
551
|
const send = (res, status, body, type = 'application/json; charset=utf-8', extra = {}) => {
|
|
528
552
|
res.writeHead(status, {
|
|
529
553
|
'content-type': type,
|
|
@@ -561,7 +585,7 @@ function createPhoneHandler(deps) {
|
|
|
561
585
|
return send(res, err.message === 'too_large' ? 413 : 400, { ok: false, reason: 'bad_body' });
|
|
562
586
|
}
|
|
563
587
|
if (check.path.startsWith('/bet/')) {
|
|
564
|
-
const out = await
|
|
588
|
+
const out = await sendOnce(check.path, body || {});
|
|
565
589
|
return send(res, out.status, out.body);
|
|
566
590
|
}
|
|
567
591
|
const out = check.path === '/action' ? act(body || {}) : doUndo(body || {});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// A handoff wakes the member it hands to.
|
|
4
|
+
//
|
|
5
|
+
// `atris member handoff <from> <to> "<step>"` used to only write the baton
|
|
6
|
+
// down, so the phone said "validator has it" while no validator run existed
|
|
7
|
+
// (live 2026-10-22, mission #266). Now the receiver is started on the
|
|
8
|
+
// handed-off step through the member runtime (member run, detached), the same
|
|
9
|
+
// bounded work step `atris member wake <name>` runs, and only when the team
|
|
10
|
+
// rules allow it: not asleep, not a person, under the limit of members
|
|
11
|
+
// working at once (the `atris member up` limit), with an engine named in the
|
|
12
|
+
// roster. When it cannot start, the outcome says why in plain words and is
|
|
13
|
+
// written onto the handoff, so nothing claims the member has the work.
|
|
14
|
+
//
|
|
15
|
+
// The team, the engine lookup and the start are passed in, so tests drive the
|
|
16
|
+
// decision without starting anything.
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const { DEFAULT_MAX } = require('./member-up');
|
|
21
|
+
|
|
22
|
+
// Two handoffs at the same moment once both counted 5 working and both
|
|
23
|
+
// started one, past the limit of 6. Count-and-start now holds one lock file
|
|
24
|
+
// in the state folder, and each start is written to a short list of recent
|
|
25
|
+
// wakes, so the next handoff counts it even before its mission says running.
|
|
26
|
+
const LOCK_FILE = 'handoff-wake.lock';
|
|
27
|
+
const RECENT_FILE = 'handoff-wakes.json';
|
|
28
|
+
// A holder that died, or one stuck past this long, no longer holds.
|
|
29
|
+
const STALE_LOCK_MS = 5 * 60 * 1000;
|
|
30
|
+
const WAIT_LOCK_MS = 3 * 60 * 1000;
|
|
31
|
+
const RECENT_MS = 30 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
function sleepSync(ms) {
|
|
34
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function pidAlive(pid) {
|
|
38
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
39
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function takeLock(file, { staleMs, waitMs, now }) {
|
|
43
|
+
const until = now() + waitMs;
|
|
44
|
+
for (;;) {
|
|
45
|
+
try {
|
|
46
|
+
const fd = fs.openSync(file, 'wx');
|
|
47
|
+
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, at: now() }));
|
|
48
|
+
return fd;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error.code !== 'EEXIST') throw error;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const stat = fs.statSync(file);
|
|
54
|
+
let owner = null;
|
|
55
|
+
try { owner = JSON.parse(fs.readFileSync(file, 'utf8')); } catch {}
|
|
56
|
+
const dead = owner && Number.isInteger(owner.pid) && !pidAlive(owner.pid);
|
|
57
|
+
const old = now() - stat.mtimeMs > staleMs;
|
|
58
|
+
if ((dead || old) && fs.statSync(file).ino === stat.ino) {
|
|
59
|
+
fs.unlinkSync(file);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (now() >= until) return null;
|
|
66
|
+
sleepSync(100);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Runs fn() while holding an exclusive lock file. A holder that died, or
|
|
71
|
+
// one older than staleMs, is cleared. Throws when the lock stays held past
|
|
72
|
+
// waitMs, so a caller never writes without it.
|
|
73
|
+
function withFileLock(lockFile, fn, { staleMs = STALE_LOCK_MS, waitMs = WAIT_LOCK_MS, now = Date.now } = {}) {
|
|
74
|
+
fs.mkdirSync(path.dirname(lockFile), { recursive: true });
|
|
75
|
+
const fd = takeLock(lockFile, { staleMs, waitMs, now });
|
|
76
|
+
if (fd === null) throw new Error(`${path.basename(lockFile)} stayed locked by another writer`);
|
|
77
|
+
try {
|
|
78
|
+
return fn();
|
|
79
|
+
} finally {
|
|
80
|
+
try { fs.closeSync(fd); } catch {}
|
|
81
|
+
try { fs.unlinkSync(lockFile); } catch {}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Runs fn({ recent, record }) while holding the wake lock. recent: wakes from
|
|
86
|
+
// the last half hour whose run is still alive; record(entry) adds one.
|
|
87
|
+
// Returns { busy: true } when the lock stayed held past waitMs.
|
|
88
|
+
function withWakeReservation(stateDir, fn, { staleMs = STALE_LOCK_MS, waitMs = WAIT_LOCK_MS, now = Date.now } = {}) {
|
|
89
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
90
|
+
const lockFile = path.join(stateDir, LOCK_FILE);
|
|
91
|
+
const recentFile = path.join(stateDir, RECENT_FILE);
|
|
92
|
+
const fd = takeLock(lockFile, { staleMs, waitMs, now });
|
|
93
|
+
if (fd === null) return { busy: true };
|
|
94
|
+
try {
|
|
95
|
+
let recent = [];
|
|
96
|
+
try { recent = JSON.parse(fs.readFileSync(recentFile, 'utf8')).wakes || []; } catch {}
|
|
97
|
+
recent = recent.filter((w) => w && w.member && now() - Number(w.at || 0) < RECENT_MS && pidAlive(w.pid));
|
|
98
|
+
const out = fn({ recent, record: (entry) => recent.push({ ...entry, at: now() }) });
|
|
99
|
+
const tmp = `${recentFile}.${process.pid}.tmp`;
|
|
100
|
+
fs.writeFileSync(tmp, JSON.stringify({ wakes: recent }));
|
|
101
|
+
fs.renameSync(tmp, recentFile);
|
|
102
|
+
return out;
|
|
103
|
+
} finally {
|
|
104
|
+
try { fs.closeSync(fd); } catch {}
|
|
105
|
+
try { fs.unlinkSync(lockFile); } catch {}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function notStarted(to, reason, why) {
|
|
110
|
+
return {
|
|
111
|
+
status: 'not_started',
|
|
112
|
+
reason,
|
|
113
|
+
text: `${to} did not start: ${why}. nobody is working on this step yet. to start it: atris member wake ${to}`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// deps: { listMembers() -> [{ name, live, sleeping, parked, human }],
|
|
118
|
+
// awake(name), engine(name), start(name, step) -> { ok, mission_id, pid, worktree, error },
|
|
119
|
+
// max }
|
|
120
|
+
// deps.reserve(fn), when given, holds the shared wake lock around the
|
|
121
|
+
// count-and-start (withWakeReservation); without it the decision runs bare.
|
|
122
|
+
function wakeHandoffReceiver(handoff, deps) {
|
|
123
|
+
const to = String(handoff && handoff.to || '').trim();
|
|
124
|
+
if (typeof deps.reserve !== 'function') return decideAndStart(handoff, deps, { recent: [], record: () => {} });
|
|
125
|
+
const out = deps.reserve((reservation) => decideAndStart(handoff, deps, reservation));
|
|
126
|
+
if (out && out.busy) return notStarted(to, 'busy', 'another handoff held the start lock for minutes, so this one gave up');
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function decideAndStart(handoff, deps, { recent, record }) {
|
|
131
|
+
const to = String(handoff && handoff.to || '').trim();
|
|
132
|
+
const step = String(handoff && handoff.summary || '').trim();
|
|
133
|
+
const recentNames = new Set(recent.map((w) => w.member));
|
|
134
|
+
const team = (deps.listMembers() || []).map((member) => (member && recentNames.has(member.name) ? { ...member, live: true } : member));
|
|
135
|
+
const seat = team.find((member) => member && member.name === to);
|
|
136
|
+
if (!seat) return notStarted(to, 'not_on_team', `${to} is not on the team`);
|
|
137
|
+
if (seat.human) return notStarted(to, 'person', `${to} is a person, so no engine starts for them`);
|
|
138
|
+
if (seat.live) {
|
|
139
|
+
return {
|
|
140
|
+
status: 'already_working',
|
|
141
|
+
reason: 'already_working',
|
|
142
|
+
text: `${to} is already working; the step is queued as its next push`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (seat.sleeping || deps.awake(to) === false) return notStarted(to, 'asleep', `${to} is asleep`);
|
|
146
|
+
if (seat.parked) return notStarted(to, 'parked', `${to} is parked`);
|
|
147
|
+
const max = Math.max(1, Number(deps.max) || DEFAULT_MAX);
|
|
148
|
+
const working = team.filter((member) => member && member.live).length;
|
|
149
|
+
if (working >= max) {
|
|
150
|
+
return notStarted(to, 'at_limit', `${working} members are already working and the limit is ${max}`);
|
|
151
|
+
}
|
|
152
|
+
const engine = deps.engine(to);
|
|
153
|
+
if (!engine) return notStarted(to, 'no_engine', `the roster names no engine for ${to}`);
|
|
154
|
+
let run;
|
|
155
|
+
try {
|
|
156
|
+
run = deps.start(to, step) || {};
|
|
157
|
+
} catch (error) {
|
|
158
|
+
run = { ok: false, error: error && error.message };
|
|
159
|
+
}
|
|
160
|
+
if (!run.ok || !run.mission_id) {
|
|
161
|
+
const why = String(run.error || 'the run did not start').split('\n')[0].trim().slice(0, 200);
|
|
162
|
+
return notStarted(to, 'start_failed', why);
|
|
163
|
+
}
|
|
164
|
+
record({ member: to, mission_id: run.mission_id, pid: run.pid || null });
|
|
165
|
+
return {
|
|
166
|
+
status: 'started',
|
|
167
|
+
reason: 'started',
|
|
168
|
+
mission_id: run.mission_id,
|
|
169
|
+
pid: run.pid || null,
|
|
170
|
+
worktree: run.worktree || null,
|
|
171
|
+
engine: (engine.engine && engine.engine.id) || engine.id || null,
|
|
172
|
+
text: `${to} is working on it now (mission ${run.mission_id})`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = {
|
|
177
|
+
withFileLock,
|
|
178
|
+
wakeHandoffReceiver,
|
|
179
|
+
withWakeReservation,
|
|
180
|
+
};
|
package/lib/member-up.js
CHANGED
|
@@ -346,11 +346,25 @@ function createMemberUp(deps, options = {}) {
|
|
|
346
346
|
}
|
|
347
347
|
|
|
348
348
|
// One pass over the team. Returns the members it started (or would start).
|
|
349
|
+
// Count-and-start runs under the wake lock that
|
|
350
|
+
// handoffs share (deps.reserve), and counts the members a handoff or
|
|
351
|
+
// another start just woke, so neither path goes past the limit.
|
|
349
352
|
function check() {
|
|
353
|
+
if (typeof deps.reserve !== 'function' || dryRun) return checkUnder({ recent: [], record: () => {} });
|
|
354
|
+
const out = deps.reserve((reservation) => checkUnder(reservation));
|
|
355
|
+
if (out && out.busy) {
|
|
356
|
+
print('another start held the start lock, so nobody was started this pass');
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function checkUnder({ recent, record }) {
|
|
350
363
|
const now = deps.now();
|
|
351
364
|
callOutStalls(now);
|
|
352
365
|
putBackDropped(now);
|
|
353
366
|
const started = [];
|
|
367
|
+
const recentNames = new Set((recent || []).map((w) => w && w.member).filter(Boolean));
|
|
354
368
|
const taken = new Set(Array.from(running.values()).map((run) => run.taskId).filter(Boolean));
|
|
355
369
|
const team = deps.listMembers();
|
|
356
370
|
const members = only ? team.filter((m) => m && only.has(m.name)) : team;
|
|
@@ -362,11 +376,11 @@ function createMemberUp(deps, options = {}) {
|
|
|
362
376
|
print(`${name} is not on the team, skipping`);
|
|
363
377
|
}
|
|
364
378
|
}
|
|
365
|
-
liveElsewhere = new Set(members.filter((m) => m && m.name && m.live).map((m) => m.name));
|
|
379
|
+
liveElsewhere = new Set(members.filter((m) => m && m.name && (m.live || recentNames.has(m.name))).map((m) => m.name));
|
|
366
380
|
for (const member of members) {
|
|
367
381
|
const name = member && member.name;
|
|
368
382
|
if (!name || member.parked || member.human || member.sleeping) continue;
|
|
369
|
-
if (running.has(name) ||
|
|
383
|
+
if (running.has(name) || liveElsewhere.has(name)) continue;
|
|
370
384
|
if (working() + (dryRun ? started.length : 0) >= max) break;
|
|
371
385
|
const s = seat(name);
|
|
372
386
|
if (now < s.nextCheckAt) continue;
|
|
@@ -391,10 +405,11 @@ function createMemberUp(deps, options = {}) {
|
|
|
391
405
|
const run = { taskId: task.id || null, title, startedAt: now, lastStepAt: now, announced: false };
|
|
392
406
|
running.set(name, run);
|
|
393
407
|
try {
|
|
394
|
-
deps.spawn(name, (code, result) => {
|
|
408
|
+
const child = deps.spawn(name, (code, result) => {
|
|
395
409
|
deps.flushProgress?.();
|
|
396
410
|
if (running.get(name) === run) finish(name, code, result);
|
|
397
411
|
}, (claimedTask) => claimed(name, run, claimedTask));
|
|
412
|
+
record({ member: name, pid: child && child.pid ? child.pid : null });
|
|
398
413
|
} catch (error) {
|
|
399
414
|
running.delete(name);
|
|
400
415
|
print(`${name} failed to start: ${oneLine(error && error.message || error)}`);
|
|
@@ -238,16 +238,24 @@ function gitSubcommand(args) {
|
|
|
238
238
|
// Tools that can publish, post, send, or merge. With a mission id, each one
|
|
239
239
|
// found on PATH gets a wrapper that checks the mission's your-yes lock first
|
|
240
240
|
// (lib/yes-lock.js); with no lock file the wrapper just runs the tool.
|
|
241
|
+
const { GUARD_DIR_PREFIX } = require('./spawn-env');
|
|
242
|
+
const GUARD_MISSION_FILE = 'mission.json';
|
|
241
243
|
const YES_LOCK_TOOLS = ['npm', 'yarn', 'pnpm', 'npx', 'pnpx', 'bunx', 'gh', 'atris'];
|
|
242
244
|
|
|
243
245
|
// The lines every wrapper shares: ask the lock, refuse with its message, run
|
|
244
|
-
// the real tool
|
|
246
|
+
// the real tool (with the words the lock gave back, when it gave any: a
|
|
247
|
+
// branch push in a woken run runs with tags and mirroring forced off), and
|
|
248
|
+
// close the steps a yes allowed once that run succeeds.
|
|
245
249
|
function yesLockLines(toolExpr, binExpr) {
|
|
246
250
|
return [
|
|
247
251
|
`const yesLock = require(${JSON.stringify(path.join(__dirname, 'yes-lock.js'))});`,
|
|
248
252
|
`const verdict = config.missionId ? yesLock.check({ stateDir: config.stateDir, missionId: config.missionId, seal: config.seal, tool: ${toolExpr}, args, gitBin: config.gitBin || null, cwd: process.cwd(), env: process.env }) : null;`,
|
|
249
253
|
`if (verdict && verdict.hold) { process.stderr.write(verdict.message + '\\n'); process.exit(yesLock.HELD_EXIT); }`,
|
|
250
|
-
`const
|
|
254
|
+
`const runArgs = verdict && Array.isArray(verdict.args) ? verdict.args : args;`,
|
|
255
|
+
// package scripts run through the guard's own script shell, which puts
|
|
256
|
+
// the wrappers back first after npm puts node_modules/.bin ahead of them
|
|
257
|
+
`const runEnv = config.scriptShell ? require(${JSON.stringify(path.join(__dirname, 'spawn-env.js'))}).packageScriptEnv({ tool: config.tool, realBin: config.realBin, scriptShell: config.scriptShell }, process.env, process.cwd(), args) : process.env;`,
|
|
258
|
+
`const runReal = () => { const child = spawnSync(${binExpr}, runArgs, { cwd: process.cwd(), stdio: 'inherit', env: runEnv }); const ok = !child.error && child.status === 0; if (verdict && verdict.consume) yesLock.consume(config.stateDir, config.missionId, config.seal, verdict.consume, { ok, claims: verdict.claims }); if (child.error) { process.stderr.write(child.error.message + '\\n'); process.exit(1); } process.exit(child.status == null ? 1 : child.status); };`,
|
|
251
259
|
];
|
|
252
260
|
}
|
|
253
261
|
|
|
@@ -257,8 +265,8 @@ function resolveTool(name, root, pathValue) {
|
|
|
257
265
|
return found.status === 0 && bin && path.isAbsolute(bin) ? bin : null;
|
|
258
266
|
}
|
|
259
267
|
|
|
260
|
-
function yesLockWrapper({ tool, realBin, missionId, seal, stateDir }) {
|
|
261
|
-
const config = { tool, realBin, missionId, seal, stateDir };
|
|
268
|
+
function yesLockWrapper({ tool, realBin, missionId, seal, stateDir, scriptShell = null }) {
|
|
269
|
+
const config = { tool, realBin, missionId, seal, stateDir, scriptShell };
|
|
262
270
|
return [
|
|
263
271
|
'#!/usr/bin/env node',
|
|
264
272
|
"'use strict';",
|
|
@@ -292,7 +300,7 @@ function prepareMissionGitGuard({
|
|
|
292
300
|
throw new Error('mission git guard could not resolve git');
|
|
293
301
|
}
|
|
294
302
|
|
|
295
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(),
|
|
303
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), GUARD_DIR_PREFIX));
|
|
296
304
|
const wrapper = path.join(dir, 'git');
|
|
297
305
|
const modulePath = __filename;
|
|
298
306
|
const config = { root: path.resolve(root), tags: normalizedTags(tags), gitBin, missionId: locked ? missionId : null, seal, stateDir };
|
|
@@ -317,11 +325,20 @@ function prepareMissionGitGuard({
|
|
|
317
325
|
'',
|
|
318
326
|
].join('\n');
|
|
319
327
|
fs.writeFileSync(wrapper, source, { encoding: 'utf8', mode: 0o755 });
|
|
328
|
+
// Which mission this guard belongs to, so a member woken by a handoff from
|
|
329
|
+
// inside this run shows its yes cards on this job.
|
|
330
|
+
fs.writeFileSync(path.join(dir, GUARD_MISSION_FILE), JSON.stringify({ mission_id: missionId || null }), 'utf8');
|
|
320
331
|
if (locked) {
|
|
332
|
+
// npm, yarn, and pnpm run package scripts with this shell, so a script's
|
|
333
|
+
// own npm or git is the wrapper even when the project ships one in
|
|
334
|
+
// node_modules/.bin; it then runs the shell the project configured
|
|
335
|
+
const scriptShell = path.join(dir, 'script-shell');
|
|
336
|
+
fs.writeFileSync(scriptShell, require('./spawn-env').scriptShellSource(dir), { encoding: 'utf8', mode: 0o755 });
|
|
321
337
|
for (const tool of YES_LOCK_TOOLS) {
|
|
322
338
|
const realBin = resolveTool(tool, root, pathValue);
|
|
323
339
|
if (!realBin) continue;
|
|
324
|
-
|
|
340
|
+
const pkg = tool === 'npm' || tool === 'yarn' || tool === 'pnpm';
|
|
341
|
+
fs.writeFileSync(path.join(dir, tool), yesLockWrapper({ tool, realBin, missionId, seal, stateDir, scriptShell: pkg ? scriptShell : null }), { encoding: 'utf8', mode: 0o755 });
|
|
325
342
|
}
|
|
326
343
|
}
|
|
327
344
|
|
|
@@ -334,7 +351,22 @@ function prepareMissionGitGuard({
|
|
|
334
351
|
};
|
|
335
352
|
}
|
|
336
353
|
|
|
354
|
+
// The mission whose guard is on this PATH (the run that is calling), or
|
|
355
|
+
// null. Used only to show a handed-off run's yes cards on that job.
|
|
356
|
+
function guardMissionOnPath(pathValue = process.env.PATH || '') {
|
|
357
|
+
for (const dir of String(pathValue).split(path.delimiter)) {
|
|
358
|
+
if (!path.basename(dir).startsWith(GUARD_DIR_PREFIX)) continue;
|
|
359
|
+
try {
|
|
360
|
+
const data = JSON.parse(fs.readFileSync(path.join(dir, GUARD_MISSION_FILE), 'utf8'));
|
|
361
|
+
if (data && data.mission_id) return String(data.mission_id);
|
|
362
|
+
} catch {}
|
|
363
|
+
}
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
|
|
337
367
|
module.exports = {
|
|
368
|
+
GUARD_DIR_PREFIX,
|
|
369
|
+
guardMissionOnPath,
|
|
338
370
|
defaultGit,
|
|
339
371
|
matchProtectedMissionDiff,
|
|
340
372
|
unreadableMissionGuard,
|
package/lib/spawn-env.js
CHANGED
|
@@ -2,17 +2,106 @@
|
|
|
2
2
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
|
|
5
|
+
// The mission guard (lib/mission-protected-lane.js) puts a folder of
|
|
6
|
+
// wrappers for git, npm, gh, and atris first on a job's PATH. Anything that
|
|
7
|
+
// adds a folder ahead of it, or starts a login shell that rebuilds PATH from
|
|
8
|
+
// the profile, must put that folder back first, or a git or npm inside it
|
|
9
|
+
// skips the wrappers and the job's yes-lock.
|
|
10
|
+
const GUARD_DIR_PREFIX = 'atris-mission-git-';
|
|
11
|
+
|
|
12
|
+
function guardDirs(pathValue) {
|
|
13
|
+
return String(pathValue || '').split(path.delimiter).filter(dir => dir && path.basename(dir).startsWith(GUARD_DIR_PREFIX));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The PATH with any guard folder moved back to the front.
|
|
17
|
+
function guardFirst(pathValue) {
|
|
18
|
+
const guards = guardDirs(pathValue);
|
|
19
|
+
if (!guards.length) return String(pathValue || '');
|
|
20
|
+
const rest = String(pathValue || '').split(path.delimiter).filter(dir => !guards.includes(dir));
|
|
21
|
+
return [...guards, ...rest].join(path.delimiter);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A first line for a shell script that puts the guard folder back first
|
|
25
|
+
// after the profile has run. Empty outside a guarded job.
|
|
26
|
+
function shellGuardLine(env = process.env) {
|
|
27
|
+
const guards = guardDirs(env.PATH);
|
|
28
|
+
if (!guards.length) return '';
|
|
29
|
+
return `export PATH=${guards.map(d => `'${d.replace(/'/g, `'\\''`)}'`).join(':')}:"$PATH"\n`;
|
|
30
|
+
}
|
|
31
|
+
|
|
5
32
|
// Autonomously launched runtimes (cron, launchd, pulse ticks) carry a minimal
|
|
6
33
|
// PATH without node's directory, so any shell command that may invoke
|
|
7
34
|
// `node ...` fails only in the one environment nobody is watching. Three
|
|
8
35
|
// subsystems hit this on 2026-07-07 alone (experiment applies, atris2 relayed
|
|
9
36
|
// ops, always-on mission verifiers). Prepend the directory of the node that
|
|
10
|
-
// runs this CLI before spawning shell commands from autonomous paths
|
|
37
|
+
// runs this CLI before spawning shell commands from autonomous paths, behind
|
|
38
|
+
// a guard folder if there is one.
|
|
11
39
|
function envWithNodeDir(base = process.env) {
|
|
12
40
|
const nodeDir = path.dirname(process.execPath);
|
|
13
41
|
const basePath = String(base.PATH || '');
|
|
14
42
|
if (basePath.split(path.delimiter).includes(nodeDir)) return base;
|
|
15
|
-
return { ...base, PATH: `${nodeDir}${path.delimiter}${basePath}` };
|
|
43
|
+
return { ...base, PATH: guardFirst(`${nodeDir}${path.delimiter}${basePath}`) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The guard's script shell for npm, yarn, and pnpm. It puts the wrappers
|
|
47
|
+
// first, then runs the shell the project or user configured
|
|
48
|
+
// (ATRIS_SCRIPT_SHELL), or /bin/sh when none is. For a POSIX shell it also
|
|
49
|
+
// puts them first inside the command, since a shell like zsh can rebuild
|
|
50
|
+
// PATH as it starts.
|
|
51
|
+
function scriptShellSource(dir) {
|
|
52
|
+
const q = `'${String(dir).replace(/'/g, `'\\''`)}'`;
|
|
53
|
+
return [
|
|
54
|
+
'#!/bin/sh',
|
|
55
|
+
`G=${q}`,
|
|
56
|
+
'PATH="$G:$PATH"; export PATH',
|
|
57
|
+
'shell="${ATRIS_SCRIPT_SHELL:-/bin/sh}"',
|
|
58
|
+
'if [ "$1" = "-c" ] && [ $# -ge 2 ]; then',
|
|
59
|
+
' case "${shell##*/}" in',
|
|
60
|
+
' sh|bash|zsh|dash|ksh|mksh|ash)',
|
|
61
|
+
' cmd=$2; shift 2',
|
|
62
|
+
` exec "$shell" -c "PATH='$G':\\"\\$PATH\\"; export PATH; $cmd" "$@" ;;`,
|
|
63
|
+
' esac',
|
|
64
|
+
'fi',
|
|
65
|
+
'exec "$shell" "$@"',
|
|
66
|
+
'',
|
|
67
|
+
].join('\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The environment npm, yarn, or pnpm runs with under a guard: scripts go
|
|
71
|
+
// through the guard's shell, which then runs the configured one. The tool
|
|
72
|
+
// says which shell is configured (project, user, or global config, or the
|
|
73
|
+
// environment), asked with the same config-file and folder options the
|
|
74
|
+
// command was given; a nested call keeps what the outer one found.
|
|
75
|
+
const CONFIG_FLAGS = {
|
|
76
|
+
npm: ['--userconfig', '--globalconfig', '--prefix', '-C'],
|
|
77
|
+
pnpm: ['--userconfig', '--globalconfig', '--dir', '-C'],
|
|
78
|
+
yarn: ['--use-yarnrc', '--cwd'],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function configFlags(tool, args = []) {
|
|
82
|
+
const names = CONFIG_FLAGS[tool] || [];
|
|
83
|
+
const out = [];
|
|
84
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
85
|
+
const a = String(args[i]);
|
|
86
|
+
if (a === '--') break;
|
|
87
|
+
if (names.includes(a.split('=')[0]) && a.includes('=')) out.push(a);
|
|
88
|
+
else if (names.includes(a) && args[i + 1] !== undefined) { out.push(a, String(args[i + 1])); i += 1; }
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function packageScriptEnv({ tool, realBin, scriptShell }, env = process.env, cwd = process.cwd(), args = []) {
|
|
94
|
+
const out = { ...env, npm_config_script_shell: scriptShell };
|
|
95
|
+
// .yarnrc beats npm_config_*, and YARN_SCRIPT_SHELL beats .yarnrc
|
|
96
|
+
if (tool === 'yarn') out.YARN_SCRIPT_SHELL = scriptShell;
|
|
97
|
+
if (tool === 'pnpm') out.npm_config_shell_emulator = 'false';
|
|
98
|
+
if (env.npm_config_script_shell === scriptShell) return out;
|
|
99
|
+
const { spawnSync } = require('child_process');
|
|
100
|
+
const res = spawnSync(realBin, [...configFlags(tool, args), 'config', 'get', 'script-shell'], { cwd, env, encoding: 'utf8', timeout: 10000 });
|
|
101
|
+
const value = res.status === 0 ? String(res.stdout || '').trim().split('\n').pop().trim() : '';
|
|
102
|
+
if (value && !/^(null|undefined)$/.test(value) && value !== scriptShell) out.ATRIS_SCRIPT_SHELL = value;
|
|
103
|
+
else delete out.ATRIS_SCRIPT_SHELL;
|
|
104
|
+
return out;
|
|
16
105
|
}
|
|
17
106
|
|
|
18
|
-
module.exports = { envWithNodeDir };
|
|
107
|
+
module.exports = { GUARD_DIR_PREFIX, envWithNodeDir, shellGuardLine, scriptShellSource, packageScriptEnv };
|
package/lib/task-proof.js
CHANGED
|
@@ -154,7 +154,8 @@ function buildVerifiedProof(verifyCmd, baseProof = '', runner, options = {}) {
|
|
|
154
154
|
const cmd = String(verifyCmd || '').trim();
|
|
155
155
|
if (!cmd) return { ok: false, reason: 'verify_command_required' };
|
|
156
156
|
const run = runner || require('child_process').spawnSync;
|
|
157
|
-
|
|
157
|
+
// a login shell rebuilds PATH; a mission guard's wrappers go back first
|
|
158
|
+
const result = run('bash', ['-lc', `${require('./spawn-env').shellGuardLine()}${cmd}`], {
|
|
158
159
|
cwd: options.cwd || process.cwd(),
|
|
159
160
|
encoding: 'utf8',
|
|
160
161
|
timeout: options.timeoutMs || 120000,
|
package/lib/task-receipt.js
CHANGED
|
@@ -61,9 +61,10 @@ function writeTaskReceipt({ taskId, command, root = process.cwd(), runner } = {}
|
|
|
61
61
|
const run = runner || spawnSync;
|
|
62
62
|
// A login shell can put an older node first on PATH (macOS path_helper puts
|
|
63
63
|
// /usr/local/bin ahead), so the check would run on a node atris does not
|
|
64
|
-
// support. Put the node running atris first, after the profile has loaded
|
|
64
|
+
// support. Put the node running atris first, after the profile has loaded,
|
|
65
|
+
// and a mission guard's wrapper folder ahead of it (lib/spawn-env.js).
|
|
65
66
|
const nodeDir = path.dirname(process.execPath).replace(/'/g, `'\\''`);
|
|
66
|
-
const script = `export PATH='${nodeDir}':"$PATH"\n${cmd}`;
|
|
67
|
+
const script = `export PATH='${nodeDir}':"$PATH"\n${require('./spawn-env').shellGuardLine()}${cmd}`;
|
|
67
68
|
const timeoutMs = verifyTimeoutMs();
|
|
68
69
|
const result = run('bash', ['-lc', script], {
|
|
69
70
|
cwd: root,
|