atris 3.62.0 → 3.64.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/atris/features/README.md +8 -0
- package/atris/skills/README.md +1 -0
- package/atris/skills/commerce/SKILL.md +120 -0
- package/atris/skills/x-search/SKILL.md +3 -1
- package/ax +4 -3
- package/bin/atris.js +22 -2
- package/cli/atris_code.py +25 -1
- package/commands/auth.js +79 -10
- package/commands/autoland.js +11 -1
- package/commands/browse.js +2 -2
- package/commands/business.js +3 -3
- package/commands/buy.js +242 -0
- package/commands/deck.js +2 -2
- package/commands/engine.js +122 -13
- package/commands/feedback.js +2 -2
- package/commands/fork.js +2 -2
- package/commands/gm.js +2 -1
- package/commands/improve.js +142 -8
- package/commands/integrations.js +4 -3
- package/commands/loops.js +2 -1
- package/commands/member.js +82 -15
- package/commands/mission.js +4 -1
- package/commands/pack-craft.js +2 -2
- package/commands/publish.js +2 -2
- package/commands/radar.js +3 -2
- package/commands/router.js +2 -2
- package/commands/task.js +91 -2
- package/commands/transactions.js +115 -0
- package/commands/truth.js +2 -2
- package/commands/x-search.js +81 -10
- package/commands/youtube.js +24 -1
- package/lib/autoland.js +3 -2
- package/lib/ax-auto-lane.js +2 -2
- package/lib/brief-rules.js +11 -0
- package/lib/chat-log-scan.js +2 -1
- package/lib/ci-runner.js +3 -2
- package/lib/codex-flight.js +2 -1
- package/lib/commerce.js +90 -0
- package/lib/deck-history.js +2 -2
- package/lib/deck-review.js +3 -3
- package/lib/design-api.js +2 -2
- package/lib/engine-registry.js +102 -18
- package/lib/first-minute.js +2 -2
- package/lib/fleet.js +8 -36
- package/lib/git-spawn.js +5 -2
- package/lib/ignored-cache.js +51 -0
- package/lib/known-commands.js +1 -1
- package/lib/manifest.js +2 -2
- package/lib/map-refs.js +70 -4
- package/lib/member-engine.js +15 -1
- package/lib/member-scaffold.js +2 -0
- package/lib/member-type.js +27 -0
- package/lib/pack-capabilities.js +2 -1
- package/lib/permission-grants.js +2 -2
- package/lib/pulse.js +5 -5
- package/lib/roster-runs.js +13 -2
- package/lib/runner-command.js +54 -3
- package/lib/sandbox-pids.js +113 -0
- package/lib/secret-gateway.js +3 -13
- package/lib/state-home.js +106 -0
- package/lib/task-db.js +4 -3
- package/lib/task-prune.js +462 -0
- package/lib/x-post-check.js +497 -0
- package/package.json +1 -1
- package/scripts/det/data/xsearch-answer.json +24 -0
- package/scripts/det/data/xsearch-live-2026-09-29.json +93 -0
- package/scripts/det/xsearch-bench.js +307 -0
- package/scripts/det/ytnotes +122 -16
- package/scripts/det/ytrail-bench.js +215 -0
- package/scripts/det/ytrail-eval.js +128 -56
- package/utils/auth.js +3 -3
- package/utils/update-check.js +2 -2
package/lib/map-refs.js
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
// A name elsewhere in the sentence never binds: map sentences often name a helper
|
|
7
7
|
// the ref does not point at, and binding it would move the ref to the wrong place.
|
|
8
8
|
|
|
9
|
+
const crypto = require('crypto');
|
|
9
10
|
const fs = require('fs');
|
|
10
11
|
const path = require('path');
|
|
12
|
+
const ignoredCache = require('./ignored-cache');
|
|
11
13
|
|
|
12
14
|
const WINDOW = 3;
|
|
13
15
|
const IDENT = /^[A-Za-z_$][\w$]*$/;
|
|
@@ -123,6 +125,14 @@ function classify(ref, file) {
|
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
function checkMapRefs(root, mapText) {
|
|
128
|
+
const parsed = parseRefs(mapText);
|
|
129
|
+
const results = classifyRefs(root, parsed);
|
|
130
|
+
return summarize(parsed.map((ref, i) => ({ ...ref, ...results[i] })));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// What the code says about each parsed ref: its status, plus the line it moved
|
|
134
|
+
// to or why it is missing. Each file a ref points at is read once.
|
|
135
|
+
function classifyRefs(root, parsed) {
|
|
126
136
|
const cache = new Map();
|
|
127
137
|
const linesOf = file => {
|
|
128
138
|
if (!cache.has(file)) {
|
|
@@ -144,11 +154,10 @@ function checkMapRefs(root, mapText) {
|
|
|
144
154
|
}
|
|
145
155
|
return cache.get(file);
|
|
146
156
|
};
|
|
147
|
-
|
|
157
|
+
return parsed.map(ref => {
|
|
148
158
|
const file = linesOf(ref.path);
|
|
149
|
-
return
|
|
159
|
+
return file ? classify(ref, file) : { status: 'missing-file' };
|
|
150
160
|
});
|
|
151
|
-
return summarize(refs);
|
|
152
161
|
}
|
|
153
162
|
|
|
154
163
|
function summarize(refs) {
|
|
@@ -197,13 +206,70 @@ function existingMapDocs(root) {
|
|
|
197
206
|
});
|
|
198
207
|
}
|
|
199
208
|
|
|
209
|
+
// Boot scores the docs every session, and checking every ref means reading
|
|
210
|
+
// every file the map points at (megabytes on this repo). The result for each
|
|
211
|
+
// map file is saved in the ignored .atris/cache and reused only while the map
|
|
212
|
+
// text and the stat stamp of every file it points at are unchanged.
|
|
213
|
+
const REFS_CACHE_NAME = 'map-refs.json';
|
|
214
|
+
|
|
215
|
+
// Size, modified time, change time, and inode of a file a ref points at; null
|
|
216
|
+
// when it is not there. Change time moves on every write and cannot be set
|
|
217
|
+
// back by hand, so an edit that keeps size and modified time still misses.
|
|
218
|
+
function fileStamp(full) {
|
|
219
|
+
try {
|
|
220
|
+
const stat = fs.statSync(full);
|
|
221
|
+
return `${stat.isFile() ? 'f' : 'o'}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${stat.ino}`;
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function refsCacheKey(root, text, parsed) {
|
|
229
|
+
const files = [...new Set(parsed.map(ref => ref.path))];
|
|
230
|
+
const stamps = files.map(file => [file, fileStamp(path.resolve(root, file))]);
|
|
231
|
+
return crypto.createHash('sha1').update(JSON.stringify([text, stamps])).digest('hex');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function loadRefsCache(root) {
|
|
235
|
+
const cache = { root, docs: {}, dirty: false, writable: ignoredCache.cacheEnabled(root) };
|
|
236
|
+
if (!cache.writable) return cache;
|
|
237
|
+
const parsed = ignoredCache.readCacheJson(root, REFS_CACHE_NAME);
|
|
238
|
+
if (parsed && parsed.version === 1 && parsed.docs && typeof parsed.docs === 'object') cache.docs = parsed.docs;
|
|
239
|
+
return cache;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function saveRefsCache(cache) {
|
|
243
|
+
if (!cache.writable || !cache.dirty) return;
|
|
244
|
+
ignoredCache.writeCacheJson(cache.root, REFS_CACHE_NAME, { version: 1, docs: cache.docs });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function cachedMapRefs(root, doc, text, cache) {
|
|
248
|
+
const parsed = parseRefs(text);
|
|
249
|
+
const key = cache.writable ? refsCacheKey(root, text, parsed) : '';
|
|
250
|
+
const hit = key && cache.docs[doc];
|
|
251
|
+
let results;
|
|
252
|
+
if (hit && hit.key === key && Array.isArray(hit.results) && hit.results.length === parsed.length) {
|
|
253
|
+
results = hit.results;
|
|
254
|
+
} else {
|
|
255
|
+
results = classifyRefs(root, parsed);
|
|
256
|
+
if (key) {
|
|
257
|
+
cache.docs[doc] = { key, results };
|
|
258
|
+
cache.dirty = true;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return parsed.map((ref, i) => ({ ...ref, ...results[i] }));
|
|
262
|
+
}
|
|
263
|
+
|
|
200
264
|
// mapText is the already-read atris/MAP.md, so boot does not read it twice.
|
|
201
265
|
function checkMapDocs(root, mapText) {
|
|
266
|
+
const cache = loadRefsCache(root);
|
|
202
267
|
const refs = existingMapDocs(root).flatMap(doc => {
|
|
203
268
|
const text = doc === MAP_REF_DOCS[0] && typeof mapText === 'string'
|
|
204
269
|
? mapText : fs.readFileSync(path.resolve(root, doc), 'utf8');
|
|
205
|
-
return
|
|
270
|
+
return cachedMapRefs(root, doc, text, cache).map(ref => ({ doc, ...ref }));
|
|
206
271
|
});
|
|
272
|
+
saveRefsCache(cache);
|
|
207
273
|
return summarize(refs);
|
|
208
274
|
}
|
|
209
275
|
|
package/lib/member-engine.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// words ("judge: opus 5.5"). The job's pick then comes from the roster.
|
|
9
9
|
|
|
10
10
|
const fs = require('fs');
|
|
11
|
+
const { memberRefusalReason } = require('./member-type');
|
|
11
12
|
const path = require('path');
|
|
12
13
|
const {
|
|
13
14
|
ENGINE_JOBS,
|
|
@@ -204,6 +205,7 @@ function memberRosterEngine(memberName, root = process.cwd(), options = {}) {
|
|
|
204
205
|
const name = String(memberName || '').trim();
|
|
205
206
|
if (!name || name.includes('/') || name.includes('\\')) return null;
|
|
206
207
|
try {
|
|
208
|
+
if (!memberCardRunnable(root, name)) return null;
|
|
207
209
|
const picked = resolveEngineForMember(name, root, options);
|
|
208
210
|
if (!picked.found && options.requireMember !== false) return null;
|
|
209
211
|
return picked.roster_backed && picked.engine ? picked : null;
|
|
@@ -212,6 +214,18 @@ function memberRosterEngine(memberName, root = process.cwd(), options = {}) {
|
|
|
212
214
|
}
|
|
213
215
|
}
|
|
214
216
|
|
|
217
|
+
// A person or odd-typed card never runs on an engine.
|
|
218
|
+
function memberCardRunnable(root, name) {
|
|
219
|
+
const card = findMemberCard(root, name);
|
|
220
|
+
if (!card) return true;
|
|
221
|
+
try {
|
|
222
|
+
const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(fs.readFileSync(card, 'utf8'));
|
|
223
|
+
const match = block ? /^type:\s*(.*)$/m.exec(block[1]) : null;
|
|
224
|
+
const value = match ? match[1].trim().replace(/^["']|["']$/g, '') : '';
|
|
225
|
+
return !memberRefusalReason(name, value ? { type: value } : {});
|
|
226
|
+
} catch { return true; }
|
|
227
|
+
}
|
|
228
|
+
|
|
215
229
|
// A parked card stays on disk and still runs by name; this view (the roster
|
|
216
230
|
// team block and the boot lineup) just leaves it out.
|
|
217
231
|
function memberCardParked(root, name) {
|
|
@@ -232,7 +246,7 @@ function teamRosterView(root = process.cwd(), options = {}) {
|
|
|
232
246
|
else if (layer.scope === 'project') warnings.push({ file: line.file, line: line.line, text: line.text, message: `there is no team member named ${line.member}, so the line does nothing` });
|
|
233
247
|
}
|
|
234
248
|
}
|
|
235
|
-
for (const name of [...names]) if (memberCardParked(root, name)) names.delete(name);
|
|
249
|
+
for (const name of [...names]) if (memberCardParked(root, name) || !memberCardRunnable(root, name)) names.delete(name);
|
|
236
250
|
const rows = [...names].sort().map((name) => {
|
|
237
251
|
const picked = resolveEngineForMember(name, root, { ...options, rosterState: state });
|
|
238
252
|
const line = teamLineFor(state, name);
|
package/lib/member-scaffold.js
CHANGED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// MEMBER.md `type:` says who a member is. Missing means ai and `agent` is an
|
|
4
|
+
// alias for ai. `human` is a person: Atris can prepare work for them but never
|
|
5
|
+
// acts as them. Any other value keeps the file readable but is never run.
|
|
6
|
+
|
|
7
|
+
function memberTypeOf(frontmatter) {
|
|
8
|
+
const raw = frontmatter && frontmatter.type;
|
|
9
|
+
const value = typeof raw === 'string' ? raw.trim() : '';
|
|
10
|
+
if (!value) return { type: 'ai', raw: '', runnable: true };
|
|
11
|
+
const lowered = value.toLowerCase();
|
|
12
|
+
if (lowered === 'ai' || lowered === 'agent') return { type: 'ai', raw: value, runnable: true };
|
|
13
|
+
if (lowered === 'human') return { type: 'human', raw: value, runnable: false };
|
|
14
|
+
return { type: 'other', raw: value, runnable: false };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Plain sentence for why a member will not run; '' when it can run.
|
|
18
|
+
function memberRefusalReason(name, frontmatter) {
|
|
19
|
+
const info = memberTypeOf(frontmatter);
|
|
20
|
+
if (info.runnable) return '';
|
|
21
|
+
if (info.type === 'human') {
|
|
22
|
+
return `${name} is a person, not an AI teammate; Atris can prepare work for them but cannot act as them`;
|
|
23
|
+
}
|
|
24
|
+
return `${name} has type '${info.raw}', which is neither ai nor human, so Atris won't run it`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { memberTypeOf, memberRefusalReason };
|
package/lib/pack-capabilities.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const path = require('path');
|
|
6
|
+
const { stateHome } = require('./state-home');
|
|
6
7
|
const dns = require('dns').promises;
|
|
7
8
|
const net = require('net');
|
|
8
9
|
const { randomUUID, createHash } = require('crypto');
|
|
@@ -250,7 +251,7 @@ function buildClaudeCapabilityArgs(policy, options = {}) {
|
|
|
250
251
|
function receiptDirectory(options = {}) {
|
|
251
252
|
if (options.receiptDir) return path.resolve(options.receiptDir);
|
|
252
253
|
if (process.env.ATRIS_PACK_RUNS_DIR) return path.resolve(process.env.ATRIS_PACK_RUNS_DIR);
|
|
253
|
-
return path.join(
|
|
254
|
+
return path.join(stateHome(), '.atris', 'runs', 'packs');
|
|
254
255
|
}
|
|
255
256
|
|
|
256
257
|
function appendReceiptEvent(eventsPath, event) {
|
package/lib/permission-grants.js
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
// Design brief: atris/designs/permission-grants-cli-831.md
|
|
9
9
|
|
|
10
10
|
const fs = require('fs');
|
|
11
|
-
const os = require('os');
|
|
12
11
|
const path = require('path');
|
|
12
|
+
const { stateHome } = require('./state-home');
|
|
13
13
|
const crypto = require('crypto');
|
|
14
14
|
|
|
15
15
|
const GRANTS_SCHEMA = 'atris.permission_grants.v1';
|
|
@@ -39,7 +39,7 @@ const NEVER_GRANTABLE = [
|
|
|
39
39
|
|
|
40
40
|
function grantsFilePath() {
|
|
41
41
|
return process.env.ATRIS_PERMISSION_GRANTS_FILE
|
|
42
|
-
|| path.join(
|
|
42
|
+
|| path.join(stateHome(), '.atris', 'permission-grants.json');
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
function parseArgv(command) {
|
package/lib/pulse.js
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
const crypto = require('crypto');
|
|
17
|
-
const os = require('os');
|
|
18
17
|
const path = require('path');
|
|
18
|
+
const { stateHome: userStateHome } = require('./state-home');
|
|
19
19
|
const { DEFAULT_CLAUDE_RUNNER_MODEL } = require('./runner-command');
|
|
20
20
|
const { readOrbScorecard } = require('./orb-scorecard');
|
|
21
21
|
|
|
@@ -69,7 +69,7 @@ function pulseRepoSlug(root) {
|
|
|
69
69
|
return `${base}-${hash}`;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
function pulseStateHome(root, homeDir =
|
|
72
|
+
function pulseStateHome(root, homeDir = userStateHome()) {
|
|
73
73
|
return path.join(homeDir, '.atris', 'overnight', `pulse-${pulseRepoSlug(root)}`);
|
|
74
74
|
}
|
|
75
75
|
|
|
@@ -77,7 +77,7 @@ function pulseMarker(root) {
|
|
|
77
77
|
return `ATRIS_PULSE_${pulseRepoSlug(root).toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
function legacyPulseStateHome(homeDir =
|
|
80
|
+
function legacyPulseStateHome(homeDir = userStateHome()) {
|
|
81
81
|
return path.join(homeDir, '.atris', 'overnight', LEGACY_STATE_DIRNAME);
|
|
82
82
|
}
|
|
83
83
|
|
|
@@ -92,7 +92,7 @@ function readTickScriptRoot(stateHome) {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
function legacyPulseStateMatchesRoot(root, homeDir =
|
|
95
|
+
function legacyPulseStateMatchesRoot(root, homeDir = userStateHome()) {
|
|
96
96
|
const legacyStateHome = legacyPulseStateHome(homeDir);
|
|
97
97
|
const scriptRoot = readTickScriptRoot(legacyStateHome);
|
|
98
98
|
if (!scriptRoot) return false;
|
|
@@ -100,7 +100,7 @@ function legacyPulseStateMatchesRoot(root, homeDir = os.homedir()) {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
function resolvePulseSlot(root, options = {}) {
|
|
103
|
-
const homeDir = options.homeDir ||
|
|
103
|
+
const homeDir = options.homeDir || userStateHome();
|
|
104
104
|
const stateHome = pulseStateHome(root, homeDir);
|
|
105
105
|
const marker = pulseMarker(root);
|
|
106
106
|
const legacyStateHome = legacyPulseStateHome(homeDir);
|
package/lib/roster-runs.js
CHANGED
|
@@ -29,14 +29,25 @@ const TAIL_BYTES = 512 * 1024;
|
|
|
29
29
|
const RUNS_ROTATE_BYTES = 2 * 1024 * 1024;
|
|
30
30
|
const JOB_ALIASES = Object.freeze({ navigator: 'search', executor: 'build', builder: 'build', validator: 'review', reviewer: 'review' });
|
|
31
31
|
|
|
32
|
+
// The record belongs to the project, not to the scratch checkout a run
|
|
33
|
+
// happened in. A fleet build, a mission, or an ask run inside a git worktree
|
|
34
|
+
// writes to the main checkout, so the history survives when the worktree is
|
|
35
|
+
// reaped and `atris engine roster --runs` reads the same file from either
|
|
36
|
+
// place. Same pointer-file lookup the roster and member cards use.
|
|
37
|
+
function runsRoot(root = process.cwd()) {
|
|
38
|
+
const { mainCheckoutRoot } = require('./engine-registry');
|
|
39
|
+
const main = mainCheckoutRoot(root);
|
|
40
|
+
return main && fs.existsSync(main) ? main : root;
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
function rosterRunsPath(root = process.cwd()) {
|
|
33
|
-
return path.join(root, RUNS_FILE);
|
|
44
|
+
return path.join(runsRoot(root), RUNS_FILE);
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
// Rotated logs, oldest name first; the stamp in each name makes name order
|
|
37
48
|
// the order they were renamed in.
|
|
38
49
|
function rotatedRunsPaths(root = process.cwd()) {
|
|
39
|
-
const dir = path.dirname(
|
|
50
|
+
const dir = path.dirname(rosterRunsPath(root));
|
|
40
51
|
let names;
|
|
41
52
|
try {
|
|
42
53
|
names = fs.readdirSync(dir);
|
package/lib/runner-command.js
CHANGED
|
@@ -38,10 +38,12 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
|
|
|
38
38
|
efforts: CLAUDE_EFFORTS,
|
|
39
39
|
}),
|
|
40
40
|
// No -m or effort unless the roster pins one: codex then reads its own
|
|
41
|
-
// ~/.codex/config.toml, which is what the roster view reports.
|
|
41
|
+
// ~/.codex/config.toml, which is what the roster view reports. Codex runs
|
|
42
|
+
// only OpenAI models, so a claude model name never reaches it.
|
|
42
43
|
codex: Object.freeze({
|
|
43
44
|
bin: 'codex',
|
|
44
45
|
model: '',
|
|
46
|
+
ownModelsOnly: true,
|
|
45
47
|
commandTemplate: '{bin} exec {pinnedModelFlag} {pinnedEffortFlag} {prompt}',
|
|
46
48
|
effortFlag: '-c model_reasoning_effort=',
|
|
47
49
|
efforts: Object.freeze(['low', 'medium', 'high', 'xhigh']),
|
|
@@ -85,9 +87,11 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
|
|
|
85
87
|
}),
|
|
86
88
|
// No hard pin: a pinned grok-4.6 went stale when the grok CLI moved its
|
|
87
89
|
// own default to grok-4.7-build-fast, so an unpinned run rides the CLI.
|
|
90
|
+
// Grok runs only xAI models, so a claude model name never reaches it.
|
|
88
91
|
grok: Object.freeze({
|
|
89
92
|
bin: 'grok',
|
|
90
93
|
model: '',
|
|
94
|
+
ownModelsOnly: true,
|
|
91
95
|
commandTemplate: '{bin} --always-approve {pinnedModelFlag} {pinnedEffortFlag} -p {prompt}',
|
|
92
96
|
effortFlag: '--reasoning-effort',
|
|
93
97
|
efforts: Object.freeze(['low', 'medium', 'high']),
|
|
@@ -211,6 +215,30 @@ function resolvePinnedRunnerModel(mission) {
|
|
|
211
215
|
return explicit || runnerProfileValue('model');
|
|
212
216
|
}
|
|
213
217
|
|
|
218
|
+
// The own-models-only engine a runner bin launches ("codex", "/opt/bin/grok"),
|
|
219
|
+
// or '' when the program is not one of them.
|
|
220
|
+
function ownModelsOnlyEngineForBin(bin) {
|
|
221
|
+
const name = String(bin || '').split(/[\\/]/).pop().replace(/\.exe$/i, '').toLowerCase();
|
|
222
|
+
if (!name) return '';
|
|
223
|
+
return Object.keys(RUNNER_PROFILE_DEFS).find((id) => {
|
|
224
|
+
const def = RUNNER_PROFILE_DEFS[id];
|
|
225
|
+
return def.ownModelsOnly && def.bin === name;
|
|
226
|
+
}) || '';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The model an engine is launched with. An engine that runs only its own
|
|
230
|
+
// vendor's models drops a claude model name (a mission's stored default, a
|
|
231
|
+
// claude env pin) and rides its own default instead.
|
|
232
|
+
function runnerModelFor(engineId, model) {
|
|
233
|
+
const value = String(model || '').trim();
|
|
234
|
+
const name = String(engineId || '').trim().toLowerCase();
|
|
235
|
+
const def = RUNNER_PROFILE_DEFS[RUNNER_PROFILE_ALIASES[name] || name];
|
|
236
|
+
if (!value || !def || !def.ownModelsOnly) return value;
|
|
237
|
+
// Required here, not at the top: engine-registry requires this file.
|
|
238
|
+
const { isClaudeFamilyModel } = require('./engine-registry');
|
|
239
|
+
return isClaudeFamilyModel(value) ? '' : value;
|
|
240
|
+
}
|
|
241
|
+
|
|
214
242
|
function resolveClaudeRunnerBin() {
|
|
215
243
|
const env = firstConfiguredEnv(['ATRIS_RUNNER_BIN']);
|
|
216
244
|
if (env) return env;
|
|
@@ -265,6 +293,14 @@ function renderRunnerCommandTemplate(template, { promptFile, allowedTools, model
|
|
|
265
293
|
// The pinned flags take their leading space with them, so an unpinned
|
|
266
294
|
// launch reads exactly like the template without the placeholders.
|
|
267
295
|
template = template.replace(/\s*\{pinnedModelFlag\}/g, pinnedModel ? ` --model ${shellWord(pinnedModel)}` : '');
|
|
296
|
+
// A model dropped for an engine that cannot run it leaves no gap either,
|
|
297
|
+
// and a literal "--model {model}", "-m{model}" or a quoted "{model}" goes
|
|
298
|
+
// with it, quotes and all.
|
|
299
|
+
if (!model) {
|
|
300
|
+
template = template
|
|
301
|
+
.replace(/\s*\{modelFlag\}/g, '')
|
|
302
|
+
.replace(/\s*(?:--model|-m)(?:\s+|=)?(["']?)\{model\}\1/g, '');
|
|
303
|
+
}
|
|
268
304
|
// Checked even when the template has no slot, so an engine that cannot
|
|
269
305
|
// take effort refuses instead of running at its default.
|
|
270
306
|
const effortFlag = runnerEffortFlag(effort);
|
|
@@ -298,11 +334,25 @@ function buildRunnerCommand({ promptFile, allowedTools, model, effort } = {}) {
|
|
|
298
334
|
}
|
|
299
335
|
const resolved = resolveClaudeRunnerModel({ model });
|
|
300
336
|
const template = resolveClaudeRunnerCommandTemplate();
|
|
337
|
+
// Both the profile and the program that actually runs count: a runner bin
|
|
338
|
+
// named codex or grok (or a path ending in one) is that engine under any
|
|
339
|
+
// profile, with or without a template.
|
|
340
|
+
const profile = resolveRunnerProfileName();
|
|
341
|
+
const binEngine = ownModelsOnlyEngineForBin(resolveClaudeRunnerBin());
|
|
342
|
+
const modelFor = (value) => runnerModelFor(binEngine, runnerModelFor(profile, value));
|
|
301
343
|
if (template) {
|
|
302
|
-
return renderRunnerCommandTemplate(template, {
|
|
344
|
+
return renderRunnerCommandTemplate(template, {
|
|
345
|
+
promptFile,
|
|
346
|
+
allowedTools,
|
|
347
|
+
model: modelFor(resolved),
|
|
348
|
+
pinnedModel: modelFor(resolvePinnedRunnerModel({ model })),
|
|
349
|
+
effort,
|
|
350
|
+
});
|
|
303
351
|
}
|
|
304
352
|
const safePath = String(promptFile).replace(/'/g, "'\\''");
|
|
305
|
-
|
|
353
|
+
const launchModel = modelFor(resolved);
|
|
354
|
+
let cmd = `${shellWord(resolveClaudeRunnerBin())} -p "$(cat '${safePath}')"`;
|
|
355
|
+
if (launchModel) cmd += ` --model ${shellWord(launchModel)}`;
|
|
306
356
|
const effortFlag = runnerEffortFlag(effort);
|
|
307
357
|
if (effortFlag) cmd += ` ${effortFlag}`;
|
|
308
358
|
if (allowedTools) {
|
|
@@ -339,6 +389,7 @@ module.exports = {
|
|
|
339
389
|
resolveRunnerProfile,
|
|
340
390
|
resolveClaudeRunnerModel,
|
|
341
391
|
resolvePinnedRunnerModel,
|
|
392
|
+
runnerModelFor,
|
|
342
393
|
resolveClaudeRunnerBin,
|
|
343
394
|
resolveClaudeRunnerCommandTemplate,
|
|
344
395
|
buildRunnerAvailabilityCommand,
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Find every process a sealed run may have left behind: members of its
|
|
4
|
+
// process group, holders of its lease file, and anything whose cwd sits in its
|
|
5
|
+
// worktree. Each lsof scan walks the whole process table (about 300 to 500ms on
|
|
6
|
+
// a busy mac), and the reaper repeats the sweep six times per sealed run, so
|
|
7
|
+
// the three scans run side by side instead of one after another. They run from
|
|
8
|
+
// "/" so a scan never finds its own siblings through the worktree cwd.
|
|
9
|
+
|
|
10
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
11
|
+
|
|
12
|
+
function sandboxPidQueries({ pgid, leaseFile, cwd }) {
|
|
13
|
+
const queries = [];
|
|
14
|
+
const group = Number(pgid);
|
|
15
|
+
if (Number.isInteger(group) && group > 0) queries.push(['/usr/bin/pgrep', ['-g', String(group)]]);
|
|
16
|
+
queries.push(['/usr/sbin/lsof', ['-t', String(leaseFile)]]);
|
|
17
|
+
if (cwd) queries.push(['/usr/sbin/lsof', ['-a', '-d', 'cwd', '+D', String(cwd), '-t']]);
|
|
18
|
+
return queries;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function collectPids(outputs, excluded) {
|
|
22
|
+
const pids = new Set();
|
|
23
|
+
for (const output of outputs) {
|
|
24
|
+
for (const value of String(output || '').trim().split(/\s+/)) {
|
|
25
|
+
const pid = Number(value);
|
|
26
|
+
if (Number.isInteger(pid) && pid > 0) pids.add(pid);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
for (const pid of excluded) pids.delete(pid);
|
|
30
|
+
return [...pids];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// pgrep and lsof both exit 1 when nothing matches. Anything else (another
|
|
34
|
+
// status, a signal, a spawn error, no stdout pipe) means the scan did not run,
|
|
35
|
+
// which is not the same as finding nothing.
|
|
36
|
+
function runScan(bin, argv) {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
let stdout = '';
|
|
39
|
+
let settled = false;
|
|
40
|
+
const finish = (ok) => {
|
|
41
|
+
if (settled) return;
|
|
42
|
+
settled = true;
|
|
43
|
+
resolve({ ok, stdout: ok ? stdout : '' });
|
|
44
|
+
};
|
|
45
|
+
let child;
|
|
46
|
+
try {
|
|
47
|
+
child = spawn(bin, argv, { cwd: '/', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
48
|
+
} catch {
|
|
49
|
+
finish(false);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
child.once('error', () => finish(false));
|
|
53
|
+
if (!child.stdout) {
|
|
54
|
+
finish(false);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
child.stdout.on('error', () => finish(false));
|
|
58
|
+
child.stdout.setEncoding('utf8');
|
|
59
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
60
|
+
child.once('close', (status, signal) => finish(!signal && (status === 0 || status === 1)));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sequentialOutputs(query) {
|
|
65
|
+
return sandboxPidQueries(query).map(([bin, argv]) => {
|
|
66
|
+
try {
|
|
67
|
+
return spawnSync(bin, argv, { cwd: '/', encoding: 'utf8' }).stdout;
|
|
68
|
+
} catch {
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// If any side-by-side scan fails, run the whole sweep again one by one and
|
|
75
|
+
// keep every pid either pass saw.
|
|
76
|
+
async function trackedSandboxPids(query, { excluded = [process.pid], scan = runScan } = {}) {
|
|
77
|
+
const scans = await Promise.all(sandboxPidQueries(query).map(([bin, argv]) => scan(bin, argv)));
|
|
78
|
+
const outputs = scans.map((result) => result.stdout);
|
|
79
|
+
if (scans.some((result) => !result.ok)) outputs.push(...sequentialOutputs(query));
|
|
80
|
+
return collectPids(outputs, excluded);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Synchronous callers get the same parallel sweep through one short node
|
|
84
|
+
// helper. If the helper cannot answer, fall back to the one-by-one sweep so
|
|
85
|
+
// the reaper never runs blind.
|
|
86
|
+
function trackedSandboxPidsSync(query) {
|
|
87
|
+
const excluded = [process.pid];
|
|
88
|
+
const helper = spawnSync(process.execPath, [__filename, JSON.stringify(query)], {
|
|
89
|
+
cwd: '/',
|
|
90
|
+
encoding: 'utf8',
|
|
91
|
+
});
|
|
92
|
+
if (helper.status === 0) {
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(helper.stdout);
|
|
95
|
+
if (Array.isArray(parsed)) {
|
|
96
|
+
return collectPids([parsed.join(' ')], [...excluded, helper.pid]);
|
|
97
|
+
}
|
|
98
|
+
} catch {}
|
|
99
|
+
}
|
|
100
|
+
return collectPids(sequentialOutputs(query), excluded);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (require.main === module) {
|
|
104
|
+
trackedSandboxPids(JSON.parse(process.argv[2] || '{}'))
|
|
105
|
+
.then((pids) => { process.stdout.write(JSON.stringify(pids) + '\n'); })
|
|
106
|
+
.catch(() => { process.exitCode = 1; });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = {
|
|
110
|
+
runScan,
|
|
111
|
+
trackedSandboxPids,
|
|
112
|
+
trackedSandboxPidsSync,
|
|
113
|
+
};
|
package/lib/secret-gateway.js
CHANGED
|
@@ -393,6 +393,7 @@ function buildGatewaySupervisorScript(gatewayModulePath) {
|
|
|
393
393
|
"const fs = require('node:fs');",
|
|
394
394
|
"const { spawn, spawnSync } = require('node:child_process');",
|
|
395
395
|
`const sg = require(${JSON.stringify(gatewayModulePath)});`,
|
|
396
|
+
`const sandboxPids = require(${JSON.stringify(require.resolve('./sandbox-pids'))});`,
|
|
396
397
|
"const [exitFile, stateFile, leaseFile, statusFile, executable, ...args] = process.argv.slice(2);",
|
|
397
398
|
"const leaseFd = fs.openSync(leaseFile, 'w', 0o600);",
|
|
398
399
|
"const statusFd = fs.openSync(statusFile, 'w', 0o600);",
|
|
@@ -413,18 +414,7 @@ function buildGatewaySupervisorScript(gatewayModulePath) {
|
|
|
413
414
|
" fs.writeFileSync(stateFile, JSON.stringify({ pgid: child.pid, cwd: process.cwd() }) + '\\n', { mode: 0o600 });",
|
|
414
415
|
' let stopping = false;',
|
|
415
416
|
' const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));',
|
|
416
|
-
'
|
|
417
|
-
' const pids = new Set();',
|
|
418
|
-
" for (const [bin, argv] of [['/usr/bin/pgrep', ['-g', String(child.pid)]], ['/usr/sbin/lsof', ['-t', leaseFile]], ['/usr/sbin/lsof', ['-a', '-d', 'cwd', '+D', process.cwd(), '-t']]]) {",
|
|
419
|
-
" const found = spawnSync(bin, argv, { encoding: 'utf8' });",
|
|
420
|
-
" for (const value of String(found.stdout || '').trim().split(/\\s+/)) {",
|
|
421
|
-
' const pid = Number(value);',
|
|
422
|
-
' if (Number.isInteger(pid) && pid > 0) pids.add(pid);',
|
|
423
|
-
' }',
|
|
424
|
-
' }',
|
|
425
|
-
' pids.delete(process.pid);',
|
|
426
|
-
' return [...pids];',
|
|
427
|
-
' }',
|
|
417
|
+
' const trackedPids = () => sandboxPids.trackedSandboxPids({ pgid: child.pid, leaseFile, cwd: process.cwd() });',
|
|
428
418
|
' async function stop(code) {',
|
|
429
419
|
' if (stopping) return;',
|
|
430
420
|
' stopping = true;',
|
|
@@ -433,7 +423,7 @@ function buildGatewaySupervisorScript(gatewayModulePath) {
|
|
|
433
423
|
' }',
|
|
434
424
|
" for (const [signal, delay] of [['SIGTERM', 100], ['SIGKILL', 100], ['SIGKILL', 100]]) {",
|
|
435
425
|
' try { process.kill(-child.pid, signal); } catch {}',
|
|
436
|
-
' for (const pid of trackedPids()) { try { process.kill(pid, signal); } catch {} }',
|
|
426
|
+
' for (const pid of await trackedPids()) { try { process.kill(pid, signal); } catch {} }',
|
|
437
427
|
' await wait(delay);',
|
|
438
428
|
' }',
|
|
439
429
|
' try { await gateway.close(); } catch {}',
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Where ~/.atris state lives, with a fence for test runs.
|
|
2
|
+
//
|
|
3
|
+
// The node test runner sets NODE_TEST_CONTEXT in every test-file process, and
|
|
4
|
+
// CLI subprocesses spawned by tests inherit it. A test that forgets to point
|
|
5
|
+
// ATRIS_TASKS_DB or HOME at a temp dir used to write the user's real task
|
|
6
|
+
// database. Under the test runner, when HOME is still the real user home, we
|
|
7
|
+
// hand back a throwaway home instead, so forgetting the override is safe.
|
|
8
|
+
//
|
|
9
|
+
// Explicit choices still win: a temp HOME keeps its own .atris, and
|
|
10
|
+
// ATRIS_TEST_REAL_HOME=1 opts a test back into the real home on purpose.
|
|
11
|
+
// Outside the test runner nothing changes.
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
function normalizedPath(p) {
|
|
17
|
+
let resolved;
|
|
18
|
+
try { resolved = fs.realpathSync(p); } catch (_) { resolved = path.resolve(p); }
|
|
19
|
+
const foldCase = process.platform === 'darwin' || process.platform === 'win32';
|
|
20
|
+
return foldCase ? resolved.toLowerCase() : resolved;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Same folder under any spelling: symlinks, letter case, and macOS firmlinks
|
|
24
|
+
// (the home folder seen through /System/Volumes/Data), which realpath leaves
|
|
25
|
+
// alone but which share the device and inode of the usual path.
|
|
26
|
+
function samePath(a, b) {
|
|
27
|
+
if (!a || !b) return false;
|
|
28
|
+
if (normalizedPath(a) === normalizedPath(b)) return true;
|
|
29
|
+
try {
|
|
30
|
+
const sa = fs.statSync(a);
|
|
31
|
+
const sb = fs.statSync(b);
|
|
32
|
+
return sa.ino !== 0 && sa.dev === sb.dev && sa.ino === sb.ino;
|
|
33
|
+
} catch (_) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The account's home from the user database, not HOME. Read once per
|
|
39
|
+
// process: it cannot change while we run, and callers resolve paths often.
|
|
40
|
+
let realUserHome;
|
|
41
|
+
function realHomeOnce() {
|
|
42
|
+
if (realUserHome === undefined) {
|
|
43
|
+
try { realUserHome = os.userInfo().homedir || null; } catch (_) { realUserHome = null; }
|
|
44
|
+
}
|
|
45
|
+
return realUserHome;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function protectedHomes(env) {
|
|
49
|
+
const homes = [];
|
|
50
|
+
if (realHomeOnce()) homes.push(realHomeOnce());
|
|
51
|
+
// Test seam that can only widen the fence: marks one more folder as a
|
|
52
|
+
// real home, so a test can prove the fence without touching the real one.
|
|
53
|
+
if (env.ATRIS_TEST_PROTECTED_HOME) homes.push(env.ATRIS_TEST_PROTECTED_HOME);
|
|
54
|
+
return homes;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
realHomeOnce();
|
|
58
|
+
|
|
59
|
+
function underTest(env) {
|
|
60
|
+
return Boolean(env.NODE_TEST_CONTEXT || env.ATRIS_TEST_STATE_DIR);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// os.homedir() follows HOME and any test mock of it; a caller-supplied env
|
|
64
|
+
// with its own HOME (like a spawned child's env) takes that instead.
|
|
65
|
+
function currentHome(env) {
|
|
66
|
+
return (env !== process.env && env.HOME) || os.homedir();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function testStateFenced(env = process.env) {
|
|
70
|
+
if (env.ATRIS_TEST_REAL_HOME === '1') return false;
|
|
71
|
+
if (!underTest(env)) return false;
|
|
72
|
+
const home = currentHome(env);
|
|
73
|
+
const real = protectedHomes(env);
|
|
74
|
+
// If we cannot tell who the real user is, assume HOME is real.
|
|
75
|
+
if (real.length === 0) return true;
|
|
76
|
+
return real.some((h) => samePath(home, h));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// One fence folder per OS user, shared by every test run. Tests that need
|
|
80
|
+
// isolation already set their own paths; tests that did not were sharing the
|
|
81
|
+
// real db, so sharing one throwaway db keeps that behavior without the damage.
|
|
82
|
+
// The choice is exported through the env so children that lose
|
|
83
|
+
// NODE_TEST_CONTEXT still stay fenced.
|
|
84
|
+
function fenceDir(env) {
|
|
85
|
+
if (env.ATRIS_TEST_STATE_DIR) return env.ATRIS_TEST_STATE_DIR;
|
|
86
|
+
let user = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
87
|
+
if (user === null) {
|
|
88
|
+
try { user = os.userInfo().username; } catch (_) { user = 'user'; }
|
|
89
|
+
}
|
|
90
|
+
const dir = path.join(os.tmpdir(), `atris-test-home-${user}`);
|
|
91
|
+
if (env === process.env) process.env.ATRIS_TEST_STATE_DIR = dir;
|
|
92
|
+
return dir;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The folder that holds .atris: the real home normally, a throwaway one when
|
|
96
|
+
// a test run would otherwise reach the real home.
|
|
97
|
+
function stateHome(env = process.env) {
|
|
98
|
+
if (testStateFenced(env)) return fenceDir(env);
|
|
99
|
+
return currentHome(env);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function atrisHome(env = process.env) {
|
|
103
|
+
return path.join(stateHome(env), '.atris');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = { stateHome, atrisHome, testStateFenced };
|