nomarmy 0.1.0-alpha.2 → 0.1.0-alpha.21

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.
Files changed (102) hide show
  1. package/README.md +86 -480
  2. package/bin/nomarmy.mjs +1081 -185
  3. package/docker/Dockerfile +2 -2
  4. package/docker/Dockerfile.go +6 -4
  5. package/docker/Dockerfile.rust +17 -2
  6. package/harnesses/_template/README.md +27 -0
  7. package/harnesses/_template/harness.yml +26 -0
  8. package/harnesses/browser-playwright/README.md +35 -0
  9. package/harnesses/browser-playwright/fixture/package.json +1 -0
  10. package/harnesses/browser-playwright/fixture/page.html +1 -0
  11. package/harnesses/browser-playwright/fixture/page.spec.js +5 -0
  12. package/harnesses/browser-playwright/fixture/playwright.config.js +8 -0
  13. package/harnesses/browser-playwright/harness.yml +18 -0
  14. package/harnesses/go/README.md +45 -0
  15. package/harnesses/go/harness.yml +14 -0
  16. package/harnesses/mock-oidc/README.md +31 -0
  17. package/harnesses/mock-oidc/fixture/.nomarmy.yml +4 -0
  18. package/harnesses/mock-oidc/fixture/discovery.test.mjs +16 -0
  19. package/harnesses/mock-oidc/harness.yml +19 -0
  20. package/harnesses/node/README.md +53 -0
  21. package/harnesses/node/harness.yml +18 -0
  22. package/harnesses/python/README.md +46 -0
  23. package/harnesses/python/harness.yml +16 -0
  24. package/harnesses/rust/README.md +45 -0
  25. package/harnesses/rust/harness.yml +13 -0
  26. package/install.sh +29 -9
  27. package/lib/admission.mjs +178 -30
  28. package/lib/agents.mjs +8 -6
  29. package/lib/army.mjs +25 -10
  30. package/lib/codex-link.mjs +37 -0
  31. package/lib/config.mjs +15 -0
  32. package/lib/connect.mjs +232 -19
  33. package/lib/continue-from.mjs +103 -0
  34. package/lib/coordinator-instructions.mjs +5 -1
  35. package/lib/diff-checks.mjs +114 -0
  36. package/lib/dispatch-schema.mjs +14 -12
  37. package/lib/doctor.mjs +98 -9
  38. package/lib/egress-proxy.mjs +116 -0
  39. package/lib/execute.mjs +241 -33
  40. package/lib/git-record.mjs +27 -3
  41. package/lib/harness-schema.mjs +61 -0
  42. package/lib/harnesses.mjs +99 -0
  43. package/lib/health.mjs +162 -18
  44. package/lib/install-freshness.mjs +114 -0
  45. package/lib/jev-checks.mjs +110 -0
  46. package/lib/job-format.mjs +54 -0
  47. package/lib/judge.mjs +130 -0
  48. package/lib/limits.mjs +77 -0
  49. package/lib/model-probe.mjs +61 -0
  50. package/lib/mutation.mjs +159 -0
  51. package/lib/notify.mjs +30 -3
  52. package/lib/openclaw-install.mjs +122 -0
  53. package/lib/openclaw-path.mjs +28 -0
  54. package/lib/openclaw-run.mjs +74 -12
  55. package/lib/openclaw-runtime-health.mjs +56 -0
  56. package/lib/outcome.mjs +21 -2
  57. package/lib/outcomes.mjs +6 -0
  58. package/lib/path-utils.mjs +4 -0
  59. package/lib/podman-health.mjs +41 -0
  60. package/lib/process.mjs +4 -1
  61. package/lib/propose.mjs +10 -11
  62. package/lib/refusal-retry.mjs +16 -0
  63. package/lib/registry-python.mjs +98 -0
  64. package/lib/registry-secrets.mjs +140 -0
  65. package/lib/repo-query.mjs +13 -7
  66. package/lib/runs.mjs +7 -1
  67. package/lib/same-path.mjs +14 -0
  68. package/lib/sandbox-images.mjs +499 -83
  69. package/lib/sandbox-vm.mjs +32 -0
  70. package/lib/scan.mjs +5 -1
  71. package/lib/schema.mjs +20 -11
  72. package/lib/scout.mjs +21 -3
  73. package/lib/server-context.mjs +21 -1
  74. package/lib/setup-steps.mjs +55 -0
  75. package/lib/share.mjs +82 -0
  76. package/lib/stale-sessions.mjs +60 -0
  77. package/lib/stats.mjs +315 -0
  78. package/lib/statusline.mjs +32 -6
  79. package/lib/subscription-setup.mjs +13 -0
  80. package/lib/suggestions.mjs +153 -0
  81. package/lib/thinking.mjs +23 -0
  82. package/lib/transcript.mjs +30 -5
  83. package/lib/usage-limits.mjs +329 -0
  84. package/lib/user-config.mjs +106 -0
  85. package/lib/validators.mjs +220 -0
  86. package/lib/verification-artifacts.mjs +46 -0
  87. package/lib/verification-flow.mjs +52 -7
  88. package/lib/verification-network.mjs +66 -0
  89. package/lib/verify.mjs +338 -85
  90. package/lib/worker-prompt.mjs +5 -2
  91. package/lib/wsl-cli.mjs +152 -0
  92. package/lib/wsl.mjs +230 -0
  93. package/lib/zod-issues.mjs +15 -0
  94. package/mcp/server.mjs +165 -34
  95. package/package.json +7 -5
  96. package/playbooks/feature.md +8 -5
  97. package/scripts/configure-openclaw.sh +4 -2
  98. package/scripts/generate-harness-docs.mjs +42 -0
  99. package/scripts/install-openclaw.mjs +23 -0
  100. package/scripts/lib.sh +9 -2
  101. package/scripts/select-model.mjs +12 -5
  102. package/scripts/start-inference.sh +2 -2
package/lib/admission.mjs CHANGED
@@ -1,14 +1,19 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { executionMode } from "./execution.mjs";
4
- import { clampInt } from "./budget-state.mjs";
4
+ import { jobLabel, jobElapsedSeconds } from "./job-format.mjs";
5
+ import { continuationProblem } from "./continue-from.mjs";
6
+ import { loadConfig } from "./config.mjs";
7
+ import { maxJobs } from "./limits.mjs";
5
8
  import { checkBrief, assessAdmission, describeBudgets } from "./budget.mjs";
6
9
  import { parseStatusPorcelainZ, isRuntimeJunk } from "./git-record.mjs";
7
10
  import { readJson } from "./openclaw-run.mjs";
8
11
  import { readOpenClawTranscriptTail } from "./transcript.mjs";
9
12
  import { readClaudeSessionTranscript } from "./claude-transcript.mjs";
10
13
  import { notify } from "./notify.mjs";
11
- import { recentModelRefusal } from "./health.mjs";
14
+ import { readCodexRateLimits, recordUsageSnapshot, readUsageSnapshots, usageStatus, usageDisplayText, refreshStaleOverLimitReadings, staleUsageRefreshedNote, USAGE_STALE_MINUTES } from "./usage-limits.mjs";
15
+ import { projectDirProblem } from "./server-context.mjs";
16
+ import { recentModelRefusal, refusedModelIn, recordModelRefusal, recordProbeSuccess, modelProven, modelRefusals, claimModelRefusalRetry } from "./health.mjs";
12
17
  import { writeLease, removeLease, liveLeases, liveSlots, acquireSlot } from "./slots.mjs";
13
18
  import { loadRun, runTotals, runAdmissionProblems, recordRunJob, detectUsageLimit } from "./runs.mjs";
14
19
  import { agentProviderId, hostToolsImplementProblem } from "./agents.mjs";
@@ -21,7 +26,7 @@ import { policyAdmissionProblems } from "./outcome.mjs";
21
26
  // Claude or Codex job took llama-server's only slot and blocked local work
22
27
  // it never competed with -- reported from a real Senti run.
23
28
  export function jobLane(job) {
24
- return job.pool || job.subscription_worker ? "remote" : "local";
29
+ return job.mode === "verify" || job.pool || job.subscription_worker ? "remote" : "local";
25
30
  }
26
31
 
27
32
  // A static, operator-declared ceiling on how many remote jobs (api and
@@ -33,8 +38,9 @@ export function jobLane(job) {
33
38
  // benefit of spreading load across providers with their own separate rate
34
39
  // limits. Not rate-limit-aware (see config/providers.yml.example); read
35
40
  // fresh each call, matching currentMaxWorkers()'s own env-read pattern.
41
+ /** Api and subscription jobs at once, machine-wide: limits.yml's max_jobs (see lib/limits.mjs). */
36
42
  export function currentMaxPoolWorkers() {
37
- return clampInt(process.env.NOMARMY_MAX_POOL_WORKERS, 1, 32, 4);
43
+ return maxJobs().value;
38
44
  }
39
45
 
40
46
  // Pure partition of a batch's ORIGINAL indices by lane -- pulled out of
@@ -113,7 +119,7 @@ export function createJobRuntime(deps) {
113
119
  * batch queue for a slot instead of failing.
114
120
  */
115
121
  function withAgentSlot(args, jobId, fn, { waitMs = 0 } = {}) {
116
- const max = args.agentName ? agentMaxConcurrent(args.agentName) : null;
122
+ const max = args.mode !== "verify" && args.agentName ? agentMaxConcurrent(args.agentName) : null;
117
123
  if (!max) return fn();
118
124
  return (async () => {
119
125
  const slot = await acquireSlot(slotsRoot, args.agentName, max, { jobId, waitMs });
@@ -121,6 +127,19 @@ export function createJobRuntime(deps) {
121
127
  try { return await fn(); } finally { slot.release(); }
122
128
  })();
123
129
  }
130
+ // Who ran the job and for whom, onto its finished record, so `nomarmy
131
+ // stats` can count by role and agent, and by repo for verify runs too
132
+ // (their records didn't carry the repo). Best-effort; never fails the job.
133
+ function stampJobRecord(entry, result) {
134
+ const file = path.join(result?.jobDir ?? path.join(jobsRoot, entry.jobId), "metadata.json");
135
+ try {
136
+ const record = JSON.parse(fs.readFileSync(file, "utf8"));
137
+ record.labels = { role: entry.role ?? null, agent: entry.agent ?? null, label: entry.label ?? null, runId: entry.runId ?? null };
138
+ record.projectDir = record.projectDir ?? projectDir;
139
+ fs.writeFileSync(`${file}.tmp`, JSON.stringify(record, null, 2));
140
+ fs.renameSync(`${file}.tmp`, file);
141
+ } catch { /* no record (a refused or crashed job): nothing to label */ }
142
+ }
124
143
  function track(jobId, meta, promise) {
125
144
  const entry = { ...meta, jobId, startedAt: new Date().toISOString(), settled: false, result: null, error: null, promise: null };
126
145
  // A machine-wide lease for as long as the job runs, so every session's
@@ -129,7 +148,7 @@ export function createJobRuntime(deps) {
129
148
  if (meta.lane) writeLease(leasesRoot, jobId, { lane: meta.lane, agent: meta.agent ?? null, runId: meta.runId ?? null, role: meta.role ?? null, model: meta.model ?? null, repo: projectDir });
130
149
  const release = () => removeLease(leasesRoot, jobId);
131
150
  entry.promise = promise.then(
132
- r => { entry.settled = true; entry.result = r; release(); notifyJobFinished(entry, r, null); return r; },
151
+ r => { entry.settled = true; entry.result = r; release(); stampJobRecord(entry, r); notifyJobFinished(entry, r, null); return r; },
133
152
  e => { entry.settled = true; entry.error = e; release(); notifyJobFinished(entry, null, e); throw e; });
134
153
  entry.promise.catch(() => {});
135
154
  activeJobs.set(jobId, entry);
@@ -140,18 +159,29 @@ export function createJobRuntime(deps) {
140
159
  * watching hears about it from any coordinator without polling.
141
160
  */
142
161
  function notifyJobFinished(entry, result, error) {
162
+ try {
163
+ const provider = entry.agent ? agentProviderId(agentsConfig().agents[entry.agent]) : null;
164
+ const snapshot = provider ? readCodexRateLimits(path.join(jobsRoot, entry.jobId)) : null;
165
+ if (snapshot) recordUsageSnapshot(stateRoot, provider, snapshot);
166
+ } catch { /* Usage telemetry must never affect the job result. */ }
143
167
  if (!entry.lane) return; // only tracked jobs, never internal helpers
144
168
  const m = result?.manifest ?? {};
169
+ // Remember a model its vendor refused, until something on it works.
170
+ try {
171
+ const refused = refusedModelIn(m);
172
+ if (refused) recordModelRefusal(stateRoot, refused, String(m.workerError ?? m.worker?.error ?? "").split("\n")[0].slice(0, 300) || null);
173
+ else if (m.worker?.provider && m.worker?.model && !m.workerError && !error) recordProbeSuccess(stateRoot, `${m.worker.provider}/${m.worker.model}`);
174
+ } catch { /* best-effort, like the usage reading */ }
145
175
  const outcome = error ? "failed" : String(m.outcome ?? (result?.ok ? "done" : "finished")).toLowerCase().replace(/_/g, " ");
146
- const who = entry.agent ? `${entry.agent}${entry.model ? `/${entry.model}` : ""}` : "local model";
176
+ const who = entry.mode === "verify" ? "verification runner" : entry.agent ? `${entry.agent}${entry.model ? `/${entry.model}` : ""}` : "local model";
147
177
  const took = Math.round((Date.now() - Date.parse(entry.startedAt)) / 60000);
148
178
  const ok = !error && (result?.ok || m.coordinatorStatus === "complete");
149
- notify(`nomArmy: ${entry.role ?? entry.mode ?? "job"} ${ok ? "done" : outcome}`, `${entry.workerId ?? entry.jobId} on ${who}: ${outcome} after ${took}m. ${ok ? "Ready for the General's review." : "Needs a look."}`);
179
+ notify(`nomArmy: ${entry.role ?? entry.mode ?? "job"} ${ok ? "done" : outcome}`, `${entry.label ? `${entry.label} (${entry.workerId ?? entry.jobId})` : entry.workerId ?? entry.jobId} on ${who}: ${outcome} after ${took}m. ${ok ? "Ready for the General's review." : "Needs a look."}`);
150
180
  }
151
181
  function admissionHardware() {
152
182
  return executionMode(deps.env).managesModelServer ? deps.budgetState.hardwareSnapshot : null;
153
183
  }
154
- function capacitySnapshot() {
184
+ function capacitySnapshot(usageOverride = null) {
155
185
  const admission = assessAdmission({ hardware: admissionHardware(), runningJobs: runningCount("local"), slots: deps.budgetState.contextInfo.slots, maxWorkers: currentMaxWorkers() });
156
186
  return {
157
187
  // The local model's budget. An api or subscription job's scales with
@@ -161,14 +191,55 @@ export function createJobRuntime(deps) {
161
191
  admission,
162
192
  memory: deps.budgetState.hardwareSnapshot?.memory ?? null,
163
193
  running: [...activeJobs.values()].filter(j => !j.settled).map(j => ({ jobId: j.jobId, workerId: j.workerId, mode: j.mode, lane: j.lane, startedAt: j.startedAt, phase: readJson(path.join(jobsRoot, j.jobId, "status.json"))?.phase ?? "starting" })),
194
+ usageLimits: Object.fromEntries(Object.entries(usageOverride ?? readUsageSnapshots(stateRoot)).map(([provider, snapshot]) => {
195
+ const status = usageStatus(snapshot);
196
+ return [provider, { ...status, text: usageDisplayText(status) }];
197
+ })),
164
198
  maxWorkers: currentMaxWorkers(),
165
- remote: { running: runningCount("remote"), maxWorkers: currentMaxPoolWorkers(), note: "api and subscription agents; each agent's own max_concurrent also applies" }
199
+ remote: (() => { const limit = maxJobs(); return { running: runningCount("remote"), maxWorkers: limit.value, setBy: limit.source === "file" ? limit.path : limit.source === "env" ? "NOMARMY_MAX_POOL_WORKERS" : "default", note: "api and subscription agents; each agent's own max_concurrent also applies. Change with `nomarmy config max-jobs <n>`." }; })()
166
200
  };
167
201
  }
168
202
  async function admit(jobs) {
169
- await deps.budgetState.refresh();
170
- if (jobs.some((j) => jobLane(j) === "remote")) await modelCatalogReady();
203
+ if (jobs.some(j => j.mode !== "verify")) await deps.budgetState.refresh();
204
+ if (jobs.some((j) => j.mode !== "verify" && jobLane(j) === "remote")) await modelCatalogReady();
171
205
  const problems = [];
206
+ const notARepo = (deps.projectDirProblem ?? projectDirProblem)(projectDir);
207
+ if (notARepo) problems.push(notARepo);
208
+ // Every job needs its sandbox; the server wires the Podman check (tests don't).
209
+ const noSandbox = deps.sandboxProblem ? deps.sandboxProblem() : null;
210
+ if (noSandbox) problems.push(noSandbox);
211
+ const usageNow = deps.now?.() ?? Date.now();
212
+ let snapshots = readUsageSnapshots(stateRoot);
213
+ const providerFor = (job) => {
214
+ if (job.mode === "verify" || !job.agentName || job.confirm_over_limit === true) return null;
215
+ try { return agentProviderId(agentsConfig().agents[job.agentName]) ?? null; } catch { return null; }
216
+ };
217
+ const held = [];
218
+ jobs.forEach((j, i) => {
219
+ const provider = providerFor(j);
220
+ if (!provider || !snapshots[provider]) return;
221
+ const status = usageStatus(snapshots[provider], usageNow);
222
+ if (status.level === "over") held.push({ i, j, provider, status });
223
+ });
224
+ const usageNotes = [];
225
+ let failedProviders = new Set();
226
+ let refreshError = null;
227
+ if (held.some((item) => item.status.ageMinutes >= USAGE_STALE_MINUTES)) {
228
+ const refreshed = await refreshStaleOverLimitReadings(stateRoot, { run: deps.usageRun, now: usageNow, snapshots, timeoutMs: deps.usageRefreshTimeoutMs });
229
+ snapshots = refreshed.snapshots;
230
+ refreshError = refreshed.error;
231
+ failedProviders = new Set(refreshed.failedProviders ?? []);
232
+ }
233
+ const cleared = new Set();
234
+ for (const item of held) {
235
+ const status = snapshots[item.provider] ? usageStatus(snapshots[item.provider], usageNow) : item.status;
236
+ if (status.level !== "over") {
237
+ if (!cleared.has(item.provider)) { cleared.add(item.provider); usageNotes.push(staleUsageRefreshedNote(item.provider)); }
238
+ continue;
239
+ }
240
+ const extra = failedProviders.has(item.provider) ? ` ${refreshError ?? "OpenClaw usage refresh failed"}.` : "";
241
+ problems.push(`${jobs.length > 1 ? `job ${item.i + 1}: ` : ""}agent "${item.j.agentName}" is held at its usage limit: ${status.text} (reading ${status.ageMinutes} minutes old).${extra} Ask the operator before resubmitting with confirm_over_limit: true, or send the job to another agent.`);
242
+ }
172
243
  // A pool-routed job is checked against that pool's OWN (model-dependent)
173
244
  // budget, not the local-derived global one -- see budgetsForPool. Which
174
245
  // specific entry pickProvider will land on isn't known yet at admission
@@ -178,12 +249,36 @@ export function createJobRuntime(deps) {
178
249
  // (see budgetsForSubscriptionWorker) -- there's no "which entry" unknown
179
250
  // the way a weighted pool has, since the name given IS the entry.
180
251
  jobs.forEach((j, i) => {
252
+ if (j.mode === "verify") {
253
+ try {
254
+ const profiles = Object.keys(loadConfig(projectDir)?.config?.verification ?? {});
255
+ if (!j.verification || !profiles.includes(j.verification)) problems.push(`${jobs.length > 1 ? `job ${i + 1}: ` : ""}verify requires a verification profile from .nomarmy.yml; available profiles: ${profiles.join(", ") || "(none)"}`);
256
+ } catch (error) { problems.push(error.message); }
257
+ return;
258
+ }
181
259
  const jobBudgets = budgetsForJob(j);
182
260
  for (const p of checkBrief(j, jobBudgets)) problems.push(jobs.length > 1 ? `job ${i + 1}: ${p}` : p);
183
261
  });
184
262
  // verify_regression re-runs `verification`; with no profile set there is
185
263
  // nothing to re-run. Refuse before starting anything, matching every other
186
264
  // admission check here, rather than silently no-op at runtime.
265
+ // stakes applies to what gets committed; reviews names the job a scout reviews.
266
+ jobs.forEach((j, i) => {
267
+ const at = jobs.length > 1 ? `job ${i + 1}: ` : "";
268
+ if (j.stakes === "high" && (j.mode ?? "implement") !== "implement") problems.push(`${at}stakes: high applies to implement jobs; for a review, send a scout with reviews: <job id>`);
269
+ if (j.reviews) {
270
+ if (j.mode !== "scout") problems.push(`${at}reviews: <job id> marks a scout as a review of that job; this job is ${j.mode ?? "implement"}`);
271
+ else if (!fs.existsSync(path.join(jobsRoot, j.reviews, "metadata.json"))) problems.push(`${at}reviews: no finished job ${j.reviews} to review (see local_worker_jobs)`);
272
+ }
273
+ });
274
+ // continue_from: the retained job must exist, be unfinished and uncommitted,
275
+ // and belong to this repo; see lib/continue-from.mjs.
276
+ jobs.forEach((j, i) => {
277
+ if (!j.continue_from) return;
278
+ const { problem } = continuationProblem({ continueFrom: j.continue_from, mode: j.mode, baseRef: j.base_ref ?? null, jobsRoot, projectDir,
279
+ isRunning: (id) => activeJobs.get(id)?.settled === false });
280
+ if (problem) problems.push(`${jobs.length > 1 ? `job ${i + 1}: ` : ""}${problem}`);
281
+ });
187
282
  jobs.forEach((j, i) => {
188
283
  if (j.verify_regression && !j.verification) {
189
284
  problems.push(`${jobs.length > 1 ? `job ${i + 1}: ` : ""}verify_regression requires a verification profile; there is nothing to run twice without one`);
@@ -196,6 +291,7 @@ export function createJobRuntime(deps) {
196
291
  // clean, so a missing on_behalf_of is never reported twice in two
197
292
  // different shapes.
198
293
  jobs.forEach((j, i) => {
294
+ if (j.mode === "verify") return;
199
295
  const fieldProblems = subscriptionJobFieldProblems(j);
200
296
  for (const p of fieldProblems) problems.push(jobs.length > 1 ? `job ${i + 1}: ${p}` : p);
201
297
  if (fieldProblems.length === 0 && j.on_behalf_of) {
@@ -211,26 +307,63 @@ export function createJobRuntime(deps) {
211
307
  // Claude CLI) isn't bounded by the sandbox, so it's refused unless that
212
308
  // agent says allow_host_tools (lib/agents.mjs). Scouts and reviews still run.
213
309
  jobs.forEach((j, i) => {
214
- if (!j.agentName || (j.mode ?? "implement") !== "implement") return;
310
+ if (j.mode === "verify" || !j.agentName || (j.mode ?? "implement") !== "implement") return;
215
311
  let problem = null;
216
312
  try { problem = hostToolsImplementProblem(j.agentName, agentsConfig().agents[j.agentName]); } catch { return; }
217
313
  if (problem) problems.push(`${jobs.length > 1 ? `job ${i + 1}: ` : ""}${problem}`);
218
314
  });
219
- // A model its vendor refused on a job today, with nothing working on it
220
- // since, isn't sent another job (lib/health.mjs recentModelRefusal).
315
+ // Re-test stale refusals once per day before making an admission decision.
316
+ const now = deps.now?.() ?? Date.now();
317
+ const refusedJobs = new Map();
221
318
  jobs.forEach((j, i) => {
222
- if (!j.agentName || !j.model) return;
319
+ if (j.mode === "verify" || !j.agentName || !j.model) return;
223
320
  let provider = null;
224
321
  try { provider = agentProviderId(agentsConfig().agents[j.agentName]); } catch { return; }
225
322
  if (!provider) return;
226
- const refusal = recentModelRefusal(stateRoot, `${provider}/${j.model}`);
227
- if (refusal) problems.push(`${jobs.length > 1 ? `job ${i + 1}: ` : ""}model_not_found: ${provider}/${j.model} was refused on an earlier job today and hasn't worked since, so this job wasn't sent. Use another model (the job's \`model\`, or \`nomarmy army assign\`); \`nomarmy army assign <role> ${j.agentName} ${j.model}\` re-tests it, and a passing test clears this.`);
323
+ const key = `${provider}/${j.model}`;
324
+ if (!recentModelRefusal(stateRoot, key, { now })) return;
325
+ if (!refusedJobs.has(key)) refusedJobs.set(key, { provider, model: j.model, jobs: [] });
326
+ refusedJobs.get(key).jobs.push(i);
228
327
  });
328
+ await Promise.all([...refusedJobs.entries()].map(async ([key, entry]) => {
329
+ const hadRefusal = Boolean(modelRefusals(stateRoot)[key]);
330
+ if (hadRefusal && deps.probeModel && claimModelRefusalRetry(stateRoot, key, { now })) {
331
+ try {
332
+ const result = await deps.probeModel({ provider: entry.provider, model: entry.model, stateRoot });
333
+ if (result.ok) { recordProbeSuccess(stateRoot, key, { now }); return; }
334
+ if (result.refused) recordModelRefusal(stateRoot, key, result.reason, { now });
335
+ } catch { /* A failed probe is inconclusive; keep the original refusal. */ }
336
+ }
337
+ for (const i of entry.jobs) problems.push(`${jobs.length > 1 ? `job ${i + 1}: ` : ""}model_not_found: ${key} was refused on an earlier job and hasn't worked since, so this job wasn't sent. Use another model or re-test with nomarmy army assign.`);
338
+ }));
339
+ // A model that has never worked for its agent gets one test call first
340
+ // (lib/model-probe.mjs): a refusal then costs seconds, not a whole job.
341
+ // The server wires the probe; without one (tests), nothing is probed.
342
+ if (deps.probeModel && !problems.length) {
343
+ const pending = new Map();
344
+ jobs.forEach((j, i) => {
345
+ if (j.mode === "verify" || !j.agentName || !j.model) return;
346
+ let provider = null;
347
+ try { provider = agentProviderId(agentsConfig().agents[j.agentName]); } catch { return; }
348
+ const key = provider && `${provider}/${j.model}`;
349
+ if (!key || modelProven(stateRoot, key) || modelRefusals(stateRoot)[key]) return;
350
+ if (!pending.has(key)) pending.set(key, { provider, model: j.model, jobs: [] });
351
+ pending.get(key).jobs.push({ i, agentName: j.agentName });
352
+ });
353
+ await Promise.all([...pending.entries()].map(async ([key, p]) => {
354
+ let result;
355
+ try { result = await deps.probeModel({ provider: p.provider, model: p.model, stateRoot }); } catch { return; }
356
+ if (result.ok) { recordProbeSuccess(stateRoot, key); return; }
357
+ if (!result.refused) return; // inconclusive: let the job find out
358
+ recordModelRefusal(stateRoot, key, result.reason);
359
+ for (const { i, agentName } of p.jobs) problems.push(`${jobs.length > 1 ? `job ${i + 1}: ` : ""}model_not_found: ${key} refused a test call before this job was sent${result.reason ? ` (${result.reason})` : ""}. Pick another of ${agentName}'s models (the job's \`model\`; the \`army\` tool lists them).`);
360
+ }));
361
+ }
229
362
  // An agent's max_concurrent, machine-wide. Batch jobs on the same agent
230
363
  // queue for its slot at launch instead (withAgentSlot's waitMs).
231
364
  if (jobs.length === 1) {
232
365
  const [j] = jobs;
233
- const max = j.agentName ? agentMaxConcurrent(j.agentName) : null;
366
+ const max = j.mode !== "verify" && j.agentName ? agentMaxConcurrent(j.agentName) : null;
234
367
  const held = max ? liveSlots(slotsRoot, j.agentName) : 0;
235
368
  if (max && held >= max) problems.push(`not admitted (capacity): agent "${j.agentName}" already has ${held} job(s) running across this machine's nomArmy sessions, at its max_concurrent of ${max}`);
236
369
  }
@@ -241,7 +374,7 @@ export function createJobRuntime(deps) {
241
374
  const run = loadRun(runsRoot, j.run_id);
242
375
  if (run.repo !== projectDir) problems.push(`${jobs.length > 1 ? `job ${i + 1}: ` : ""}run "${run.id}" belongs to ${run.repo}, not this repository`);
243
376
  const running = liveLeases(leasesRoot, { runId: run.id }).length + jobs.slice(0, i).filter((o) => o.run_id === run.id).length;
244
- for (const p of runAdmissionProblems(run, { agentName: j.agentName ?? "local", running })) problems.push(jobs.length > 1 ? `job ${i + 1}: ${p}` : p);
377
+ for (const p of runAdmissionProblems(run, { agentName: j.mode === "verify" ? null : j.agentName ?? "local", running })) problems.push(jobs.length > 1 ? `job ${i + 1}: ${p}` : p);
245
378
  } catch (error) { problems.push(jobs.length > 1 ? `job ${i + 1}: ${error.message}` : error.message); }
246
379
  });
247
380
  // Slot capacity only concerns local jobs: a remote job's inference runs
@@ -254,17 +387,31 @@ export function createJobRuntime(deps) {
254
387
  const anyLocal = jobs.some((j) => jobLane(j) === "local");
255
388
  const admission = anyLocal
256
389
  ? assessAdmission({ hardware: admissionHardware(), runningJobs: runningCount("local"), slots: deps.budgetState.contextInfo.slots, maxWorkers: currentMaxWorkers() })
257
- : assessAdmission({ hardware: admissionHardware(), runningJobs: 0, slots: null, maxWorkers: Infinity });
390
+ : assessAdmission({ hardware: jobs.every(j => j.mode === "verify") ? null : admissionHardware(), runningJobs: 0, slots: null, maxWorkers: Infinity });
258
391
  if (!admission.admit) problems.push(...admission.reasons.map(r => `not admitted (${admission.level}): ${r}`));
259
392
  if (jobs.some((j) => jobLane(j) === "remote")) {
260
393
  const remoteCeiling = currentMaxPoolWorkers(), runningRemote = runningCount("remote");
261
394
  if (runningRemote >= remoteCeiling) {
262
- problems.push(`not admitted (capacity): ${runningRemote} remote job(s) (api or subscription agents) already running, at NOMARMY_MAX_POOL_WORKERS=${remoteCeiling}`);
395
+ problems.push(`not admitted (capacity): ${runningRemote} remote job(s) (api or subscription agents) already running, the limit of ${remoteCeiling} at once (raise it with \`nomarmy config max-jobs <n>\`)`);
263
396
  }
264
397
  }
398
+ for (const note of usageNotes) admission.reasons.push(note);
265
399
  return { problems, admission };
266
400
  }
267
401
 
402
+ async function displayedCapacity() {
403
+ const now = deps.now?.() ?? Date.now();
404
+ const refreshed = await refreshStaleOverLimitReadings(stateRoot, { run: deps.usageRun, now, timeoutMs: deps.usageRefreshTimeoutMs });
405
+ const view = capacitySnapshot(refreshed.snapshots);
406
+ if (refreshed.error) {
407
+ for (const provider of refreshed.failedProviders ?? []) {
408
+ if (!view.usageLimits[provider]) continue;
409
+ view.usageLimits[provider] = { ...view.usageLimits[provider], text: `${view.usageLimits[provider].text}. ${refreshed.error}` };
410
+ }
411
+ }
412
+ return view;
413
+ }
414
+
268
415
  function refusal(problems) {
269
416
  return toolText(refusalText(problems, capacitySnapshot), true);
270
417
  }
@@ -285,17 +432,17 @@ export function createJobRuntime(deps) {
285
432
  */
286
433
  function recordJobInRun(args, jobId, result, error = null) {
287
434
  if (!args.run_id) return;
288
- const kind = args.pool ? "api" : args.subscription_worker ? "subscription" : "local";
435
+ const kind = args.mode === "verify" ? "verify" : args.pool ? "api" : args.subscription_worker ? "subscription" : "local";
289
436
  const m = result?.manifest ?? {};
290
437
  const errorLines = [m.worker?.error, error?.message,
291
438
  ...String(m.workerError ?? "").split(/\r?\n/).filter((l) => /error|limit|429/i.test(l))].filter(Boolean).join("\n");
292
- const usageLimit = kind === "local" ? null : detectUsageLimit(errorLines);
439
+ const usageLimit = (kind === "local" || kind === "verify") ? null : detectUsageLimit(errorLines);
293
440
  try {
294
441
  const before = runTotals(loadRun(runsRoot, args.run_id)).warnings;
295
442
  const updated = recordRunJob(runsRoot, args.run_id, {
296
- jobId, agent: args.agentName ?? "local", kind, model: args.model ?? null, role: args.armyRole ?? null, mode: args.mode,
297
- outcome: m.outcome ?? (error ? "ERROR" : null), costUsd: m.metrics?.worker_cost_usd ?? null,
298
- tokens: m.metrics?.worker_tokens_total ?? null, usageLimit,
443
+ jobId, agent: kind === "verify" ? null : args.agentName ?? "local", kind, model: kind === "verify" ? null : args.model ?? null, role: args.armyRole ?? null, mode: args.mode,
444
+ outcome: m.outcome ?? (error ? "ERROR" : null), costUsd: kind === "verify" ? 0 : m.metrics?.worker_cost_usd ?? null,
445
+ tokens: kind === "verify" ? 0 : m.metrics?.worker_tokens_total ?? null, usageLimit,
299
446
  });
300
447
  // A limit crossed or an agent paused by this job is worth interrupting for.
301
448
  const fresh = runTotals(updated).warnings.filter((w) => !before.includes(w) && /OVER|paused/.test(w));
@@ -311,7 +458,7 @@ export function createJobRuntime(deps) {
311
458
  function launch(args) {
312
459
  const workerId = args.worker_id || null;
313
460
  const jobId = slug(workerId || (args.mode === "scout" ? "scout" : "worker"));
314
- return trackInRun(args, track(jobId, { mode: args.mode, workerId: workerId || jobId, lane: jobLane(args), agent: args.agentName ?? null, runId: args.run_id ?? null, role: args.armyRole ?? null, model: args.model ?? null },
461
+ return trackInRun(args, track(jobId, { mode: args.mode, workerId: workerId || jobId, lane: jobLane(args), agent: args.agentName ?? null, runId: args.run_id ?? null, role: args.armyRole ?? null, model: args.model ?? null, label: jobLabel(args) },
315
462
  withAgentSlot(args, jobId, () => executeJob({ ...jobArgs(args, workerId), jobId }))));
316
463
  }
317
464
  // Best-effort progress signal for a job still mid-run: a plain "phase: worker,
@@ -357,12 +504,13 @@ export function createJobRuntime(deps) {
357
504
 
358
505
  async function summarize(entry, files, jobDir = null) {
359
506
  const status = files.status, meta = files.meta ?? files.failure;
360
- const elapsedSeconds = status?.startedAt ? Math.round((Date.now() - Date.parse(status.startedAt)) / 1000) : entry ? Math.round((Date.now() - Date.parse(entry.startedAt)) / 1000) : null;
507
+ const elapsedSeconds = jobElapsedSeconds({ status, meta, entry });
361
508
  const out = { jobId: entry?.jobId ?? status?.jobId ?? meta?.jobId ?? null, workerId: entry?.workerId ?? status?.workerId ?? meta?.workerId ?? null,
362
509
  mode: entry?.mode ?? status?.mode ?? meta?.mode ?? null, state: null, phase: status?.phase ?? "starting", elapsedSeconds,
363
510
  timeoutSeconds: status?.timeoutSeconds ?? null, coordinatorStatus: meta?.coordinatorStatus ?? null, outcome: meta?.outcome ?? null,
364
511
  reviewRequired: meta?.reviewRequired ?? null, issues: (meta?.issues ?? []).slice(0, 6), worktree: meta?.worktree ?? null, branch: meta?.branch ?? null,
365
512
  commit: meta?.commit?.sha ?? null, scout: meta?.scout ? { supported: meta.scout.supported, unsupported: meta.scout.unsupported } : null };
513
+ if (meta?.mode === "verify") Object.assign(out, { verification: meta.verification, baseRef: meta.baseRef, baseSha: meta.baseSha });
366
514
  if (entry && !entry.settled) out.state = "running";
367
515
  else if (entry?.error) { out.state = "failed"; out.error = String(entry.error.message ?? entry.error).split("\n")[0]; }
368
516
  else if (meta) out.state = "finished";
@@ -372,5 +520,5 @@ export function createJobRuntime(deps) {
372
520
  return out;
373
521
  }
374
522
 
375
- return { WORKER_START_STAGGER_MS, activeJobs, runningCount, agentMaxConcurrent, withAgentSlot, track, notifyJobFinished, capacitySnapshot, admit, refusal, runBrief, recordJobInRun, trackInRun, launch, liveProgress, summarize };
523
+ return { WORKER_START_STAGGER_MS, activeJobs, runningCount, agentMaxConcurrent, withAgentSlot, track, notifyJobFinished, capacitySnapshot, displayedCapacity, admit, refusal, runBrief, recordJobInRun, trackInRun, launch, liveProgress, summarize };
376
524
  }
package/lib/agents.mjs CHANGED
@@ -30,12 +30,14 @@ import fs from "node:fs";
30
30
  import path from "node:path";
31
31
  import YAML from "yaml";
32
32
  import { z } from "zod";
33
+ import { typeError, isMissingField, isUnknownDiscriminator } from "./zod-issues.mjs";
33
34
 
34
35
  import { providerEntrySchema, formatDispatchIssues, openclawProviderId, PROVIDER_TYPES, ID_RE, HOST_TOOL_PROVIDERS, agentRunsToolsOnHost } from "./dispatch-schema.mjs";
35
36
 
36
37
  export { HOST_TOOL_PROVIDERS, agentRunsToolsOnHost };
37
38
  import { findProviderConflicts, describeProviderConflict } from "./subscription-config.mjs";
38
39
  import { privateConfigProblem } from "./army.mjs";
40
+ import { THINKING_LEVELS } from "./thinking.mjs";
39
41
 
40
42
  export const AGENTS_FILENAME = "agents.yml";
41
43
  export const AGENT_KINDS = Object.freeze(["local", "api", "subscription"]);
@@ -45,10 +47,10 @@ export const RESERVED_AGENT_NAMES = Object.freeze(["__proto__", "constructor", "
45
47
  export const BUILTIN_LOCAL_AGENT = Object.freeze({ kind: "local", slot: "coder" });
46
48
 
47
49
  const requiredString = () =>
48
- z.string({ required_error: "is required", invalid_type_error: "must be a string" })
50
+ z.string({ error: typeError("a string") })
49
51
  .refine((value) => value.trim().length > 0, { message: "must not be empty" });
50
- const thinkingSchema = z.union([z.boolean(), z.enum(["low", "medium", "high"])]).default(true);
51
- const positiveInt = () => z.number({ invalid_type_error: "must be a number" }).int("must be a whole number").positive("must be a positive number");
52
+ const thinkingSchema = z.union([z.boolean(), z.enum(THINKING_LEVELS)]).default(true);
53
+ const positiveInt = () => z.number({ error: typeError("a number") }).int("must be a whole number").positive("must be a positive number");
52
54
 
53
55
  const localAgentSchema = z.object({
54
56
  kind: z.literal("local"),
@@ -62,7 +64,7 @@ const localAgentSchema = z.object({
62
64
  // providerEntrySchema in agentsFileSchema's superRefine, so they exist once.
63
65
  const apiAgentSchema = z.object({
64
66
  kind: z.literal("api"),
65
- provider: z.enum(API_PROVIDER_TYPES, { errorMap: () => ({ message: `must be one of ${API_PROVIDER_TYPES.join(", ")}` }) }),
67
+ provider: z.enum(API_PROVIDER_TYPES, { error: `must be one of ${API_PROVIDER_TYPES.join(", ")}` }),
66
68
  model: requiredString().optional(),
67
69
  auth_env: requiredString(),
68
70
  base_url: z.string().optional(),
@@ -95,7 +97,7 @@ export function hostToolsImplementProblem(name, agent) {
95
97
  }
96
98
 
97
99
  export const agentSchema = z.discriminatedUnion("kind", [localAgentSchema, apiAgentSchema, subscriptionAgentSchema], {
98
- errorMap: (issue, ctx) => (issue.code === "invalid_union_discriminator" ? { message: `kind must be one of ${AGENT_KINDS.join(", ")}` } : { message: ctx.defaultError }),
100
+ error: (issue) => (isUnknownDiscriminator(issue) ? `kind must be one of ${AGENT_KINDS.join(", ")}` : undefined),
99
101
  });
100
102
 
101
103
  /** An api agent as the pool entry the dispatch path understands. `model` may be absent (see resolveAgentModel). */
@@ -148,7 +150,7 @@ export function formatAgentIssues(error) {
148
150
  const lines = error.issues.map((issue) => {
149
151
  const where = issue.path.length ? issue.path.join(".") : "config";
150
152
  if (issue.code === "unrecognized_keys") return `${where}: unexpected field(s) ${issue.keys.map((k) => `"${k}"`).join(", ")}`;
151
- if (issue.code === "invalid_type" && issue.received === "undefined") return `${where}: is required`;
153
+ if (isMissingField(issue)) return `${where}: is required`;
152
154
  return `${where}: ${issue.message}`;
153
155
  });
154
156
  return [...new Set(lines)];
package/lib/army.mjs CHANGED
@@ -29,7 +29,9 @@ import os from "node:os";
29
29
  import path from "node:path";
30
30
  import YAML from "yaml";
31
31
  import { z } from "zod";
32
+ import { typeError } from "./zod-issues.mjs";
32
33
  import { agentRunsToolsOnHost } from "./dispatch-schema.mjs";
34
+ import { usageStatus, usageDisplayText } from "./usage-limits.mjs";
33
35
 
34
36
  export const GLOBAL_CONFIG_FILENAME = "config.yml";
35
37
  export const PROJECT_CONFIG_FILENAMES = Object.freeze([".nomarmy.yml", ".nomarmy.yaml"]);
@@ -115,7 +117,7 @@ const roleSchema = z.object({
115
117
  disabled: z.boolean().optional(),
116
118
  }).strict();
117
119
 
118
- const positiveNumber = () => z.number({ invalid_type_error: "must be a number" }).positive("must be positive");
120
+ const positiveNumber = () => z.number({ error: typeError("a number") }).positive("must be positive");
119
121
  // Limits on one /feature run (lib/runs.mjs). Personal, like the General:
120
122
  // global or local only, never a committed project file.
121
123
  const runLimitsSchema = z.object({
@@ -152,11 +154,13 @@ function formatIssues(error) {
152
154
  });
153
155
  }
154
156
 
155
- // The global and local files hold only `army:` for now (the global one is
156
- // where machine-level settings such as the local model will move later);
157
- // strict, so a typo'd key is an error rather than silently ignored. The
158
- // project file is .nomarmy.yml, whose other sections lib/schema.mjs owns.
157
+ // Global configuration is army-only; the local file also admits the separately
158
+ // validated verification network policy. Both stay strict against unknown keys.
159
+ // The project file is .nomarmy.yml, whose other sections lib/schema.mjs owns.
159
160
  const armyOnlyFileSchema = z.object({ army: armySchema.optional() }).strict();
161
+ // Verification owns validation and use of this operator-local section. Army
162
+ // updates must preserve it without permitting it in global configuration.
163
+ const localArmyFileSchema = armyOnlyFileSchema.extend({ verification_network: z.unknown().optional() });
160
164
 
161
165
  /** Parse one layer's file and validate its `army:` section. Missing file or no section -> null. */
162
166
  export function readArmyFile(filePath, { armyOnly = false } = {}) {
@@ -168,7 +172,7 @@ export function readArmyFile(filePath, { armyOnly = false } = {}) {
168
172
  throw new ArmyConfigError(`${filePath} is not valid YAML: ${error.message}`, { path: filePath, errors: [`config: is not valid YAML: ${error.message}`] });
169
173
  }
170
174
  const doc = parsed ?? {};
171
- const result = armyOnly ? armyOnlyFileSchema.safeParse(doc) : armySchema.optional().safeParse(doc.army);
175
+ const result = armyOnly ? (path.basename(filePath) === LOCAL_CONFIG_FILENAME ? localArmyFileSchema : armyOnlyFileSchema).safeParse(doc) : armySchema.optional().safeParse(doc.army);
172
176
  if (result.success) return (armyOnly ? result.data.army : result.data) ?? null;
173
177
  const errors = formatIssues(result.error).map((line) => (armyOnly ? line : `army.${line}`));
174
178
  throw new ArmyConfigError(`${filePath} has an invalid army section:\n${errors.map((l) => ` - ${l}`).join("\n")}`, { path: filePath, errors });
@@ -361,11 +365,22 @@ export function generalOverlap(army, agents = {}) {
361
365
  * description, phase, suggested mode, agent, any problem or overlap with
362
366
  * the General, and which layer set each field.
363
367
  */
364
- export function describeArmy(loaded, { agents = {}, describeAgent = null } = {}) {
368
+ export function describeArmy(loaded, { agents = {}, describeAgent = null, usageSnapshots = null, agentProviderId = null, now = Date.now(), usageRefreshError = null, usageRefreshFailed = null } = {}) {
365
369
  const army = loaded.army;
366
370
  const problems = armyTargetProblems(army, agents);
367
371
  const overlap = generalOverlap(army, agents);
368
372
  const runsOn = (name) => (name && agents[name] && describeAgent ? describeAgent(agents[name]) : null);
373
+ const usage = (name) => {
374
+ if (!name || !agents[name] || !usageSnapshots || !agentProviderId) return null;
375
+ let provider;
376
+ try { provider = agentProviderId(agents[name]); } catch { return null; }
377
+ const snapshot = usageSnapshots[provider];
378
+ if (!snapshot) return null;
379
+ const status = usageStatus(snapshot, now);
380
+ const failed = new Set(usageRefreshFailed ?? []);
381
+ const text = failed.has(provider) && usageRefreshError ? `${usageDisplayText(status)}. ${usageRefreshError}` : usageDisplayText(status);
382
+ return { level: status.level, text };
383
+ };
369
384
  // A subscription whose owner isn't the General's own is worth a word,
370
385
  // not a refusal: it may be the same person's other account (a real Senti
371
386
  // General skipped a role's agent for exactly this, unsure whose it was).
@@ -378,7 +393,7 @@ export function describeArmy(loaded, { agents = {}, describeAgent = null } = {})
378
393
  const roles = Object.fromEntries(Object.entries(army.roles).map(([name, role]) => [name, {
379
394
  description: role.description ?? null, phase: role.phase ?? null, mode: role.mode ?? null,
380
395
  agent: role.agent ?? null, model: role.model ?? agents[role.agent]?.model ?? null, modelIsAuto: role.model === "auto",
381
- agentRunsOn: runsOn(role.agent),
396
+ agentRunsOn: runsOn(role.agent), usage: usage(role.agent),
382
397
  // Its agent's own tools run on this machine (dispatch-schema.mjs): an
383
398
  // implement role there is refused per job unless the agent allows it.
384
399
  hostTools: agentRunsToolsOnHost(agents[role.agent]) ? { allowed: agents[role.agent].allow_host_tools === true, implementRole: (role.mode ?? "implement") === "implement" } : null,
@@ -388,13 +403,13 @@ export function describeArmy(loaded, { agents = {}, describeAgent = null } = {})
388
403
  ? "not defined -- run `nomarmy army general <agent>` so nomArmy can flag roles that share the General's model or usage"
389
404
  : !Object.prototype.hasOwnProperty.call(agents, army.general) ? `agent "${army.general}" is not defined in your agents.yml` : null;
390
405
  return {
391
- general: { ...GENERAL, agent: army.general, agentRunsOn: runsOn(army.general), problem: generalProblem, setBy: loaded.sources.general },
406
+ general: { ...GENERAL, agent: army.general, agentRunsOn: runsOn(army.general), usage: usage(army.general), problem: generalProblem, setBy: loaded.sources.general },
392
407
  workflow: army.workflow,
393
408
  runLimits: army.runLimits ?? {},
394
409
  roles,
395
410
  layers: loaded.layers,
396
411
  howToDispatch: Object.keys(roles).length
397
- ? "Pass army_role: \"<role>\" on a job (plus on_behalf_of when its agent is a subscription). The role's description goes at the top of the brief; set `mode` on the job yourself, the role's is only a suggestion. A role with model \"auto\" needs model on the job, picked from that agent's models below; any job may pass model to override the role's. Use agent: \"<name>\" instead to pick an agent directly."
412
+ ? "Pass army_role: \"<role>\" on a job (plus on_behalf_of when its agent is a subscription). The role's description goes at the top of the brief; set `mode` on the job yourself, the role's is only a suggestion. A role with model \"auto\" needs model on the job, picked from that agent's models below (never its refusedModels: its vendor turned those down on a job); any job may pass model to override the role's. Use agent: \"<name>\" instead to pick an agent directly."
398
413
  : "No army is configured. Run `nomarmy army init` for the default roster, or dispatch with agent: \"<name>\".",
399
414
  };
400
415
  }
@@ -0,0 +1,37 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { parseOpenclawAuthProfiles } from "./health.mjs";
4
+ import { CODEX_IMPORT_ARGS, CODEX_IMPORT_RECOVERY, emailOpenaiProfiles, usableCodexImport } from "./openclaw-runtime-health.mjs";
5
+
6
+ // Never relay migration output: --include-secrets may expose credential data.
7
+ export async function linkCodex({ run, command = "openclaw", isTTY = false, removeEmailProfiles = false, confirm = async () => false, print = () => {}, codexDir = path.join(os.homedir(), ".codex"), now = Date.now() }) {
8
+ const read = async () => {
9
+ const result = await run(command, ["models", "auth", "list", "--json"]);
10
+ return result.ok ? parseOpenclawAuthProfiles(result.stdout) : null;
11
+ };
12
+ const profiles = await read();
13
+ if (!profiles) { print("Cannot inspect OpenClaw auth profiles; nothing imported."); return false; }
14
+ const emails = emailOpenaiProfiles(profiles);
15
+ if (emails.length) {
16
+ print(`Email-keyed profiles will capture the Codex import: ${emails.map((p) => p.id).join(", ")}.`);
17
+ if (!removeEmailProfiles && !(isTTY && await confirm("Remove these email-keyed profiles before importing?", { defaultYes: true }))) {
18
+ print("Stopped; use --remove-email-profiles to allow removal non-interactively.");
19
+ return false;
20
+ }
21
+ for (const p of emails) {
22
+ if (!(await run(command, ["models", "auth", "logout", p.id])).ok) { print("OpenClaw profile logout failed; nothing imported."); return false; }
23
+ }
24
+ const remaining = await read();
25
+ if (!remaining || emailOpenaiProfiles(remaining).length) { print("Email-keyed profiles still exist; nothing imported."); return false; }
26
+ }
27
+ print(`Linking OpenClaw: ${CODEX_IMPORT_RECOVERY}`);
28
+ const args = CODEX_IMPORT_ARGS.map((arg) => arg === "~/.codex" ? codexDir : arg);
29
+ if (!(await run(command, args)).ok) { print("Codex import failed."); return false; }
30
+ const imported = await read();
31
+ if (!imported || emailOpenaiProfiles(imported).length || !usableCodexImport(imported, now)) {
32
+ print("Sign-in has no usable auth profile: expected an unexpired openai:account- profile labeled (Codex import).");
33
+ return false;
34
+ }
35
+ print("Confirmed an unexpired openai:account- (Codex import) profile.");
36
+ return true;
37
+ }
package/lib/config.mjs CHANGED
@@ -10,6 +10,8 @@
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
12
12
  import YAML from "yaml";
13
+ import { validateEnabledHarnesses } from "./harnesses.mjs";
14
+ import { readRegistrySecrets } from "./registry-secrets.mjs";
13
15
 
14
16
  import {
15
17
  collectElevated,
@@ -125,6 +127,13 @@ export function validateConfig(input) {
125
127
  };
126
128
  }
127
129
 
130
+ if (Object.hasOwn(candidate, "verification_network")) {
131
+ return { valid: false, config: null, errors: ["verification_network is operator-local only: put it in gitignored .nomarmy.local.yml, never .nomarmy.yml"], elevated: emptyElevated() };
132
+ }
133
+ if (Object.hasOwn(candidate, "registries")) {
134
+ return { valid: false, config: null, errors: ["registries: allowed only in .nomarmy.local.yml, never in committed repository configuration"], elevated: emptyElevated() };
135
+ }
136
+
128
137
  const result = configSchema.safeParse(candidate);
129
138
  if (!result.success) {
130
139
  return {
@@ -135,6 +144,10 @@ export function validateConfig(input) {
135
144
  };
136
145
  }
137
146
 
147
+ try { validateEnabledHarnesses(result.data.harnesses); }
148
+ catch (error) {
149
+ return { valid: false, config: null, errors: [error.message], elevated: emptyElevated() };
150
+ }
138
151
  return {
139
152
  valid: true,
140
153
  config: result.data,
@@ -154,6 +167,8 @@ export function validateConfig(input) {
154
167
  * @returns {{ found: boolean, path: string|null, config: object|null, elevated: { shared: string[], remote: string[] } }}
155
168
  */
156
169
  export function loadConfig(repoDir = process.cwd()) {
170
+ try { readRegistrySecrets(repoDir); }
171
+ catch (error) { throw new ConfigError(error.message, { errors: [error.message] }); }
157
172
  const configPath = findConfigFile(repoDir);
158
173
  if (!configPath) {
159
174
  return { found: false, path: null, config: null, elevated: emptyElevated() };