@nonbot/cli 0.9.12 → 0.10.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/CHANGELOG.md +247 -1
- package/dist/commands/choir.js +3 -0
- package/dist/commands/daemon.js +377 -9
- package/dist/commands/logs.js +6 -1
- package/dist/index.js +1 -1
- package/dist/lib/activations.js +46 -6
- package/dist/lib/activity-log.js +6 -0
- package/dist/lib/choir/hub.js +116 -18
- package/dist/lib/cloud-repo.js +306 -0
- package/dist/lib/command-builders.js +32 -1
- package/dist/lib/completion.js +125 -1
- package/dist/lib/daemon-lifecycle.js +44 -0
- package/dist/lib/exit-transcript.js +74 -0
- package/dist/lib/machine.js +7 -0
- package/dist/lib/payload-validator.js +51 -0
- package/dist/lib/run-prompt.js +38 -17
- package/dist/lib/snapshot.js +14 -12
- package/dist/lib/terminal.js +10 -4
- package/dist/lib/update-check.js +99 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/lib/activations.js
CHANGED
|
@@ -7,8 +7,9 @@ import { resolveTerminal } from './terminal.js';
|
|
|
7
7
|
import { getActiveProfile } from './auth.js';
|
|
8
8
|
import { appendActivityLog } from './activity-log.js';
|
|
9
9
|
import { activationCard, clockTime } from './output.js';
|
|
10
|
+
import { scrubLine } from './snapshot.js';
|
|
10
11
|
import { buildCommandFromParams, hookSettingsPathFor, shellQuoteSingle, } from './command-builders.js';
|
|
11
|
-
import { validatePayload, validateActivationId, validateRepoPath, ValidationError, extractTerminalTheme, } from './payload-validator.js';
|
|
12
|
+
import { validatePayload, validateActivationId, validateRepoPath, validateRepoUrl, validateRepoRef, ValidationError, extractTerminalTheme, } from './payload-validator.js';
|
|
12
13
|
const PANE_ID_RE = /^%\d+$/;
|
|
13
14
|
export const BUILT_COMMAND_MAX_LENGTH = 32 * 1024;
|
|
14
15
|
export const WIRE_COMMAND_MAX_LENGTH = 4096;
|
|
@@ -56,7 +57,8 @@ export function resolveExecutableCommand(act, warn = (s) => console.warn(s), opt
|
|
|
56
57
|
if (shell.length > BUILT_COMMAND_MAX_LENGTH) {
|
|
57
58
|
throw new Error(`[${act.id}] built command is too large (${shell.length} chars, cap ${BUILT_COMMAND_MAX_LENGTH}). This indicates an oversized AGENTS.md or a builder bug.`);
|
|
58
59
|
}
|
|
59
|
-
|
|
60
|
+
const driftBase = params.template === 'real' ? buildCommandFromParams({ ...params, wallClock: false }) : shell;
|
|
61
|
+
if (typeof act.command === 'string' && act.command.length > 0 && act.command !== driftBase) {
|
|
60
62
|
warn(`[${act.id}] note: daemon-built command differs from server-supplied command (executing daemon-built; server text is informational).\n`);
|
|
61
63
|
}
|
|
62
64
|
if (opts.injectClaudeHook && params.template === 'real' && params.provider === 'claude') {
|
|
@@ -89,6 +91,16 @@ export function validateActivationEnvelope(act) {
|
|
|
89
91
|
throw e;
|
|
90
92
|
}
|
|
91
93
|
}
|
|
94
|
+
try {
|
|
95
|
+
validateRepoUrl(act.repoUrl);
|
|
96
|
+
validateRepoRef(act.repoRef);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
if (e instanceof ValidationError) {
|
|
100
|
+
throw new Error(`[${act.id}] outer envelope validation failed (act.${e.field}): ${e.reason}`);
|
|
101
|
+
}
|
|
102
|
+
throw e;
|
|
103
|
+
}
|
|
92
104
|
if (act.payload &&
|
|
93
105
|
typeof act.payload === 'object' &&
|
|
94
106
|
'repoPath' in act.payload) {
|
|
@@ -122,6 +134,23 @@ function truncate(s, max) {
|
|
|
122
134
|
return s;
|
|
123
135
|
return s.slice(0, max - 32) + `\n…[truncated to ${max}B]`;
|
|
124
136
|
}
|
|
137
|
+
const CHILD_ENV_ALLOW = new Set([
|
|
138
|
+
'PATH', 'HOME', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'USER', 'LOGNAME', 'TMPDIR',
|
|
139
|
+
'TMUX', 'TMUX_PANE', 'NODE_ENV', 'NONBOT_PAT', 'NONBOT_BASE_URL', 'NONBOT_RUN_ID',
|
|
140
|
+
'NONBOT_ROLE', 'NONBOT_CONFIG_DIR', 'CLAUDE_CODE_OAUTH_TOKEN',
|
|
141
|
+
'NONBOT_RUN_TIMEOUT_MIN',
|
|
142
|
+
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
|
|
143
|
+
]);
|
|
144
|
+
export function buildChildEnv(base, opts) {
|
|
145
|
+
const out = {};
|
|
146
|
+
for (const [k, v] of Object.entries(base)) {
|
|
147
|
+
if (CHILD_ENV_ALLOW.has(k))
|
|
148
|
+
out[k] = v;
|
|
149
|
+
else if (opts.byok && (k === 'ANTHROPIC_API_KEY' || k === 'ANTHROPIC_AUTH_TOKEN'))
|
|
150
|
+
out[k] = v;
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
125
154
|
export function makeHeadlessSpawner(opts) {
|
|
126
155
|
return (act) => new Promise((resolve, reject) => {
|
|
127
156
|
try {
|
|
@@ -140,8 +169,12 @@ export function makeHeadlessSpawner(opts) {
|
|
|
140
169
|
return;
|
|
141
170
|
}
|
|
142
171
|
const cwd = act.repoPath || undefined;
|
|
172
|
+
const childEnv = buildChildEnv(process.env, {
|
|
173
|
+
byok: !process.env.CLAUDE_CODE_OAUTH_TOKEN &&
|
|
174
|
+
(!!process.env.ANTHROPIC_API_KEY || !!process.env.ANTHROPIC_AUTH_TOKEN),
|
|
175
|
+
});
|
|
143
176
|
if (opts.wait) {
|
|
144
|
-
const proc = spawn('bash', ['-c', resolved.shell], { cwd, stdio: 'inherit' });
|
|
177
|
+
const proc = spawn('bash', ['-c', resolved.shell], { cwd, env: childEnv, stdio: 'inherit' });
|
|
145
178
|
proc.on('error', (e) => {
|
|
146
179
|
const err = e;
|
|
147
180
|
reject(new Error(`failed to run headless: ${err.message}`));
|
|
@@ -151,13 +184,14 @@ export function makeHeadlessSpawner(opts) {
|
|
|
151
184
|
}
|
|
152
185
|
const proc = spawn('bash', ['-c', resolved.shell], {
|
|
153
186
|
cwd,
|
|
187
|
+
env: childEnv,
|
|
154
188
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
155
189
|
});
|
|
156
190
|
const prefixLines = (chunk, sink) => {
|
|
157
191
|
const text = chunk.toString('utf-8');
|
|
158
192
|
for (const line of text.split('\n')) {
|
|
159
193
|
if (line.length > 0)
|
|
160
|
-
sink(`[${act.id}] ${line}\n`);
|
|
194
|
+
sink(`[${act.id}] ${scrubLine(line)}\n`);
|
|
161
195
|
}
|
|
162
196
|
};
|
|
163
197
|
proc.stdout?.on('data', (c) => prefixLines(c, opts.log));
|
|
@@ -207,7 +241,11 @@ async function captureSpawn(cmd, args) {
|
|
|
207
241
|
});
|
|
208
242
|
}
|
|
209
243
|
export const _captureSpawnForTests = captureSpawn;
|
|
210
|
-
export
|
|
244
|
+
export function exitFilePathFor(activationId) {
|
|
245
|
+
const ext = process.platform === 'darwin' ? 'command' : 'sh';
|
|
246
|
+
return path.join(tmpdir(), `nonbot-${activationId}.${ext}.exit`);
|
|
247
|
+
}
|
|
248
|
+
export async function spawnTerminalDefault(act, auth, opts) {
|
|
211
249
|
validateActivationEnvelope(act);
|
|
212
250
|
const resolved = resolveExecutableCommand(act, undefined, { injectClaudeHook: true });
|
|
213
251
|
const ext = process.platform === 'darwin' ? 'command' : 'sh';
|
|
@@ -253,7 +291,9 @@ export async function spawnTerminalDefault(act, auth) {
|
|
|
253
291
|
`Switch to iTerm for theme support. Launching un-themed.`);
|
|
254
292
|
}
|
|
255
293
|
}
|
|
256
|
-
const
|
|
294
|
+
const cloudExitFile = opts?.cloud && profile.id === 'tmux' ? exitFilePathFor(act.id) : undefined;
|
|
295
|
+
const launchOpts = itermProfileName || cloudExitFile ? { itermProfileName, cloudExitFile } : undefined;
|
|
296
|
+
const { cmd, args } = profile.launch(scriptPath, launchOpts);
|
|
257
297
|
const result = await captureSpawn(cmd, args);
|
|
258
298
|
const unlinkBest = async () => {
|
|
259
299
|
try {
|
package/dist/lib/activity-log.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
export function isExitTranscriptEntry(e) {
|
|
5
|
+
return (!!e &&
|
|
6
|
+
typeof e === 'object' &&
|
|
7
|
+
e.kind === 'exit-transcript' &&
|
|
8
|
+
typeof e.tail === 'string');
|
|
9
|
+
}
|
|
4
10
|
function configDir() {
|
|
5
11
|
const override = process.env.NONBOT_CONFIG_DIR;
|
|
6
12
|
if (override && override.length > 0)
|
package/dist/lib/choir/hub.js
CHANGED
|
@@ -9,6 +9,41 @@ import { makeEvent, sanitizeForEgress, assertNoForbiddenFields } from './progres
|
|
|
9
9
|
import { LOCAL_TEXT_MAX, SUMMARY_MAX, } from './types.js';
|
|
10
10
|
const DEFAULT_POLL_MS = 5_000;
|
|
11
11
|
const DEFAULT_HEARTBEAT_MS = 30_000;
|
|
12
|
+
const EGRESS_CHUNK = 25;
|
|
13
|
+
const CLIENT_MINTED_SESSION_PREFIX = 'choir_';
|
|
14
|
+
export function isClientMintedSessionId(sessionId) {
|
|
15
|
+
return typeof sessionId === 'string' && sessionId.startsWith(CLIENT_MINTED_SESSION_PREFIX);
|
|
16
|
+
}
|
|
17
|
+
export const EGRESS_RETRY_MAX = 200;
|
|
18
|
+
const EGRESS_RETRY_BASE_MS = 5_000;
|
|
19
|
+
const EGRESS_RETRY_MAX_MS = 5 * 60_000;
|
|
20
|
+
export function retryQueueKey(ev) {
|
|
21
|
+
const s = typeof ev.sessionId === 'string' ? ev.sessionId : '';
|
|
22
|
+
const p = typeof ev.paneId === 'string' ? ev.paneId : '';
|
|
23
|
+
return `${s}|${p}`;
|
|
24
|
+
}
|
|
25
|
+
export function trimRetryQueue(queue, cap, keyOf = retryQueueKey) {
|
|
26
|
+
const limit = Number.isFinite(cap) && cap > 0 ? Math.floor(cap) : 0;
|
|
27
|
+
if (queue.length <= limit)
|
|
28
|
+
return queue;
|
|
29
|
+
const cut = queue.length - limit;
|
|
30
|
+
const tail = queue.slice(cut);
|
|
31
|
+
const covered = new Set();
|
|
32
|
+
for (const ev of tail)
|
|
33
|
+
covered.add(keyOf(ev));
|
|
34
|
+
const survivors = [];
|
|
35
|
+
for (let i = cut - 1; i >= 0; i--) {
|
|
36
|
+
const k = keyOf(queue[i]);
|
|
37
|
+
if (covered.has(k))
|
|
38
|
+
continue;
|
|
39
|
+
covered.add(k);
|
|
40
|
+
survivors.push([i, queue[i]]);
|
|
41
|
+
}
|
|
42
|
+
if (survivors.length === 0)
|
|
43
|
+
return tail;
|
|
44
|
+
survivors.reverse();
|
|
45
|
+
return [...survivors.map(([, ev]) => ev), ...tail];
|
|
46
|
+
}
|
|
12
47
|
const DEFAULT_WRITE_RATE_LIMIT = 60;
|
|
13
48
|
const DEFAULT_WRITE_RATE_WINDOW_MS = 60_000;
|
|
14
49
|
const READ_TOOLS = new Set(['run_radar', 'run_check', 'run_status']);
|
|
@@ -34,6 +69,7 @@ function constantTimeEqual(a, b) {
|
|
|
34
69
|
export function createHub(opts) {
|
|
35
70
|
const now = opts.now ?? (() => Date.now());
|
|
36
71
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
72
|
+
const log = opts.log ?? ((_s) => { });
|
|
37
73
|
const spawnImpl = opts.spawnImpl;
|
|
38
74
|
const journalFs = opts.journalFs ?? {
|
|
39
75
|
appendFileSync: nodeAppendFileSync,
|
|
@@ -55,6 +91,10 @@ export function createHub(opts) {
|
|
|
55
91
|
const rateWindows = new Map();
|
|
56
92
|
const gate = new EmissionGate();
|
|
57
93
|
let pendingEvents = [];
|
|
94
|
+
let retryQueue = [];
|
|
95
|
+
let retryNotBefore = 0;
|
|
96
|
+
let retryBackoffMs = EGRESS_RETRY_BASE_MS;
|
|
97
|
+
const localOnly = isClientMintedSessionId(opts.sessionId);
|
|
58
98
|
let server = null;
|
|
59
99
|
let pollTimer = null;
|
|
60
100
|
let heartbeatTimer = null;
|
|
@@ -350,25 +390,76 @@ export function createHub(opts) {
|
|
|
350
390
|
return null;
|
|
351
391
|
}
|
|
352
392
|
}
|
|
353
|
-
function flushEgress() {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
393
|
+
async function flushEgress() {
|
|
394
|
+
try {
|
|
395
|
+
if (pendingEvents.length > 0) {
|
|
396
|
+
const batch = pendingEvents.map((e) => sanitizeForEgress(e));
|
|
397
|
+
pendingEvents = [];
|
|
398
|
+
for (const ev of batch) {
|
|
399
|
+
try {
|
|
400
|
+
assertNoForbiddenFields(ev);
|
|
401
|
+
retryQueue.push(ev);
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
retryQueue = trimRetryQueue(retryQueue, EGRESS_RETRY_MAX);
|
|
363
407
|
}
|
|
364
|
-
|
|
408
|
+
if (localOnly) {
|
|
409
|
+
retryQueue = [];
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (retryQueue.length === 0)
|
|
413
|
+
return;
|
|
414
|
+
if (now() < retryNotBefore)
|
|
415
|
+
return;
|
|
416
|
+
while (retryQueue.length > 0) {
|
|
417
|
+
const chunk = retryQueue.slice(0, EGRESS_CHUNK);
|
|
418
|
+
const outcome = await postEvents(chunk);
|
|
419
|
+
if (outcome === 'throttled') {
|
|
420
|
+
retryQueue = trimRetryQueue(retryQueue, EGRESS_RETRY_MAX);
|
|
421
|
+
retryNotBefore = now() + retryBackoffMs;
|
|
422
|
+
retryBackoffMs = Math.min(retryBackoffMs * 2, EGRESS_RETRY_MAX_MS);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
retryQueue = retryQueue.slice(chunk.length);
|
|
426
|
+
retryBackoffMs = EGRESS_RETRY_BASE_MS;
|
|
365
427
|
}
|
|
366
428
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
429
|
+
catch {
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
async function postEvents(events) {
|
|
433
|
+
const res = await postJson(`${baseUrl}/api/cli/choir/events`, {
|
|
434
|
+
sessionId: state.sessionId,
|
|
435
|
+
events,
|
|
436
|
+
});
|
|
437
|
+
if (!res)
|
|
438
|
+
return 'dropped';
|
|
439
|
+
const status = res.status;
|
|
440
|
+
if (status === 429)
|
|
441
|
+
return 'throttled';
|
|
442
|
+
try {
|
|
443
|
+
if (typeof res.json !== 'function')
|
|
444
|
+
return 'sent';
|
|
445
|
+
const body = (await res.json());
|
|
446
|
+
if (!body || typeof body !== 'object')
|
|
447
|
+
return 'sent';
|
|
448
|
+
const skipped = body.skipped;
|
|
449
|
+
if (typeof skipped === 'number' && skipped > 0) {
|
|
450
|
+
const accepted = body.accepted;
|
|
451
|
+
log(`⚠ choir events: server skipped ${skipped} of ${events.length} ` +
|
|
452
|
+
`(accepted ${typeof accepted === 'number' ? accepted : '?'}) — ` +
|
|
453
|
+
`session not persisted server-side\n`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
}
|
|
458
|
+
return 'sent';
|
|
370
459
|
}
|
|
371
460
|
function sendHeartbeat() {
|
|
461
|
+
if (localOnly)
|
|
462
|
+
return;
|
|
372
463
|
const panes = Object.values(state.panes);
|
|
373
464
|
const body = {
|
|
374
465
|
sessionId: state.sessionId,
|
|
@@ -407,17 +498,21 @@ export function createHub(opts) {
|
|
|
407
498
|
},
|
|
408
499
|
body: JSON.stringify(body),
|
|
409
500
|
});
|
|
410
|
-
if (p && typeof p.
|
|
411
|
-
;
|
|
412
|
-
p.catch(() => {
|
|
413
|
-
});
|
|
501
|
+
if (p && typeof p.then === 'function') {
|
|
502
|
+
return p.catch(() => null);
|
|
414
503
|
}
|
|
504
|
+
return Promise.resolve(p ?? null);
|
|
415
505
|
}
|
|
416
506
|
catch {
|
|
507
|
+
return Promise.resolve(null);
|
|
417
508
|
}
|
|
418
509
|
}
|
|
419
510
|
return {
|
|
420
511
|
start() {
|
|
512
|
+
if (localOnly) {
|
|
513
|
+
log('ℹ choir hub: local session telemetry stays LOCAL — the web only ' +
|
|
514
|
+
'persists events for server-issued run sessions.\n');
|
|
515
|
+
}
|
|
421
516
|
try {
|
|
422
517
|
const impl = opts.netImpl;
|
|
423
518
|
if (!impl)
|
|
@@ -459,6 +554,9 @@ export function createHub(opts) {
|
|
|
459
554
|
getState() {
|
|
460
555
|
return state;
|
|
461
556
|
},
|
|
557
|
+
retryQueueSize() {
|
|
558
|
+
return retryQueue.length;
|
|
559
|
+
},
|
|
462
560
|
dispatch,
|
|
463
561
|
pollOnce,
|
|
464
562
|
flushEgress,
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from 'node:child_process';
|
|
2
|
+
import { readdirSync as nodeReaddirSync, rmSync as nodeRmSync, statSync as nodeStatSync, writeFileSync as nodeWriteFileSync, } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { validateActivationId, validateRepoRef, repoUrlLooksSafe } from './payload-validator.js';
|
|
6
|
+
const defaultWorkspaceFs = {
|
|
7
|
+
readdirSync: (p) => nodeReaddirSync(p),
|
|
8
|
+
statSync: (p) => nodeStatSync(p),
|
|
9
|
+
rmSync: (p, opts) => nodeRmSync(p, opts),
|
|
10
|
+
writeFileSync: (p, data) => nodeWriteFileSync(p, data),
|
|
11
|
+
};
|
|
12
|
+
const CLONE_TIMEOUT_MS = 45_000;
|
|
13
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
14
|
+
export const DEFAULT_CLOUD_BASE_DIR = path.join(tmpdir(), 'nonbot-cloud');
|
|
15
|
+
export const MAX_CLOUD_WORKSPACES = 20;
|
|
16
|
+
export const WORKSPACE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
export const REASON_TOO_MANY_WORKSPACES = 'too many workspaces';
|
|
18
|
+
export const REASON_ORIGIN_TOO_BROAD = 'allowlist entry too broad';
|
|
19
|
+
const OWN_REASONS = new Set([
|
|
20
|
+
'invalid repo url',
|
|
21
|
+
'origin not allowed',
|
|
22
|
+
REASON_ORIGIN_TOO_BROAD,
|
|
23
|
+
'invalid repo ref',
|
|
24
|
+
'invalid activation id',
|
|
25
|
+
REASON_TOO_MANY_WORKSPACES,
|
|
26
|
+
]);
|
|
27
|
+
const EXIT_CODED_REASON_RE = /^(clone|checkout) failed \(exit [-\w]+\)$/;
|
|
28
|
+
const REASON_MAX = 200;
|
|
29
|
+
function runGit(spawnImpl, cwd, args) {
|
|
30
|
+
return spawnImpl('git', ['-C', cwd, ...args], {
|
|
31
|
+
encoding: 'utf-8',
|
|
32
|
+
timeout: GIT_TIMEOUT_MS,
|
|
33
|
+
windowsHide: true,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function exitLabel(r) {
|
|
37
|
+
const status = r?.status;
|
|
38
|
+
return status === null || status === undefined ? 'unknown' : String(status);
|
|
39
|
+
}
|
|
40
|
+
function exitLabelFor(code) {
|
|
41
|
+
return code === null ? 'unknown' : String(code);
|
|
42
|
+
}
|
|
43
|
+
function runGitAsync(spawnImpl, args, timeoutMs) {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
let settled = false;
|
|
46
|
+
const done = (code) => {
|
|
47
|
+
if (settled)
|
|
48
|
+
return;
|
|
49
|
+
settled = true;
|
|
50
|
+
resolve(code);
|
|
51
|
+
};
|
|
52
|
+
let child;
|
|
53
|
+
try {
|
|
54
|
+
child = spawnImpl('git', args, {
|
|
55
|
+
timeout: timeoutMs,
|
|
56
|
+
windowsHide: true,
|
|
57
|
+
stdio: 'ignore',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
done(null);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
child.on('error', () => done(null));
|
|
66
|
+
child.on('close', (code) => done(typeof code === 'number' ? code : null));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
done(null);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export function countCloudWorkspaces(baseDir = DEFAULT_CLOUD_BASE_DIR, fsImpl = defaultWorkspaceFs) {
|
|
74
|
+
try {
|
|
75
|
+
return fsImpl.readdirSync(baseDir).length;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function removeCloudWorkspace(repoPath, opts = {}) {
|
|
82
|
+
const baseDir = opts.baseDir ?? DEFAULT_CLOUD_BASE_DIR;
|
|
83
|
+
const fsImpl = opts.fsImpl ?? defaultWorkspaceFs;
|
|
84
|
+
if (typeof repoPath !== 'string' || repoPath.length === 0)
|
|
85
|
+
return false;
|
|
86
|
+
const resolved = path.resolve(repoPath);
|
|
87
|
+
const base = path.resolve(baseDir);
|
|
88
|
+
if (path.dirname(resolved) !== base)
|
|
89
|
+
return false;
|
|
90
|
+
try {
|
|
91
|
+
fsImpl.rmSync(resolved, { recursive: true, force: true });
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export const WORKSPACE_LIVENESS_FILE = '.nonbot-live';
|
|
99
|
+
export function touchWorkspaceLiveness(repoPath, opts = {}) {
|
|
100
|
+
const baseDir = opts.baseDir ?? DEFAULT_CLOUD_BASE_DIR;
|
|
101
|
+
const fsImpl = opts.fsImpl ?? defaultWorkspaceFs;
|
|
102
|
+
const now = opts.now ?? Date.now;
|
|
103
|
+
if (typeof repoPath !== 'string' || repoPath.length === 0)
|
|
104
|
+
return false;
|
|
105
|
+
const resolved = path.resolve(repoPath);
|
|
106
|
+
if (path.dirname(resolved) !== path.resolve(baseDir))
|
|
107
|
+
return false;
|
|
108
|
+
if (typeof fsImpl.writeFileSync !== 'function')
|
|
109
|
+
return false;
|
|
110
|
+
try {
|
|
111
|
+
fsImpl.writeFileSync(path.join(resolved, WORKSPACE_LIVENESS_FILE), `${now()}\n`);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export function reapStaleWorkspaces(opts = {}) {
|
|
119
|
+
const baseDir = opts.baseDir ?? DEFAULT_CLOUD_BASE_DIR;
|
|
120
|
+
const maxAgeMs = opts.maxAgeMs ?? WORKSPACE_MAX_AGE_MS;
|
|
121
|
+
const now = opts.now ?? Date.now;
|
|
122
|
+
const fsImpl = opts.fsImpl ?? defaultWorkspaceFs;
|
|
123
|
+
const active = new Set(opts.activeIds ?? []);
|
|
124
|
+
let removed = 0;
|
|
125
|
+
let entries;
|
|
126
|
+
try {
|
|
127
|
+
entries = fsImpl.readdirSync(baseDir);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
const cutoff = now() - maxAgeMs;
|
|
133
|
+
for (const name of entries) {
|
|
134
|
+
if (active.has(name))
|
|
135
|
+
continue;
|
|
136
|
+
const full = path.join(baseDir, name);
|
|
137
|
+
try {
|
|
138
|
+
const st = fsImpl.statSync(full);
|
|
139
|
+
if (!st.isDirectory())
|
|
140
|
+
continue;
|
|
141
|
+
if (st.mtimeMs >= cutoff)
|
|
142
|
+
continue;
|
|
143
|
+
if (isMarkedLive(full, cutoff, fsImpl))
|
|
144
|
+
continue;
|
|
145
|
+
fsImpl.rmSync(full, { recursive: true, force: true });
|
|
146
|
+
removed++;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return removed;
|
|
152
|
+
}
|
|
153
|
+
function isMarkedLive(workspaceDir, cutoff, fsImpl) {
|
|
154
|
+
const marker = path.join(workspaceDir, WORKSPACE_LIVENESS_FILE);
|
|
155
|
+
let st;
|
|
156
|
+
try {
|
|
157
|
+
st = fsImpl.statSync(marker);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
if (!st || typeof st.mtimeMs !== 'number' || !Number.isFinite(st.mtimeMs))
|
|
163
|
+
return true;
|
|
164
|
+
return st.mtimeMs >= cutoff;
|
|
165
|
+
}
|
|
166
|
+
export function normalizeRepoPrefix(url) {
|
|
167
|
+
let u;
|
|
168
|
+
try {
|
|
169
|
+
u = new URL(url);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
let pathname = u.pathname.replace(/\/+$/, '');
|
|
175
|
+
while (pathname.endsWith('.git')) {
|
|
176
|
+
pathname = pathname.slice(0, -4).replace(/\/+$/, '');
|
|
177
|
+
}
|
|
178
|
+
return `${u.origin}${pathname}/`;
|
|
179
|
+
}
|
|
180
|
+
export function isHostWidePrefix(normalized) {
|
|
181
|
+
if (typeof normalized !== 'string' || normalized.length === 0)
|
|
182
|
+
return false;
|
|
183
|
+
try {
|
|
184
|
+
return new URL(normalized).pathname === '/';
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
export function classifyRepoUrl(repoUrl, allowedOrigins) {
|
|
191
|
+
if (!repoUrlLooksSafe(repoUrl))
|
|
192
|
+
return 'not_allowed';
|
|
193
|
+
if (!Array.isArray(allowedOrigins))
|
|
194
|
+
return 'not_allowed';
|
|
195
|
+
const candidate = normalizeRepoPrefix(repoUrl);
|
|
196
|
+
if (candidate === null)
|
|
197
|
+
return 'not_allowed';
|
|
198
|
+
let sawHostWideCover = false;
|
|
199
|
+
for (const o of allowedOrigins) {
|
|
200
|
+
if (typeof o !== 'string' || o.length === 0)
|
|
201
|
+
continue;
|
|
202
|
+
const entry = normalizeRepoPrefix(o);
|
|
203
|
+
if (entry === null)
|
|
204
|
+
continue;
|
|
205
|
+
if (!candidate.startsWith(entry))
|
|
206
|
+
continue;
|
|
207
|
+
if (isHostWidePrefix(entry)) {
|
|
208
|
+
sawHostWideCover = true;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
return 'allowed';
|
|
212
|
+
}
|
|
213
|
+
return sawHostWideCover ? 'too_broad' : 'not_allowed';
|
|
214
|
+
}
|
|
215
|
+
export function repoUrlAllowed(repoUrl, allowedOrigins) {
|
|
216
|
+
return classifyRepoUrl(repoUrl, allowedOrigins) === 'allowed';
|
|
217
|
+
}
|
|
218
|
+
export function runBranchFor(activationId) {
|
|
219
|
+
return `run/${activationId.replace(/^act_/, '').slice(0, 8)}`;
|
|
220
|
+
}
|
|
221
|
+
export async function prepareCloneWorkspace(args) {
|
|
222
|
+
const spawnImpl = args.spawnImpl ?? nodeSpawn;
|
|
223
|
+
if (!repoUrlLooksSafe(args.repoUrl)) {
|
|
224
|
+
throw new Error('invalid repo url');
|
|
225
|
+
}
|
|
226
|
+
const verdict = classifyRepoUrl(args.repoUrl, args.allowedOrigins);
|
|
227
|
+
if (verdict === 'too_broad') {
|
|
228
|
+
throw new Error(REASON_ORIGIN_TOO_BROAD);
|
|
229
|
+
}
|
|
230
|
+
if (verdict !== 'allowed') {
|
|
231
|
+
throw new Error('origin not allowed');
|
|
232
|
+
}
|
|
233
|
+
let ref;
|
|
234
|
+
try {
|
|
235
|
+
ref = validateRepoRef(args.repoRef);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
throw new Error('invalid repo ref');
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
validateActivationId(args.activationId);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
throw new Error('invalid activation id');
|
|
245
|
+
}
|
|
246
|
+
const baseDir = args.baseDir ?? DEFAULT_CLOUD_BASE_DIR;
|
|
247
|
+
if (countCloudWorkspaces(baseDir, args.fsImpl ?? defaultWorkspaceFs) >= MAX_CLOUD_WORKSPACES) {
|
|
248
|
+
throw new Error(REASON_TOO_MANY_WORKSPACES);
|
|
249
|
+
}
|
|
250
|
+
const repoPath = path.join(baseDir, args.activationId);
|
|
251
|
+
const branch = runBranchFor(args.activationId);
|
|
252
|
+
const cloneArgs = [
|
|
253
|
+
'clone',
|
|
254
|
+
'--depth',
|
|
255
|
+
'1',
|
|
256
|
+
'--filter=blob:none',
|
|
257
|
+
...(ref ? ['--branch', ref] : []),
|
|
258
|
+
args.repoUrl,
|
|
259
|
+
repoPath,
|
|
260
|
+
];
|
|
261
|
+
const cloneCode = await runGitAsync(spawnImpl, cloneArgs, CLONE_TIMEOUT_MS);
|
|
262
|
+
if (cloneCode !== 0) {
|
|
263
|
+
throw new Error(`clone failed (exit ${exitLabelFor(cloneCode)})`);
|
|
264
|
+
}
|
|
265
|
+
const checkoutCode = await runGitAsync(spawnImpl, ['-C', repoPath, 'checkout', '-b', branch], GIT_TIMEOUT_MS);
|
|
266
|
+
if (checkoutCode !== 0) {
|
|
267
|
+
throw new Error(`checkout failed (exit ${exitLabelFor(checkoutCode)})`);
|
|
268
|
+
}
|
|
269
|
+
touchWorkspaceLiveness(repoPath, { baseDir, fsImpl: args.fsImpl ?? defaultWorkspaceFs });
|
|
270
|
+
return { repoPath, branch };
|
|
271
|
+
}
|
|
272
|
+
export function pushRunBranch(args) {
|
|
273
|
+
const spawnImpl = args.spawnImpl ?? nodeSpawnSync;
|
|
274
|
+
const { repoPath, branch, activationId } = args;
|
|
275
|
+
const status = runGit(spawnImpl, repoPath, ['status', '--porcelain']);
|
|
276
|
+
if (!status || status.status !== 0) {
|
|
277
|
+
return { pushed: false, reason: `status failed (exit ${exitLabel(status)})` };
|
|
278
|
+
}
|
|
279
|
+
const dirty = String(status.stdout ?? '').trim() !== '';
|
|
280
|
+
if (dirty) {
|
|
281
|
+
const added = runGit(spawnImpl, repoPath, ['add', '-A']);
|
|
282
|
+
if (!added || added.status !== 0) {
|
|
283
|
+
return { pushed: false, reason: `add failed (exit ${exitLabel(added)})` };
|
|
284
|
+
}
|
|
285
|
+
const committed = runGit(spawnImpl, repoPath, [
|
|
286
|
+
'commit',
|
|
287
|
+
'-m',
|
|
288
|
+
`wip(run): ${activationId} — auto-push on exit`,
|
|
289
|
+
]);
|
|
290
|
+
if (!committed || committed.status !== 0) {
|
|
291
|
+
return { pushed: false, reason: `commit failed (exit ${exitLabel(committed)})` };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const pushed = runGit(spawnImpl, repoPath, ['push', '-u', 'origin', branch]);
|
|
295
|
+
if (!pushed || pushed.status !== 0) {
|
|
296
|
+
return { pushed: false, reason: `push failed (exit ${exitLabel(pushed)})` };
|
|
297
|
+
}
|
|
298
|
+
return { pushed: true };
|
|
299
|
+
}
|
|
300
|
+
export function shortCloneFailureReason(e) {
|
|
301
|
+
const msg = e instanceof Error && typeof e.message === 'string' ? e.message : '';
|
|
302
|
+
if (OWN_REASONS.has(msg) || EXIT_CODED_REASON_RE.test(msg)) {
|
|
303
|
+
return msg.slice(0, REASON_MAX);
|
|
304
|
+
}
|
|
305
|
+
return 'workspace preparation failed';
|
|
306
|
+
}
|
|
@@ -12,6 +12,33 @@ const ESC_GREEN = '\\033[32m';
|
|
|
12
12
|
const ESC_RED = '\\033[31m';
|
|
13
13
|
const ESC_CYAN = '\\033[36m';
|
|
14
14
|
const ESC_PRIMARY = '\\033[38;5;141m';
|
|
15
|
+
export const RUN_TIMEOUT_DEFAULT_MIN = 95;
|
|
16
|
+
export const RUN_TIMEOUT_KILL_AFTER = '60s';
|
|
17
|
+
export const RUN_TIMEOUT_BIN_VAR = 'NB_RUN_TIMEOUT_BIN';
|
|
18
|
+
export function resolveRunTimeoutMinutes(env = process.env) {
|
|
19
|
+
const raw = env.NONBOT_RUN_TIMEOUT_MIN;
|
|
20
|
+
if (typeof raw !== 'string' || raw.trim() === '')
|
|
21
|
+
return RUN_TIMEOUT_DEFAULT_MIN;
|
|
22
|
+
const n = Number(raw.trim());
|
|
23
|
+
if (!Number.isInteger(n) || n < 1)
|
|
24
|
+
return RUN_TIMEOUT_DEFAULT_MIN;
|
|
25
|
+
return n;
|
|
26
|
+
}
|
|
27
|
+
export function wallClockExceededReason(minutes) {
|
|
28
|
+
return `wall clock exceeded (${minutes}m)`;
|
|
29
|
+
}
|
|
30
|
+
const RUN_TIMEOUT_MISSING_NOTE = 'wall clock unenforced: no timeout/gtimeout on PATH (install coreutils)';
|
|
31
|
+
export function buildRunTimeoutPrelude() {
|
|
32
|
+
const warn = emitBanner(' ' + ESC_RED + '⚠' + ESC_RESET + ' ' + ESC_DIM + RUN_TIMEOUT_MISSING_NOTE + ESC_RESET + '\n');
|
|
33
|
+
return (`if command -v timeout >/dev/null 2>&1; then ${RUN_TIMEOUT_BIN_VAR}=timeout; ` +
|
|
34
|
+
`elif command -v gtimeout >/dev/null 2>&1; then ${RUN_TIMEOUT_BIN_VAR}=gtimeout; ` +
|
|
35
|
+
`else ${RUN_TIMEOUT_BIN_VAR}=; ${warn}; fi; `);
|
|
36
|
+
}
|
|
37
|
+
export function buildRunTimeoutPrefix() {
|
|
38
|
+
return (`\${${RUN_TIMEOUT_BIN_VAR}:+\$${RUN_TIMEOUT_BIN_VAR} --signal=TERM ` +
|
|
39
|
+
`--kill-after=${RUN_TIMEOUT_KILL_AFTER} ` +
|
|
40
|
+
`\${NONBOT_RUN_TIMEOUT_MIN:-${RUN_TIMEOUT_DEFAULT_MIN}}m} `);
|
|
41
|
+
}
|
|
15
42
|
export const PROVIDER_PROFILES = {
|
|
16
43
|
claude: {
|
|
17
44
|
realCli: 'claude',
|
|
@@ -294,7 +321,11 @@ export function buildRealCommand(params) {
|
|
|
294
321
|
const settingsFlag = wantHook
|
|
295
322
|
? `--settings ${shellQuoteSingle(params.hookSettingsPath)} `
|
|
296
323
|
: '';
|
|
297
|
-
|
|
324
|
+
const wallClock = params.wallClock !== false;
|
|
325
|
+
const timeoutPrelude = wallClock ? buildRunTimeoutPrelude() : '';
|
|
326
|
+
const timeoutPrefix = wallClock ? buildRunTimeoutPrefix() : '';
|
|
327
|
+
return (`${head}${agentsMdPrefix}${hookHeredocPrefix}${timeoutPrelude}` +
|
|
328
|
+
`${bannerEmit}${timeoutPrefix}${cli} ${settingsFlag}${shellQuoteSingle(prompt)}`);
|
|
298
329
|
}
|
|
299
330
|
export function buildCommandFromParams(params) {
|
|
300
331
|
switch (params.template) {
|