atris 3.43.0 → 3.44.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/skills/design/SKILL.md +7 -1
- package/atris/skills/engines/SKILL.md +44 -13
- package/atris/team/customer-lead/MEMBER.md +45 -0
- package/atris/team/customer-lead/SOUL.md +33 -0
- package/atris/team/customer-lead/START_HERE.md +7 -0
- package/atris/team/customer-lead/skills/customer-commitments/SKILL.md +45 -0
- package/atris/team/improver/MEMBER.md +33 -0
- package/bin/atris.js +36 -3
- package/commands/autoland.js +15 -1
- package/commands/caretaker.js +303 -0
- package/commands/clean.js +76 -0
- package/commands/engine-watch.js +212 -0
- package/commands/engine.js +99 -11
- package/commands/founder.js +304 -0
- package/commands/human-missions.js +844 -0
- package/commands/init.js +16 -7
- package/commands/mission.js +124 -69
- package/commands/slop.js +34 -3
- package/commands/task.js +51 -4
- package/commands/team.js +329 -13
- package/commands/verify.js +99 -6
- package/commands/worktree.js +119 -4
- package/lib/auto-accept-certified.js +302 -0
- package/lib/cloud-mission.js +59 -2
- package/lib/conductor-artifacts.js +1 -1
- package/lib/dispatch-scout.js +383 -0
- package/lib/engine-ask.js +645 -0
- package/lib/engine-job-lifecycle.js +65 -0
- package/lib/engine-receipt-sweep.js +98 -0
- package/lib/engine-registry.js +2 -2
- package/lib/engine-validate.js +374 -0
- package/lib/fleet.js +459 -106
- package/lib/known-commands.js +2 -2
- package/lib/member-alive.js +2 -2
- package/lib/policy-lessons.js +70 -0
- package/lib/receipt-evidence.js +56 -1
- package/lib/runner-command.js +1 -1
- package/lib/secret-gateway.js +588 -0
- package/lib/team-presence.js +13 -1
- package/lib/voice-gate.js +6 -0
- package/lib/wish-audit.js +5 -205
- package/lib/wish-delegate.js +5 -2
- package/package.json +6 -1
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Per-run loopback secret-swap gateway for one-lap. Holds one real credential
|
|
4
|
+
// outside the sandbox, hands the engine a random placeholder plus a loopback
|
|
5
|
+
// base url, and forwards only grant-matched GET/HEAD requests.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const http = require('http');
|
|
9
|
+
const https = require('https');
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const net = require('net');
|
|
12
|
+
const { spawn, spawnSync } = require('child_process');
|
|
13
|
+
|
|
14
|
+
const ALLOWED_METHODS = new Set(['GET', 'HEAD']);
|
|
15
|
+
const STRIP_RESPONSE_HEADERS = new Set([
|
|
16
|
+
'set-cookie',
|
|
17
|
+
'set-cookie2',
|
|
18
|
+
'www-authenticate',
|
|
19
|
+
'authorization',
|
|
20
|
+
'proxy-authenticate',
|
|
21
|
+
'proxy-authorization',
|
|
22
|
+
]);
|
|
23
|
+
const PROXY_ENV_KEYS = [
|
|
24
|
+
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
|
|
25
|
+
'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function createPlaceholder() {
|
|
29
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeGrant(raw) {
|
|
33
|
+
const src = raw && typeof raw === 'object' ? raw : {};
|
|
34
|
+
const id = String(src.id || '').trim();
|
|
35
|
+
const host = String(src.host || '').trim().toLowerCase();
|
|
36
|
+
const secretEnv = String(src.secretEnv || src.secret_env || '').trim();
|
|
37
|
+
const credentialHeader = String(src.credentialHeader || src.credential_header || '').trim().toLowerCase();
|
|
38
|
+
const placeholderEnv = String(src.placeholderEnv || src.placeholder_env || '').trim();
|
|
39
|
+
const baseUrlEnv = String(src.baseUrlEnv || src.base_url_env || '').trim();
|
|
40
|
+
const pathPrefixes = Array.isArray(src.pathPrefixes)
|
|
41
|
+
? src.pathPrefixes
|
|
42
|
+
: (Array.isArray(src.path_prefixes) ? src.path_prefixes : []);
|
|
43
|
+
const methods = Array.isArray(src.methods) ? src.methods : ['GET', 'HEAD'];
|
|
44
|
+
|
|
45
|
+
if (!id) throw new Error('secret grant requires id');
|
|
46
|
+
if (!host || host.includes(':') || host.includes('/') || /[A-Z]/.test(String(src.host || '').trim())) {
|
|
47
|
+
throw new Error('secret grant host must be an exact lowercase hostname');
|
|
48
|
+
}
|
|
49
|
+
if (!secretEnv) throw new Error('secret grant requires secretEnv');
|
|
50
|
+
if (!credentialHeader) throw new Error('secret grant requires credentialHeader');
|
|
51
|
+
if (!placeholderEnv) throw new Error('secret grant requires placeholderEnv');
|
|
52
|
+
if (!baseUrlEnv) throw new Error('secret grant requires baseUrlEnv');
|
|
53
|
+
if (!pathPrefixes.length) throw new Error('secret grant requires pathPrefixes');
|
|
54
|
+
|
|
55
|
+
const normalizedPrefixes = pathPrefixes.map((prefix) => {
|
|
56
|
+
const value = String(prefix || '');
|
|
57
|
+
if (!value.startsWith('/') || value.includes('?') || value.includes('#') || value.includes('\\') || value.includes('//')) {
|
|
58
|
+
throw new Error('secret grant path prefix must be a canonical absolute path');
|
|
59
|
+
}
|
|
60
|
+
if (value.includes('%') || value.includes('.') && /(^|\/)\.\.?(\/|$)/.test(value)) {
|
|
61
|
+
throw new Error('secret grant path prefix must be canonical');
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const normalizedMethods = [...new Set(methods.map((method) => String(method || '').trim().toUpperCase()))];
|
|
67
|
+
if (!normalizedMethods.length || normalizedMethods.some((method) => !ALLOWED_METHODS.has(method))) {
|
|
68
|
+
throw new Error('secret grant methods are limited to GET and HEAD');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
id,
|
|
73
|
+
host,
|
|
74
|
+
secretEnv,
|
|
75
|
+
credentialHeader,
|
|
76
|
+
placeholderEnv,
|
|
77
|
+
baseUrlEnv,
|
|
78
|
+
pathPrefixes: normalizedPrefixes,
|
|
79
|
+
methods: normalizedMethods,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function pathMatchesGrant(pathname, prefixes) {
|
|
84
|
+
for (const prefix of prefixes) {
|
|
85
|
+
if (pathname === prefix) return true;
|
|
86
|
+
const boundary = prefix.endsWith('/') ? prefix : `${prefix}/`;
|
|
87
|
+
if (pathname.startsWith(boundary)) return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function inspectRequestTarget(requestUrl) {
|
|
93
|
+
const raw = String(requestUrl || '');
|
|
94
|
+
if (!raw) return { ok: false, reason: 'empty_target' };
|
|
95
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(raw)) return { ok: false, reason: 'absolute_form' };
|
|
96
|
+
if (raw.includes('\\')) return { ok: false, reason: 'backslash' };
|
|
97
|
+
if (raw.includes('#') || /%23/i.test(raw)) return { ok: false, reason: 'fragment' };
|
|
98
|
+
if (raw.includes('@')) return { ok: false, reason: 'userinfo' };
|
|
99
|
+
|
|
100
|
+
const queryIndex = raw.indexOf('?');
|
|
101
|
+
const pathPart = queryIndex === -1 ? raw : raw.slice(0, queryIndex);
|
|
102
|
+
const search = queryIndex === -1 ? '' : raw.slice(queryIndex);
|
|
103
|
+
if (!pathPart.startsWith('/')) return { ok: false, reason: 'relative_target' };
|
|
104
|
+
if (/%2f/i.test(pathPart) || /%5c/i.test(pathPart)) return { ok: false, reason: 'encoded_separator' };
|
|
105
|
+
if (/%2e/i.test(pathPart)) return { ok: false, reason: 'dot_segment' };
|
|
106
|
+
|
|
107
|
+
let decoded;
|
|
108
|
+
try {
|
|
109
|
+
decoded = decodeURIComponent(pathPart);
|
|
110
|
+
} catch {
|
|
111
|
+
return { ok: false, reason: 'bad_encoding' };
|
|
112
|
+
}
|
|
113
|
+
if (decoded.includes('\0') || decoded.includes('\\') || decoded.includes('#') || decoded.includes('?')) {
|
|
114
|
+
return { ok: false, reason: 'ambiguous_path' };
|
|
115
|
+
}
|
|
116
|
+
if (!decoded.startsWith('/')) return { ok: false, reason: 'relative_target' };
|
|
117
|
+
for (const segment of decoded.split('/')) {
|
|
118
|
+
if (segment === '.' || segment === '..') return { ok: false, reason: 'dot_segment' };
|
|
119
|
+
}
|
|
120
|
+
return { ok: true, pathname: decoded, search };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function headerValue(headers, name) {
|
|
124
|
+
const wanted = String(name || '').toLowerCase();
|
|
125
|
+
for (const [key, value] of Object.entries(headers || {})) {
|
|
126
|
+
if (String(key).toLowerCase() === wanted) {
|
|
127
|
+
return Array.isArray(value) ? String(value[0] || '') : String(value || '');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return '';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function placeholderMatches(provided, expected) {
|
|
134
|
+
const left = Buffer.from(String(provided || ''), 'utf8');
|
|
135
|
+
const right = Buffer.from(String(expected || ''), 'utf8');
|
|
136
|
+
if (left.length !== right.length) {
|
|
137
|
+
const fill = Buffer.alloc(right.length);
|
|
138
|
+
crypto.timingSafeEqual(fill, right);
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return crypto.timingSafeEqual(left, right);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function requestHasBody(req) {
|
|
145
|
+
if (req.headers['transfer-encoding']) return true;
|
|
146
|
+
const length = req.headers['content-length'];
|
|
147
|
+
if (length === undefined) return false;
|
|
148
|
+
const n = Number(length);
|
|
149
|
+
return !Number.isFinite(n) || n > 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parseGatewaySession(raw) {
|
|
153
|
+
const parsed = typeof raw === 'string' ? JSON.parse(raw) : (raw || {});
|
|
154
|
+
const grant = normalizeGrant(parsed.grant);
|
|
155
|
+
const placeholder = String(parsed.placeholder || '');
|
|
156
|
+
const secret = String(parsed.secret || '');
|
|
157
|
+
if (!placeholder) throw new Error('gateway session requires placeholder');
|
|
158
|
+
if (!secret) throw new Error('gateway session requires secret');
|
|
159
|
+
return {
|
|
160
|
+
grant,
|
|
161
|
+
placeholder,
|
|
162
|
+
secret,
|
|
163
|
+
upstreamPort: Number.isInteger(parsed.upstreamPort) ? parsed.upstreamPort : 443,
|
|
164
|
+
upstreamAddress: parsed.upstreamAddress ? String(parsed.upstreamAddress) : grant.host,
|
|
165
|
+
rejectUnauthorized: parsed.rejectUnauthorized !== false,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function applySecretGrantEnvironment(environment, grantInput, options = {}) {
|
|
170
|
+
const grant = normalizeGrant(grantInput);
|
|
171
|
+
const placeholder = options.placeholder || createPlaceholder();
|
|
172
|
+
environment[grant.placeholderEnv] = placeholder;
|
|
173
|
+
for (const key of PROXY_ENV_KEYS) {
|
|
174
|
+
if (key.toUpperCase() === 'NO_PROXY' || key === 'no_proxy') environment[key] = '127.0.0.1,localhost';
|
|
175
|
+
else environment[key] = '';
|
|
176
|
+
}
|
|
177
|
+
const plan = {
|
|
178
|
+
grant,
|
|
179
|
+
placeholder,
|
|
180
|
+
};
|
|
181
|
+
if (options.upstreamPort !== undefined) plan.upstreamPort = options.upstreamPort;
|
|
182
|
+
if (options.upstreamAddress !== undefined) plan.upstreamAddress = options.upstreamAddress;
|
|
183
|
+
if (options.rejectUnauthorized !== undefined) plan.rejectUnauthorized = options.rejectUnauthorized;
|
|
184
|
+
environment.ATRIS_ONE_LAP_SECRET_GATEWAY = JSON.stringify(plan);
|
|
185
|
+
return { grant, placeholder, plan };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function childEnvironmentWithGateway(baseEnv, session, gateway) {
|
|
189
|
+
const env = { ...baseEnv };
|
|
190
|
+
delete env.ATRIS_ONE_LAP_SECRET_GATEWAY;
|
|
191
|
+
delete env.ATRIS_ONE_LAP_SECRET_GATEWAY_STDIN;
|
|
192
|
+
if (session.grant.secretEnv !== session.grant.placeholderEnv) {
|
|
193
|
+
delete env[session.grant.secretEnv];
|
|
194
|
+
}
|
|
195
|
+
env[session.grant.placeholderEnv] = session.placeholder;
|
|
196
|
+
env[session.grant.baseUrlEnv] = gateway.baseUrl;
|
|
197
|
+
return env;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function recordReceipt(receipts, entry) {
|
|
201
|
+
receipts.push({
|
|
202
|
+
grant_id: entry.grant_id,
|
|
203
|
+
decision: entry.decision,
|
|
204
|
+
method: entry.method,
|
|
205
|
+
host: entry.host,
|
|
206
|
+
path: entry.path,
|
|
207
|
+
upstream_status: entry.upstream_status == null ? null : entry.upstream_status,
|
|
208
|
+
request_bytes: entry.request_bytes || 0,
|
|
209
|
+
response_bytes: entry.response_bytes || 0,
|
|
210
|
+
reason: entry.reason || null,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function rejectClient(res, receipts, meta, status, reason) {
|
|
215
|
+
recordReceipt(receipts, {
|
|
216
|
+
grant_id: meta.grant_id,
|
|
217
|
+
decision: 'deny',
|
|
218
|
+
method: meta.method,
|
|
219
|
+
host: meta.host,
|
|
220
|
+
path: meta.path || '',
|
|
221
|
+
reason,
|
|
222
|
+
});
|
|
223
|
+
res.statusCode = status;
|
|
224
|
+
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
225
|
+
res.end('gateway denied\n');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function hostHeaderAllowed(hostHeader, listenPort) {
|
|
229
|
+
const raw = String(hostHeader || '').trim().toLowerCase();
|
|
230
|
+
if (!raw) return false;
|
|
231
|
+
const allowed = new Set([
|
|
232
|
+
'127.0.0.1',
|
|
233
|
+
`127.0.0.1:${listenPort}`,
|
|
234
|
+
'localhost',
|
|
235
|
+
`localhost:${listenPort}`,
|
|
236
|
+
]);
|
|
237
|
+
return allowed.has(raw);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function startSecretGateway(sessionInput) {
|
|
241
|
+
const session = parseGatewaySession(sessionInput);
|
|
242
|
+
const receipts = [];
|
|
243
|
+
let listening = false;
|
|
244
|
+
let listenPort = 0;
|
|
245
|
+
|
|
246
|
+
const server = http.createServer((req, res) => {
|
|
247
|
+
const method = String(req.method || '').toUpperCase();
|
|
248
|
+
const meta = {
|
|
249
|
+
grant_id: session.grant.id,
|
|
250
|
+
method,
|
|
251
|
+
host: session.grant.host,
|
|
252
|
+
path: '',
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
if (!session.grant.methods.includes(method) || !ALLOWED_METHODS.has(method)) {
|
|
256
|
+
rejectClient(res, receipts, meta, 405, 'method');
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (requestHasBody(req)) {
|
|
260
|
+
rejectClient(res, receipts, meta, 400, 'body');
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (!hostHeaderAllowed(headerValue(req.headers, 'host'), listenPort)) {
|
|
264
|
+
rejectClient(res, receipts, meta, 400, 'host_override');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const target = inspectRequestTarget(req.url);
|
|
269
|
+
if (!target.ok) {
|
|
270
|
+
rejectClient(res, receipts, meta, 400, target.reason);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
meta.path = target.pathname;
|
|
274
|
+
if (!pathMatchesGrant(target.pathname, session.grant.pathPrefixes)) {
|
|
275
|
+
rejectClient(res, receipts, meta, 403, 'path');
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const provided = headerValue(req.headers, session.grant.credentialHeader);
|
|
280
|
+
if (!placeholderMatches(provided, session.placeholder)) {
|
|
281
|
+
rejectClient(res, receipts, meta, 401, 'placeholder');
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const requestHeaders = {};
|
|
286
|
+
for (const [key, value] of Object.entries(req.headers || {})) {
|
|
287
|
+
const lower = String(key).toLowerCase();
|
|
288
|
+
if (lower === 'host' || lower === 'connection' || lower === session.grant.credentialHeader) continue;
|
|
289
|
+
if (lower === 'content-length' || lower === 'transfer-encoding') continue;
|
|
290
|
+
requestHeaders[key] = value;
|
|
291
|
+
}
|
|
292
|
+
requestHeaders.Host = session.grant.host;
|
|
293
|
+
requestHeaders[session.grant.credentialHeader] = session.secret;
|
|
294
|
+
|
|
295
|
+
const upstreamReq = https.request({
|
|
296
|
+
protocol: 'https:',
|
|
297
|
+
hostname: session.upstreamAddress,
|
|
298
|
+
port: session.upstreamPort,
|
|
299
|
+
servername: session.grant.host,
|
|
300
|
+
path: `${target.pathname}${target.search}`,
|
|
301
|
+
method,
|
|
302
|
+
headers: requestHeaders,
|
|
303
|
+
rejectUnauthorized: session.rejectUnauthorized,
|
|
304
|
+
}, (upstreamRes) => {
|
|
305
|
+
const status = Number(upstreamRes.statusCode || 0);
|
|
306
|
+
if (status >= 300 && status < 400) {
|
|
307
|
+
upstreamRes.resume();
|
|
308
|
+
recordReceipt(receipts, {
|
|
309
|
+
grant_id: session.grant.id,
|
|
310
|
+
decision: 'deny',
|
|
311
|
+
method,
|
|
312
|
+
host: session.grant.host,
|
|
313
|
+
path: target.pathname,
|
|
314
|
+
upstream_status: status,
|
|
315
|
+
reason: 'redirect',
|
|
316
|
+
});
|
|
317
|
+
res.statusCode = 502;
|
|
318
|
+
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
319
|
+
res.end('gateway denied redirect\n');
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let responseBytes = 0;
|
|
324
|
+
res.statusCode = status || 502;
|
|
325
|
+
for (const [key, value] of Object.entries(upstreamRes.headers || {})) {
|
|
326
|
+
const lower = String(key).toLowerCase();
|
|
327
|
+
if (STRIP_RESPONSE_HEADERS.has(lower)) continue;
|
|
328
|
+
if (lower === 'connection' || lower === 'transfer-encoding') continue;
|
|
329
|
+
if (value !== undefined) res.setHeader(key, value);
|
|
330
|
+
}
|
|
331
|
+
upstreamRes.on('data', (chunk) => {
|
|
332
|
+
responseBytes += chunk.length;
|
|
333
|
+
res.write(chunk);
|
|
334
|
+
});
|
|
335
|
+
upstreamRes.on('end', () => {
|
|
336
|
+
recordReceipt(receipts, {
|
|
337
|
+
grant_id: session.grant.id,
|
|
338
|
+
decision: 'allow',
|
|
339
|
+
method,
|
|
340
|
+
host: session.grant.host,
|
|
341
|
+
path: target.pathname,
|
|
342
|
+
upstream_status: status,
|
|
343
|
+
response_bytes: responseBytes,
|
|
344
|
+
});
|
|
345
|
+
res.end();
|
|
346
|
+
});
|
|
347
|
+
upstreamRes.on('error', () => {
|
|
348
|
+
if (!res.headersSent) {
|
|
349
|
+
rejectClient(res, receipts, meta, 502, 'upstream_error');
|
|
350
|
+
} else {
|
|
351
|
+
res.destroy();
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
upstreamReq.on('error', () => {
|
|
357
|
+
rejectClient(res, receipts, meta, 502, 'upstream_connect');
|
|
358
|
+
});
|
|
359
|
+
upstreamReq.end();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
return new Promise((resolve, reject) => {
|
|
363
|
+
server.once('error', reject);
|
|
364
|
+
server.listen(0, '127.0.0.1', () => {
|
|
365
|
+
listening = true;
|
|
366
|
+
const address = server.address();
|
|
367
|
+
listenPort = address && address.port;
|
|
368
|
+
resolve({
|
|
369
|
+
port: listenPort,
|
|
370
|
+
baseUrl: `http://127.0.0.1:${listenPort}`,
|
|
371
|
+
receipts,
|
|
372
|
+
isListening: () => listening && server.listening,
|
|
373
|
+
close: () => new Promise((closeResolve, closeReject) => {
|
|
374
|
+
if (!server.listening) {
|
|
375
|
+
listening = false;
|
|
376
|
+
closeResolve();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
server.close((error) => {
|
|
380
|
+
listening = false;
|
|
381
|
+
if (error) closeReject(error);
|
|
382
|
+
else closeResolve();
|
|
383
|
+
});
|
|
384
|
+
}),
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function buildGatewaySupervisorScript(gatewayModulePath) {
|
|
391
|
+
return [
|
|
392
|
+
"'use strict';",
|
|
393
|
+
"const fs = require('node:fs');",
|
|
394
|
+
"const { spawn, spawnSync } = require('node:child_process');",
|
|
395
|
+
`const sg = require(${JSON.stringify(gatewayModulePath)});`,
|
|
396
|
+
"const [exitFile, stateFile, leaseFile, statusFile, executable, ...args] = process.argv.slice(2);",
|
|
397
|
+
"const leaseFd = fs.openSync(leaseFile, 'w', 0o600);",
|
|
398
|
+
"const statusFd = fs.openSync(statusFile, 'w', 0o600);",
|
|
399
|
+
'function writeExit(code) {',
|
|
400
|
+
' const exitCode = Number.isInteger(code) ? code : 128;',
|
|
401
|
+
" try { fs.writeFileSync(exitFile, String(exitCode) + '\\n', { mode: 0o600 }); } catch {}",
|
|
402
|
+
' process.exit(exitCode);',
|
|
403
|
+
'}',
|
|
404
|
+
'',
|
|
405
|
+
'async function main() {',
|
|
406
|
+
" const session = sg.parseGatewaySession(fs.readFileSync(0, 'utf8'));",
|
|
407
|
+
' const gateway = await sg.startSecretGateway(session);',
|
|
408
|
+
' try { process.stdin.pause(); process.stdin.destroy(); } catch {}',
|
|
409
|
+
' const childEnv = sg.childEnvironmentWithGateway(process.env, session, gateway);',
|
|
410
|
+
" const child = spawn(executable, args, { cwd: process.cwd(), env: childEnv, detached: true, stdio: ['ignore', 'inherit', 'inherit', leaseFd, statusFd] });",
|
|
411
|
+
' fs.closeSync(leaseFd);',
|
|
412
|
+
' fs.closeSync(statusFd);',
|
|
413
|
+
" fs.writeFileSync(stateFile, JSON.stringify({ pgid: child.pid, cwd: process.cwd() }) + '\\n', { mode: 0o600 });",
|
|
414
|
+
' let stopping = false;',
|
|
415
|
+
' const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));',
|
|
416
|
+
' function trackedPids() {',
|
|
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
|
+
' }',
|
|
428
|
+
' async function stop(code) {',
|
|
429
|
+
' if (stopping) return;',
|
|
430
|
+
' stopping = true;',
|
|
431
|
+
" if (executable === '/usr/bin/sandbox-exec' && args[0] === '-p' && args[1]) {",
|
|
432
|
+
" spawnSync(executable, ['-p', args[1], '/bin/kill', '-KILL', '-1'], { cwd: process.cwd(), env: process.env, stdio: 'ignore', timeout: 5000 });",
|
|
433
|
+
' }',
|
|
434
|
+
" for (const [signal, delay] of [['SIGTERM', 100], ['SIGKILL', 100], ['SIGKILL', 100]]) {",
|
|
435
|
+
' try { process.kill(-child.pid, signal); } catch {}',
|
|
436
|
+
' for (const pid of trackedPids()) { try { process.kill(pid, signal); } catch {} }',
|
|
437
|
+
' await wait(delay);',
|
|
438
|
+
' }',
|
|
439
|
+
' try { await gateway.close(); } catch {}',
|
|
440
|
+
' let exitCode = Number.isInteger(code) ? code : 128;',
|
|
441
|
+
' try {',
|
|
442
|
+
" const savedText = fs.readFileSync(statusFile, 'utf8').trim();",
|
|
443
|
+
' const saved = Number(savedText);',
|
|
444
|
+
' if (savedText && Number.isInteger(saved) && saved >= 0 && saved <= 255) exitCode = saved;',
|
|
445
|
+
' } catch {}',
|
|
446
|
+
' writeExit(exitCode);',
|
|
447
|
+
' }',
|
|
448
|
+
" child.once('error', () => { void stop(1); });",
|
|
449
|
+
" child.once('exit', (code) => { void stop(code); });",
|
|
450
|
+
" for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => { void stop(143); });",
|
|
451
|
+
'}',
|
|
452
|
+
'',
|
|
453
|
+
'main().catch(() => { writeExit(1); });',
|
|
454
|
+
'',
|
|
455
|
+
].join('\n');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function probeListening(port) {
|
|
459
|
+
return new Promise((resolve) => {
|
|
460
|
+
const socket = net.connect({ host: '127.0.0.1', port }, () => {
|
|
461
|
+
socket.end();
|
|
462
|
+
resolve(true);
|
|
463
|
+
});
|
|
464
|
+
socket.on('error', () => resolve(false));
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Spawn the gateway supervisor without spawnSync. Some agent sandboxes deny
|
|
469
|
+
// network to every spawnSync descendant, while a normal spawn child can open
|
|
470
|
+
// HTTPS. The waiter must also keep this event loop alive: a sync sleep loop
|
|
471
|
+
// parks the parent and the sandbox then blocks child TLS too. Poll the exit
|
|
472
|
+
// file on a timer, and yield with spawnSync('/bin/sleep', ['0.05']) inside
|
|
473
|
+
// each tick only as a short OS pause after the timer has already fired.
|
|
474
|
+
function spawnBlocking(command, args, options = {}) {
|
|
475
|
+
const exitFile = options.exitFile;
|
|
476
|
+
if (!exitFile) throw new Error('spawnBlocking requires exitFile');
|
|
477
|
+
try { fs.unlinkSync(exitFile); } catch {}
|
|
478
|
+
|
|
479
|
+
// The session may carry the real secret: it must reach the supervisor only
|
|
480
|
+
// through the stdin pipe, never a temp file on disk.
|
|
481
|
+
const child = spawn(command, args, {
|
|
482
|
+
cwd: options.cwd,
|
|
483
|
+
env: options.env,
|
|
484
|
+
stdio: [options.input != null ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
485
|
+
});
|
|
486
|
+
if (options.input != null && child.stdin) {
|
|
487
|
+
child.stdin.on('error', () => {});
|
|
488
|
+
child.stdin.end(String(options.input));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const stdoutChunks = [];
|
|
492
|
+
const stderrChunks = [];
|
|
493
|
+
let spawnError = null;
|
|
494
|
+
if (child.stdout) child.stdout.on('data', (chunk) => {
|
|
495
|
+
stdoutChunks.push(Buffer.from(chunk));
|
|
496
|
+
if (typeof options.onStdoutChunk === 'function') options.onStdoutChunk(chunk);
|
|
497
|
+
});
|
|
498
|
+
if (child.stderr) child.stderr.on('data', (chunk) => {
|
|
499
|
+
stderrChunks.push(Buffer.from(chunk));
|
|
500
|
+
if (typeof options.onStderrChunk === 'function') options.onStderrChunk(chunk);
|
|
501
|
+
});
|
|
502
|
+
child.on('error', (error) => { spawnError = error; });
|
|
503
|
+
|
|
504
|
+
const encode = (chunks) => {
|
|
505
|
+
const buf = Buffer.concat(chunks);
|
|
506
|
+
if (options.encoding) return buf.toString(options.encoding);
|
|
507
|
+
return buf;
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const started = Date.now();
|
|
511
|
+
const timeout = Number(options.timeout) || 0;
|
|
512
|
+
|
|
513
|
+
return new Promise((resolve) => {
|
|
514
|
+
let settled = false;
|
|
515
|
+
const finish = (result) => {
|
|
516
|
+
if (settled) return;
|
|
517
|
+
settled = true;
|
|
518
|
+
clearInterval(timer);
|
|
519
|
+
resolve(result);
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
const timer = setInterval(() => {
|
|
523
|
+
// Short OS pause after the timer fires; do not replace the timer with a
|
|
524
|
+
// sync sleep loop or child HTTPS stalls in agent sandboxes.
|
|
525
|
+
spawnSync('/bin/sleep', ['0.05'], { stdio: 'ignore' });
|
|
526
|
+
|
|
527
|
+
if (spawnError) {
|
|
528
|
+
finish({
|
|
529
|
+
pid: child.pid,
|
|
530
|
+
status: null,
|
|
531
|
+
signal: null,
|
|
532
|
+
stdout: encode(stdoutChunks),
|
|
533
|
+
stderr: encode(stderrChunks),
|
|
534
|
+
error: spawnError,
|
|
535
|
+
});
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (fs.existsSync(exitFile)) {
|
|
539
|
+
let status = 0;
|
|
540
|
+
try {
|
|
541
|
+
const raw = fs.readFileSync(exitFile, 'utf8').trim();
|
|
542
|
+
const parsed = Number(raw);
|
|
543
|
+
if (Number.isInteger(parsed)) status = parsed;
|
|
544
|
+
} catch {}
|
|
545
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
546
|
+
finish({
|
|
547
|
+
pid: child.pid,
|
|
548
|
+
status,
|
|
549
|
+
signal: null,
|
|
550
|
+
stdout: encode(stdoutChunks),
|
|
551
|
+
stderr: encode(stderrChunks),
|
|
552
|
+
error: null,
|
|
553
|
+
});
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (timeout && Date.now() - started > timeout) {
|
|
557
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
558
|
+
const error = new Error('spawnBlocking ETIMEDOUT');
|
|
559
|
+
error.code = 'ETIMEDOUT';
|
|
560
|
+
error.errno = -60;
|
|
561
|
+
finish({
|
|
562
|
+
pid: child.pid,
|
|
563
|
+
status: null,
|
|
564
|
+
signal: 'SIGKILL',
|
|
565
|
+
stdout: encode(stdoutChunks),
|
|
566
|
+
stderr: encode(stderrChunks),
|
|
567
|
+
error,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
}, 50);
|
|
571
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
module.exports = {
|
|
576
|
+
createPlaceholder,
|
|
577
|
+
normalizeGrant,
|
|
578
|
+
pathMatchesGrant,
|
|
579
|
+
inspectRequestTarget,
|
|
580
|
+
parseGatewaySession,
|
|
581
|
+
applySecretGrantEnvironment,
|
|
582
|
+
childEnvironmentWithGateway,
|
|
583
|
+
startSecretGateway,
|
|
584
|
+
buildGatewaySupervisorScript,
|
|
585
|
+
probeListening,
|
|
586
|
+
spawnBlocking,
|
|
587
|
+
PROXY_ENV_KEYS,
|
|
588
|
+
};
|
package/lib/team-presence.js
CHANGED
|
@@ -193,7 +193,7 @@ function buildTeamPresence(input = {}) {
|
|
|
193
193
|
for (const rows of missionsByMember.values()) rows.sort((a, b) => activityOrder(a, b, missionActivityMs));
|
|
194
194
|
|
|
195
195
|
const cutoffMs = nowMs - freshnessWindowMs;
|
|
196
|
-
const
|
|
196
|
+
const activePeople = [...candidates.entries()]
|
|
197
197
|
.sort((a, b) => a[1].localeCompare(b[1]))
|
|
198
198
|
.flatMap(([key, name]) => {
|
|
199
199
|
const seenMs = lastSeen.get(key) || 0;
|
|
@@ -212,6 +212,9 @@ function buildTeamPresence(input = {}) {
|
|
|
212
212
|
last_seen: new Date(seenMs).toISOString(),
|
|
213
213
|
}];
|
|
214
214
|
});
|
|
215
|
+
const operatorKey = keyFor(input.operator || 'operator');
|
|
216
|
+
const operator = activePeople.find((person) => keyFor(person.name) === operatorKey) || null;
|
|
217
|
+
const members = activePeople.filter((person) => keyFor(person.name) !== operatorKey);
|
|
215
218
|
|
|
216
219
|
return {
|
|
217
220
|
schema: 'atris.team_presence.v1',
|
|
@@ -222,6 +225,7 @@ function buildTeamPresence(input = {}) {
|
|
|
222
225
|
waiting_operator: Math.max(0, Number(stream.waiting_operator) || 0),
|
|
223
226
|
landing_wait: Math.max(0, Number(stream.landing_wait) || 0),
|
|
224
227
|
},
|
|
228
|
+
operator,
|
|
225
229
|
members,
|
|
226
230
|
};
|
|
227
231
|
}
|
|
@@ -235,6 +239,14 @@ function renderTeamPresence(presence) {
|
|
|
235
239
|
`waiting on operator: ${presence.totals.waiting_operator}`,
|
|
236
240
|
`landing wait: ${presence.totals.landing_wait}`,
|
|
237
241
|
];
|
|
242
|
+
if (presence.operator) {
|
|
243
|
+
lines.push('operator:');
|
|
244
|
+
lines.push(` ${presence.operator.name}: ${presence.operator.doing}`);
|
|
245
|
+
if (presence.operator.loop) {
|
|
246
|
+
lines.push(` loop: ${presence.operator.loop.mission} [${presence.operator.loop.cadence} | ${presence.operator.loop.runner} | last tick ${presence.operator.loop.last_tick}]`);
|
|
247
|
+
}
|
|
248
|
+
lines.push(` last seen: ${presence.operator.last_seen}`);
|
|
249
|
+
}
|
|
238
250
|
if (!presence.members.length) {
|
|
239
251
|
lines.push('awake roster: empty');
|
|
240
252
|
return lines.join('\n');
|
package/lib/voice-gate.js
CHANGED
|
@@ -226,11 +226,16 @@ const LANDING_REASON_SENTENCES = {
|
|
|
226
226
|
autoland_policy_off: 'self-landing is off, so everything waits for you',
|
|
227
227
|
certified_independent_review: 'an independent reviewer certified it, so it can land',
|
|
228
228
|
certified_strict_verify: 'it is certified and its recorded check passed, so it can land',
|
|
229
|
+
candidate_scope_unknown: 'the changed files were not recorded clearly, so taste checks stay advisory for this item',
|
|
229
230
|
dead_exports: 'the change leaves behind code nothing calls, delete what the hygiene check names and re-certify',
|
|
230
231
|
declared_protected_lane: 'it touches a protected lane, so it waits for your decision',
|
|
231
232
|
forced_completion_needs_human: 'it was pushed to done without proof, so only a human can accept it',
|
|
232
233
|
insufficient_review_passes: 'it has not been reviewed enough times yet, one more pass unblocks it',
|
|
233
234
|
judge_equals_worker: 'built and judged by the same actor, hand the review to someone else',
|
|
235
|
+
lesson_gate: 'the touched work breaks a detector-backed lesson, fix that named lesson and re-certify',
|
|
236
|
+
lesson_detector_pending: 'a matching lesson check still needs to run before the final landing decision',
|
|
237
|
+
lesson_detector_unrunnable: 'a matching lesson check could not start, repair the check before relying on it',
|
|
238
|
+
lesson_has_no_detector: 'a matching written lesson has no runnable check, so it remains advice only',
|
|
234
239
|
mission_xp_requires_end_to_end_receipt: 'the proof does not show the whole flow working start to finish, attach that receipt or move it back to do',
|
|
235
240
|
needs_independent_reviewer: 'built and judged by the same actor, a review from someone else unblocks it',
|
|
236
241
|
needs_second_actor_review: 'the same actor built and reviewed it, someone else has to look before it lands',
|
|
@@ -243,6 +248,7 @@ const LANDING_REASON_SENTENCES = {
|
|
|
243
248
|
proof_required: 'no proof was given, say what was run and what it showed',
|
|
244
249
|
proof_unmerged_or_draft_pr_boundary: 'its proof points at an unmerged draft, merge it or prove the work another way',
|
|
245
250
|
receipt_verifier_failed: 'a saved receipt failed its re-check, fix what it names and re-certify',
|
|
251
|
+
slop_gate: 'the touched prose breaks a deterministic taste rule, fix the named lines and re-certify',
|
|
246
252
|
strict_verify_missing: 'no recorded check command to re-run, add one and re-certify',
|
|
247
253
|
untagged_protected_lane_text: 'the description reads like protected-lane work without the tag, tag it or decide it yourself',
|
|
248
254
|
verification_pending: 'the recorded check has not been re-run yet, the next hourly pass runs it',
|