@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.
@@ -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
- if (typeof act.command === 'string' && act.command.length > 0 && act.command !== shell) {
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 async function spawnTerminalDefault(act, auth) {
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 { cmd, args } = profile.launch(scriptPath, itermProfileName ? { itermProfileName } : undefined);
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 {
@@ -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)
@@ -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
- if (pendingEvents.length === 0)
355
- return;
356
- const batch = pendingEvents.map((e) => sanitizeForEgress(e));
357
- pendingEvents = [];
358
- const safe = [];
359
- for (const ev of batch) {
360
- try {
361
- assertNoForbiddenFields(ev);
362
- safe.push(ev);
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
- catch {
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
- if (safe.length === 0)
368
- return;
369
- postJson(`${baseUrl}/api/cli/choir/events`, { sessionId: state.sessionId, events: safe });
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.catch === 'function') {
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
- return `${head}${agentsMdPrefix}${hookHeredocPrefix}${bannerEmit}${cli} ${settingsFlag}${shellQuoteSingle(prompt)}`;
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) {