atris 3.64.0 → 3.65.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.
@@ -457,8 +457,61 @@ function parseVerifyCommand(verify) {
457
457
  };
458
458
  }
459
459
 
460
+ // Members record checks as one command or several joined with `&&`
461
+ // ("npm run test:a && node scripts/test-b.mjs"). Each step must pass the
462
+ // same allowlist on its own; `&&` only runs them in order and stops at the
463
+ // first failure, still with shell:false, so a chain can do nothing its steps
464
+ // could not do one at a time. One leading `cd` applies to every step.
465
+ const MAX_VERIFY_CHAIN_STEPS = 8;
466
+
467
+ function splitTopLevelAnd(command) {
468
+ const parts = [];
469
+ let current = '';
470
+ let quote = null;
471
+ for (let i = 0; i < command.length; i += 1) {
472
+ const char = command[i];
473
+ if (quote) {
474
+ if (char === quote) quote = null;
475
+ current += char;
476
+ continue;
477
+ }
478
+ if (char === "'" || char === '"') {
479
+ quote = char;
480
+ current += char;
481
+ continue;
482
+ }
483
+ if (char === '&' && command[i + 1] === '&') {
484
+ parts.push(current.trim());
485
+ current = '';
486
+ i += 1;
487
+ continue;
488
+ }
489
+ current += char;
490
+ }
491
+ parts.push(current.trim());
492
+ return parts;
493
+ }
494
+
495
+ function parseVerifyChain(verify) {
496
+ const cmd = String(verify || '').trim();
497
+ if (!cmd) return { ok: false, reason: 'no_verify_command' };
498
+ const prefixed = parseCdPrefix(cmd);
499
+ if (!prefixed.ok) return prefixed;
500
+ const parts = splitTopLevelAnd(prefixed.command);
501
+ if (parts.length > MAX_VERIFY_CHAIN_STEPS || parts.some((part) => !part || /^cd(\s|$)/.test(part))) {
502
+ return { ok: false, reason: 'verify_command_not_allowed' };
503
+ }
504
+ const steps = [];
505
+ for (const part of parts) {
506
+ const parsed = parseVerifyCommand(part);
507
+ if (!parsed.ok) return parsed;
508
+ steps.push({ ...parsed, ...(prefixed.cwd ? { cwd: prefixed.cwd } : {}) });
509
+ }
510
+ return { ok: true, steps, ...(prefixed.cwd ? { cwd: prefixed.cwd } : {}) };
511
+ }
512
+
460
513
  function isAutoCertifyVerifyCommandAllowed(verify) {
461
- return parseVerifyCommand(verify).ok;
514
+ return parseVerifyChain(verify).ok;
462
515
  }
463
516
 
464
517
  // Re-entrancy guard: every verify child carries ATRIS_VERIFY_IN_PROGRESS=1
@@ -468,12 +521,20 @@ function isAutoCertifyVerifyCommandAllowed(verify) {
468
521
  // `atris task status` and the read path re-ran the verify. Refuse loudly.
469
522
  const VERIFY_IN_PROGRESS_ENV = 'ATRIS_VERIFY_IN_PROGRESS';
470
523
 
471
- // Spawn cap: a single CLI process has no legitimate reason to run dozens of
472
- // verifies. Beyond the cap something is looping; refuse and report instead
473
- // of silently bounding.
474
- const VERIFY_SPAWN_CAP = 25;
524
+ // Spawn cap: bounds a runaway loop inside one process. The recursion that
525
+ // fork-bombed the fleet is stopped by VERIFY_IN_PROGRESS_ENV above; this cap
526
+ // is the backstop. It was 25, which starved the review queue: every hourly
527
+ // tick re-checked the same first 25 rows and the other 141 waiting rows were
528
+ // refused as verify_spawn_cap forever (measured 2026-10-01). Override with
529
+ // ATRIS_VERIFY_SPAWN_CAP.
530
+ const DEFAULT_VERIFY_SPAWN_CAP = 150;
475
531
  let verifySpawnCount = 0;
476
532
 
533
+ function verifySpawnCap() {
534
+ const raw = Number(process.env.ATRIS_VERIFY_SPAWN_CAP);
535
+ return Number.isInteger(raw) && raw > 0 ? Math.min(raw, 1000) : DEFAULT_VERIFY_SPAWN_CAP;
536
+ }
537
+
477
538
  function verifyRefusal(unrunnableCause, extra) {
478
539
  return {
479
540
  ok: false,
@@ -490,14 +551,26 @@ function runVerifyCommand(verify, workspaceRoot) {
490
551
  detail: `${VERIFY_IN_PROGRESS_ENV} is set: a verify is already running up the call chain; refusing to nest another one`,
491
552
  });
492
553
  }
493
- if (verifySpawnCount >= VERIFY_SPAWN_CAP) {
554
+ const chain = parseVerifyChain(verify);
555
+ if (!chain.ok) return chain;
556
+ let result = null;
557
+ for (const step of chain.steps) {
558
+ result = runVerifyStep(step, workspaceRoot);
559
+ if (!result.ok) {
560
+ return chain.steps.length > 1 ? { ...result, failed_step: step.argv.join(' ') } : result;
561
+ }
562
+ }
563
+ return result;
564
+ }
565
+
566
+ function runVerifyStep(parsed, workspaceRoot) {
567
+ const cap = verifySpawnCap();
568
+ if (verifySpawnCount >= cap) {
494
569
  return verifyRefusal('verify_spawn_cap', {
495
- detail: `this process already ran ${verifySpawnCount} verifies (cap ${VERIFY_SPAWN_CAP}); refusing more`,
570
+ detail: `this process already ran ${verifySpawnCount} verifies (cap ${cap}); refusing more`,
496
571
  });
497
572
  }
498
573
  verifySpawnCount += 1;
499
- const parsed = parseVerifyCommand(verify);
500
- if (!parsed.ok) return parsed;
501
574
  const cwdCheck = validateCommandCwd(parsed, workspaceRoot);
502
575
  if (!cwdCheck.ok) return cwdCheck;
503
576
  const gitPathCheck = validateGitWorktreePath(parsed.argv, workspaceRoot, cwdCheck.cwd);
@@ -1250,6 +1323,7 @@ module.exports = {
1250
1323
  sniffedProtectedLane,
1251
1324
  evaluateAutoAccept,
1252
1325
  isAutoCertifyVerifyCommandAllowed,
1326
+ parseVerifyChain,
1253
1327
  isAgentCertified,
1254
1328
  parseVerifyCommand,
1255
1329
  repoHygieneGate,
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ // Shared plumbing for business-scoped HTTP commands (`atris hire`, `atris hires`,
4
+ // `atris mail`): parse flags, find which business to act on, get a login token.
5
+ // These commands are HTTP clients of /api/business/{id}/...; no backend code here.
6
+
7
+ const { apiRequestJson } = require('../utils/api');
8
+ const { ensureValidCredentials } = require('../utils/auth');
9
+ const { findBusiness } = require('../commands/feed');
10
+ const { oneLine } = require('./developer-api');
11
+
12
+ const NO_BUSINESS = 'no business here. run this inside a business folder, or add --business <slug>';
13
+
14
+ // --help, --json and --business are allowed on every command.
15
+ const COMMON_SWITCHES = ['help', 'json'];
16
+ const COMMON_VALUES = ['business'];
17
+
18
+ // --key value, --key=value, and yes/no switches that never take a value.
19
+ // `spec` is { switches: [...], values: [...] }: the only flags the command
20
+ // takes, beyond the common ones. Anything else comes back as `error`, and
21
+ // the command must stop before making any request:
22
+ // an unknown flag (--dry-run on a command without it),
23
+ // a value on a switch (--yes=false is refused, never read as yes),
24
+ // a value flag with nothing after it (--business ""),
25
+ // the same value flag twice.
26
+ // A plain array is the old lenient form (switch names only), kept for callers
27
+ // that want it; it never reports errors.
28
+ function parseArgs(argv = [], spec = []) {
29
+ const strict = !Array.isArray(spec);
30
+ const switchNames = strict ? (spec.switches || []) : spec;
31
+ const valueNames = strict ? (spec.values || []) : [];
32
+ const switches = new Set([...switchNames, ...COMMON_SWITCHES, 'yes']);
33
+ const allowed = new Set([...switchNames, ...valueNames, ...COMMON_SWITCHES, ...COMMON_VALUES]);
34
+ const flags = {};
35
+ const pos = [];
36
+ let error = '';
37
+ const fail = (message) => { if (!error) error = message; };
38
+ for (let i = 0; i < argv.length; i += 1) {
39
+ const arg = String(argv[i]);
40
+ if (arg === '-h') { flags.help = true; continue; }
41
+ if (arg === '-y') {
42
+ if (strict && !allowed.has('yes')) fail('unknown option -y');
43
+ else flags.yes = true;
44
+ continue;
45
+ }
46
+ if (arg.startsWith('--')) {
47
+ const eq = arg.indexOf('=');
48
+ const raw = eq > 0 ? arg.slice(0, eq) : arg;
49
+ const key = raw.slice(2).replace(/-/g, '_');
50
+ if (strict && !allowed.has(key)) { fail(`unknown option ${raw}`); continue; }
51
+ if (switches.has(key)) {
52
+ if (eq < 0) flags[key] = true;
53
+ else if (strict) fail(`${raw} is a switch and takes no value: use ${raw} on its own, or leave it off`);
54
+ else flags[key] = arg.slice(eq + 1);
55
+ continue;
56
+ }
57
+ let value = true;
58
+ if (eq > 0) value = arg.slice(eq + 1);
59
+ else if (argv[i + 1] != null && !String(argv[i + 1]).startsWith('--')) { value = String(argv[i + 1]); i += 1; }
60
+ if (strict && (typeof value !== 'string' || !value.trim())) { fail(`${raw} needs a value`); continue; }
61
+ if (strict && Object.prototype.hasOwnProperty.call(flags, key)) { fail(`${raw} was given twice`); continue; }
62
+ flags[key] = value;
63
+ continue;
64
+ }
65
+ if (strict && arg.length > 1 && arg.startsWith('-')) { fail(`unknown option ${arg}`); continue; }
66
+ pos.push(arg);
67
+ }
68
+ return strict ? { flags, pos, error } : { flags, pos };
69
+ }
70
+
71
+ // A --business that was given must name a business. An empty or missing
72
+ // value is an error and never falls back to the current folder's business.
73
+ // Without --business: the nearest .atris/business.json above the current
74
+ // folder. Returns { id, name }, { error }, or null when none is found.
75
+ function resolveBusiness(flags = {}, deps = {}) {
76
+ if (Object.prototype.hasOwnProperty.call(flags, 'business')) {
77
+ const given = typeof flags.business === 'string' ? flags.business.trim() : '';
78
+ if (!given) return { error: '--business needs a business slug or id' };
79
+ return { id: given, name: given };
80
+ }
81
+ const found = (deps.findBusiness || findBusiness)(deps.cwd || process.cwd());
82
+ return found && found.businessId ? { id: found.businessId, name: found.name || 'this business' } : null;
83
+ }
84
+
85
+ // The business to act on, or says why not (through `err`) and returns null.
86
+ function businessOrSay(flags, deps, err) {
87
+ const business = resolveBusiness(flags, deps);
88
+ if (!business) { err(NO_BUSINESS); return null; }
89
+ if (business.error) { err(business.error); return null; }
90
+ return business;
91
+ }
92
+
93
+ async function loginToken(deps = {}) {
94
+ if (deps.token) return deps.token;
95
+ const ensure = deps.ensureCredentials || (() => ensureValidCredentials(apiRequestJson));
96
+ const auth = await ensure();
97
+ return auth && !auth.error && auth.credentials && auth.credentials.token ? auth.credentials.token : '';
98
+ }
99
+
100
+ // FastAPI errors arrive as {"detail": "..."}; keep them to one plain line.
101
+ function errorText(result) {
102
+ if (!result) return 'request failed';
103
+ if (Number(result.status) === 404 && !(result.data && result.data.detail)) {
104
+ return 'backend rolling out, try again shortly';
105
+ }
106
+ return oneLine((result.data && (result.data.detail || result.data.error)) || result.error || `request failed (${result.status})`);
107
+ }
108
+
109
+ // "2026-10-02T09:00:00+00:00" -> "in 9 h" / "6 h ago"
110
+ function relativeTime(iso, now = Date.now()) {
111
+ const at = Date.parse(iso || '');
112
+ if (!Number.isFinite(at)) return '';
113
+ const mins = Math.round((at - now) / 60000);
114
+ const abs = Math.abs(mins);
115
+ const span = abs < 1 ? 'now' : abs < 90 ? `${abs} min` : abs < 48 * 60 ? `${Math.round(abs / 60)} h` : `${Math.round(abs / 1440)} days`;
116
+ if (span === 'now') return 'just now';
117
+ return mins >= 0 ? `in ${span}` : `${span} ago`;
118
+ }
119
+
120
+ function dollars(value) {
121
+ if (value == null || !Number.isFinite(Number(value))) return '-';
122
+ const n = Number(value);
123
+ return Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`;
124
+ }
125
+
126
+ function clip(text, width) {
127
+ const s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
128
+ return s.length > width ? `${s.slice(0, width - 1)}…` : s;
129
+ }
130
+
131
+ module.exports = { NO_BUSINESS, parseArgs, resolveBusiness, businessOrSay, loginToken, errorText, relativeTime, dollars, clip };