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
@@ -15,10 +15,12 @@
15
15
  // never in a file nomArmy writes or reads back.
16
16
 
17
17
  import { z } from "zod";
18
+ import { typeError, isMissingField, isUnknownDiscriminator } from "./zod-issues.mjs";
19
+ import { THINKING_LEVELS } from "./thinking.mjs";
18
20
 
19
21
  const requiredString = () =>
20
22
  z
21
- .string({ required_error: "is required", invalid_type_error: "must be a string" })
23
+ .string({ error: typeError("a string") })
22
24
  .refine((value) => value.trim().length > 0, { message: "must not be empty" });
23
25
 
24
26
  // Exported (not just used internally) so bin/nomarmy.mjs's interactive
@@ -101,11 +103,11 @@ export function isNativeProviderType(type) {
101
103
  export const CUSTOM_ENDPOINT_PROVIDER_TYPES = Object.freeze(["bedrock", "azure-openai", "openai-compatible"]);
102
104
 
103
105
  const idSchema = z
104
- .string({ required_error: "is required", invalid_type_error: "must be a string" })
106
+ .string({ error: typeError("a string") })
105
107
  .regex(ID_RE, "must be 1-64 characters of letters, numbers, dot, underscore or hyphen");
106
108
 
107
109
  const weightSchema = z
108
- .number({ required_error: "is required", invalid_type_error: "must be a number" })
110
+ .number({ error: typeError("a number") })
109
111
  .positive("must be a positive number")
110
112
  .finite("must be a finite number");
111
113
 
@@ -113,13 +115,13 @@ const weightSchema = z
113
115
  // one entry at once -- a stand-in for real rate-limit-aware admission (see
114
116
  // README's dispatch-pool section for why that's out of scope for now).
115
117
  const maxConcurrentSchema = z
116
- .number({ invalid_type_error: "must be a number" })
118
+ .number({ error: typeError("a number") })
117
119
  .int("must be a whole number")
118
120
  .positive("must be a positive number")
119
121
  .default(2);
120
122
 
121
123
  const authEnvSchema = z
122
- .string({ required_error: "is required", invalid_type_error: "must be a string" })
124
+ .string({ error: typeError("a string") })
123
125
  .regex(
124
126
  AUTH_ENV_NAME_RE,
125
127
  "must be an environment variable NAME (uppercase letters, digits, underscores), never the credential itself",
@@ -127,7 +129,7 @@ const authEnvSchema = z
127
129
 
128
130
  const urlSchema = () =>
129
131
  z
130
- .string({ required_error: "is required", invalid_type_error: "must be a string" })
132
+ .string({ error: typeError("a string") })
131
133
  .url("must be a valid URL");
132
134
 
133
135
  // Whether/how the entry's model uses a thinking/reasoning mode. `true`
@@ -143,7 +145,7 @@ const urlSchema = () =>
143
145
  // OpenClaw names as supported (see parseUnsupportedThinkingError) -- so a
144
146
  // level this schema accepts is never a guarantee the provider/model
145
147
  // combination honors it today, only what to keep asking for.
146
- const thinkingSchema = z.union([z.boolean(), z.enum(["low", "medium", "high"])]).default(true);
148
+ const thinkingSchema = z.union([z.boolean(), z.enum(THINKING_LEVELS)]).default(true);
147
149
 
148
150
  // Optional override for the model's real context window, in tokens. Absent
149
151
  // (the common case), lib/dispatch-config.mjs looks this up from OpenClaw's
@@ -152,7 +154,7 @@ const thinkingSchema = z.union([z.boolean(), z.enum(["low", "medium", "high"])])
152
154
  // knows about yet (see lib/model-catalog.mjs), or an operator who wants to
153
155
  // be more conservative than the model's rated maximum.
154
156
  const contextWindowSchema = z
155
- .number({ invalid_type_error: "must be a number" })
157
+ .number({ error: typeError("a number") })
156
158
  .int("must be a whole number")
157
159
  .positive("must be a positive number")
158
160
  .optional();
@@ -189,7 +191,7 @@ const llamaCppEntrySchema = z
189
191
  // `agents add` installs it before registering the key.
190
192
  const genericOpenclawEntrySchema = hostedProviderSchema("openclaw", { requireBaseUrl: false }).extend({
191
193
  openclaw_provider: z
192
- .string({ required_error: "is required", invalid_type_error: "must be a string" })
194
+ .string({ error: typeError("a string") })
193
195
  .regex(OPENCLAW_PROVIDER_ID_RE, "must be an OpenClaw provider id (lowercase letters, digits, dot, underscore, hyphen)")
194
196
  .refine((id) => !PROVIDER_TYPES.includes(id) || NATIVE_PROVIDER_TYPES.includes(id), "names a nomArmy provider type with its own setup -- use that type instead"),
195
197
  plugin: z.string().regex(/^\S+$/, "must be one plugin install spec, e.g. clawhub:@openclaw/deepseek-provider").optional(),
@@ -205,7 +207,7 @@ export const providerEntrySchema = z.discriminatedUnion("provider", [
205
207
  export const poolsSchema = z.record(
206
208
  z.string().min(1, "pool name must not be empty"),
207
209
  z
208
- .array(providerEntrySchema, { invalid_type_error: "must be an array of provider entries" })
210
+ .array(providerEntrySchema, { error: typeError("an array of provider entries") })
209
211
  .min(1, "must list at least one provider entry"),
210
212
  );
211
213
 
@@ -250,11 +252,11 @@ export function formatDispatchIssues(error) {
250
252
  lines.push(`${where}: unexpected field(s) ${keys}`);
251
253
  continue;
252
254
  }
253
- if (issue.code === "invalid_union_discriminator") {
255
+ if (isUnknownDiscriminator(issue)) {
254
256
  lines.push(`${where}: must be one of ${PROVIDER_TYPES.join(", ")}`);
255
257
  continue;
256
258
  }
257
- if (issue.code === "invalid_type" && issue.received === "undefined") {
259
+ if (isMissingField(issue)) {
258
260
  lines.push(`${where}: is required`);
259
261
  continue;
260
262
  }
package/lib/doctor.mjs CHANGED
@@ -8,8 +8,9 @@
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
10
  import os from "node:os";
11
- import { spawn } from "node:child_process";
11
+ import { spawn, spawnSync } from "node:child_process";
12
12
  import { executionMode } from "./execution.mjs";
13
+ import { maxJobs, jobsThatFit, vmGibFor } from "./limits.mjs";
13
14
 
14
15
  const MIN_NODE_MAJOR = 18;
15
16
  const DEFAULT_LLAMA_HOST = "127.0.0.1";
@@ -144,12 +145,19 @@ export function checkGitLongPaths(facts) {
144
145
  * @param {{podmanFound: boolean}} facts
145
146
  * @returns {{ok: boolean, message: string, fix?: string}}
146
147
  */
148
+ /** How to install Podman here. On Windows and macOS it runs in a small VM that needs creating once. */
149
+ export function installPodmanHint(platform = process.platform) {
150
+ if (platform === "win32") return "Install Podman ('winget install RedHat.Podman'), then create its VM once: 'podman machine init --memory 8192' and 'podman machine start' (it uses WSL2)";
151
+ if (platform === "darwin") return "Install Podman ('brew install podman'), then create its VM once: 'podman machine init --memory 8192' and 'podman machine start'";
152
+ return "Install Podman with your distro's package manager (e.g. 'sudo apt install podman')";
153
+ }
154
+
147
155
  export function checkPodmanPresent(facts) {
148
156
  if (facts.podmanFound) return { ok: true, message: "podman is available." };
149
157
  return {
150
158
  ok: false,
151
159
  message: "podman executable not found.",
152
- fix: "Install Podman ('brew install podman' on macOS, or your distro's package manager on Linux) and ensure 'podman' is on your PATH.",
160
+ fix: `${installPodmanHint(facts.platform)}, and make sure 'podman' is on your PATH.`,
153
161
  };
154
162
  }
155
163
 
@@ -161,12 +169,62 @@ export function checkPodmanPresent(facts) {
161
169
  * @param {{podmanFound: boolean, podmanDaemonReachable: boolean, podmanDaemonError?: string|null}} facts
162
170
  * @returns {{ok: boolean, message: string, fix?: string}}
163
171
  */
172
+ // Below this the Podman VM (macOS, Windows) can't hold a sandbox, a
173
+ // verification run and an image build at once. Seen live at 2 GiB: every
174
+ // sandbox command a worker ran came back "Aborted", reads included.
175
+ export const MIN_PODMAN_VM_MB = 4096;
176
+ const RECOMMENDED_PODMAN_VM_MB = 8192;
177
+
178
+ /**
179
+ * Rootless Podman needs a range of subordinate IDs to run an image whose
180
+ * files belong to other users (nomArmy's does). Seen live after a VM
181
+ * restart: /etc/subuid and /etc/subgid emptied, one ID mapped, and every
182
+ * sandbox failed with "error during chown ... permission denied".
183
+ */
184
+ export function checkPodmanIdMappings(facts) {
185
+ const ids = facts.podmanIdMappings;
186
+ if (!ids || !ids.rootless) return { ok: true, message: "Podman ID mappings: not applicable (rootful, or Podman not answering)." };
187
+ if (ids.uid >= 2 && ids.gid >= 2) return { ok: true, message: "Podman has its subordinate ID ranges." };
188
+ return {
189
+ ok: false,
190
+ message: "Rootless Podman has no subordinate ID ranges (/etc/subuid, /etc/subgid are empty), so no sandbox can start: containers fail with \"error during chown ... permission denied\".",
191
+ fix: "nomarmy sandbox --repair (on Linux: add a range for your user to /etc/subuid and /etc/subgid, then run podman system migrate)",
192
+ };
193
+ }
194
+
195
+ /** The Podman VM's memory: only where Podman runs in a VM (macOS, Windows). */
196
+ export function checkPodmanMachineMemory(facts) {
197
+ const mb = facts.podmanMachineMemoryMb;
198
+ if (!Number.isFinite(mb)) return { ok: true, message: "Podman VM memory: not applicable (Podman runs natively here, or no machine is running)." };
199
+ const gib = (mb / 1024).toFixed(mb % 1024 ? 1 : 0);
200
+ if (mb >= MIN_PODMAN_VM_MB) return { ok: true, message: `Podman VM has ${gib} GiB of memory.` };
201
+ return {
202
+ ok: false,
203
+ message: `The Podman VM has only ${gib} GiB of memory. Sandboxes, verification and image builds share it, and at this size worker commands get cut off ("Aborted").`,
204
+ fix: `nomarmy sandbox --memory ${RECOMMENDED_PODMAN_VM_MB / 1024}`,
205
+ };
206
+ }
207
+
208
+ /** Whether the Podman VM fits the api and subscription jobs the operator allowed at once. */
209
+ export function checkPodmanVmFitsJobs(facts) {
210
+ const fit = jobsThatFit(facts.podmanMachineMemoryMb);
211
+ const limit = facts.maxJobs;
212
+ if (limit?.problem) return { ok: false, message: limit.problem, fix: "nomarmy config max-jobs <n>" };
213
+ if (fit === null || !limit) return { ok: true, message: "Jobs at once vs Podman VM: not applicable." };
214
+ if (limit.value <= fit) return { ok: true, message: `Up to ${limit.value} api and subscription jobs at once; the Podman VM fits about ${fit}.` };
215
+ return {
216
+ ok: false,
217
+ message: `Up to ${limit.value} api and subscription jobs may run at once, but the Podman VM (${(facts.podmanMachineMemoryMb / 1024).toFixed(0)} GiB) fits about ${fit} sandboxes; the rest get refused for memory or cut off.`,
218
+ fix: `nomarmy sandbox --memory ${vmGibFor(limit.value)} (or lower the limit: nomarmy config max-jobs ${fit})`,
219
+ };
220
+ }
221
+
164
222
  export function checkPodmanDaemon(facts) {
165
223
  if (!facts.podmanFound) {
166
224
  return {
167
225
  ok: false,
168
226
  message: "Podman check skipped: podman executable not found.",
169
- fix: "Install Podman ('brew install podman' on macOS, or your distro's package manager on Linux), then re-run 'nomarmy doctor'.",
227
+ fix: `${installPodmanHint(facts.platform)}, then re-run 'nomarmy doctor'.`,
170
228
  };
171
229
  }
172
230
  if (facts.podmanDaemonReachable) {
@@ -197,11 +255,11 @@ export function checkEndpoint(facts) {
197
255
  };
198
256
  }
199
257
  if (execution === "remote") return checkRemoteEndpoint(endpoint);
200
- if (execution === "local") return checkLocalEndpoint(endpoint);
258
+ if (execution === "local") return checkLocalEndpoint(endpoint, facts.platform);
201
259
  return {
202
260
  ok: false,
203
261
  message: `NOMARMY_EXECUTION '${execution}' is not recognized.`,
204
- fix: "Set NOMARMY_EXECUTION to 'local', 'remote', 'hosted', or 'bedrock' (see config/common.env).",
262
+ fix: "Set NOMARMY_EXECUTION to 'local', 'remote', 'hosted', or 'bedrock' (nomarmy setup --choose, or ~/.config/nomarmy/common.env).",
205
263
  };
206
264
  }
207
265
 
@@ -215,13 +273,14 @@ function checkRemoteEndpoint(endpoint) {
215
273
  };
216
274
  }
217
275
 
218
- function checkLocalEndpoint(endpoint) {
276
+ function checkLocalEndpoint(endpoint, platform = process.platform) {
219
277
  const { url, healthy, error } = endpoint;
220
278
  if (healthy) return { ok: true, message: `llama-server is healthy at ${url}.` };
221
279
  return {
222
280
  ok: false,
223
281
  message: `llama-server health check failed at ${url}${error ? `: ${error}` : "."}`,
224
- fix: "Start the local worker with './scripts/start-inference.sh' (after 'source scripts/lib.sh && load_profile <profile>'), then re-run 'nomarmy doctor'.",
282
+ // Most people run jobs on API and subscription agents: say how to choose that, not only how to start a model.
283
+ fix: `No local model is running. If your jobs run on API keys or subscriptions, choose that with 'nomarmy setup --choose' (hosted). To run a model on this machine: ${platform === "win32" ? "see docs/install.md#windows (the local model needs WSL2 or a native llama.cpp build)" : "'nomarmy start <profile>'"}. Then re-run 'nomarmy doctor'.`,
225
284
  };
226
285
  }
227
286
 
@@ -231,7 +290,7 @@ function checkBedrockEndpoint(endpoint) {
231
290
  return {
232
291
  ok: false,
233
292
  message: "NOMARMY_BEDROCK_REGION is not set.",
234
- fix: "Set NOMARMY_BEDROCK_REGION, e.g. export NOMARMY_BEDROCK_REGION=eu-west-2 (see config/profiles/bedrock.env).",
293
+ fix: "Set NOMARMY_BEDROCK_REGION, e.g. export NOMARMY_BEDROCK_REGION=eu-west-2 (or set it in ~/.config/nomarmy/common.env).",
235
294
  };
236
295
  }
237
296
  if (!regionValid) {
@@ -253,6 +312,27 @@ function checkBedrockEndpoint(endpoint) {
253
312
 
254
313
  // --- fact collection: every side effect the checks above need lives here ---
255
314
 
315
+ /** Rootless mode and how many UID/GID ranges Podman maps, or null. */
316
+ function probePodmanIdMappings(podmanPath, platform) {
317
+ try {
318
+ const result = spawnSync(podmanPath, ["info", "--format", "json"], { encoding: "utf8", timeout: 5000, shell: platform === "win32" && /\.(cmd|bat)$/i.test(podmanPath) });
319
+ const host = JSON.parse(result.stdout || "{}").host ?? {};
320
+ return { rootless: Boolean(host.security?.rootless), uid: host.idMappings?.uidmap?.length ?? 0, gid: host.idMappings?.gidmap?.length ?? 0 };
321
+ } catch { return null; }
322
+ }
323
+
324
+ /** The running Podman machine's memory in MiB, or null (Linux, or no machine). */
325
+ function probePodmanMachineMemory(podmanPath, platform) {
326
+ if (!["darwin", "win32"].includes(platform)) return null;
327
+ try {
328
+ const result = spawnSync(podmanPath, ["machine", "inspect"], { encoding: "utf8", timeout: 5000, shell: platform === "win32" && /\.(cmd|bat)$/i.test(podmanPath) });
329
+ const machines = JSON.parse(result.stdout || "[]");
330
+ const running = machines.find((m) => m.State === "running") ?? machines[0];
331
+ const mb = Number(running?.Resources?.Memory);
332
+ return Number.isFinite(mb) && mb > 0 ? mb : null;
333
+ } catch { return null; }
334
+ }
335
+
256
336
  /**
257
337
  * Probe whether podman actually answers, not just whether the CLI exists.
258
338
  * The output of `podman info` is not parsed here, only the exit code matters,
@@ -438,6 +518,9 @@ export async function collectFacts(env = process.env) {
438
518
  podmanFound: Boolean(podmanPath),
439
519
  podmanDaemonReachable: podmanDaemon.reachable,
440
520
  podmanDaemonError: podmanDaemon.error,
521
+ podmanMachineMemoryMb: podmanPath && podmanDaemon.reachable ? probePodmanMachineMemory(podmanPath, platform) : null,
522
+ maxJobs: maxJobs({ env }),
523
+ podmanIdMappings: podmanPath && podmanDaemon.reachable ? probePodmanIdMappings(podmanPath, platform) : null,
441
524
  execution,
442
525
  endpoint,
443
526
  };
@@ -455,6 +538,9 @@ export function evaluateChecks(facts) {
455
538
  { id: "git-longpaths", ...checkGitLongPaths(facts) },
456
539
  { id: "podman", ...checkPodmanPresent(facts) },
457
540
  { id: "podman-daemon", ...checkPodmanDaemon(facts) },
541
+ { id: "podman-vm-memory", ...checkPodmanMachineMemory(facts) },
542
+ { id: "podman-vm-jobs", ...checkPodmanVmFitsJobs(facts) },
543
+ { id: "podman-idmap", ...checkPodmanIdMappings(facts) },
458
544
  { id: "endpoint", ...checkEndpoint(facts) },
459
545
  ];
460
546
  }
@@ -467,7 +553,10 @@ export function evaluateChecks(facts) {
467
553
  export async function runDoctor(opts = {}) {
468
554
  const { json = false, exit = false, env = process.env } = opts;
469
555
  const facts = opts.facts ?? await collectFacts(env);
470
- const checks = evaluateChecks(facts);
556
+ const { runtimeIssues } = await import("./health.mjs");
557
+ const runtime = opts.runtime ? await runtimeIssues(opts.runtime) : [];
558
+ const checks = [...evaluateChecks(facts), ...(opts.additionalChecks ?? []),
559
+ ...runtime.map((issue) => ({ id: issue.id, ok: false, message: `${issue.title}. ${issue.detail}`, fix: issue.fix }))];
471
560
  const allOk = checks.every((c) => c.ok);
472
561
  const result = { ok: allOk, checks };
473
562
 
@@ -0,0 +1,116 @@
1
+ // Verification-only egress boundary. Built-ins only; safe to mount in the base image.
2
+ import { createHash, timingSafeEqual } from 'node:crypto';
3
+ import http from 'node:http';
4
+ import net from 'node:net';
5
+ import { lookup } from 'node:dns/promises';
6
+ import { pathToFileURL } from 'node:url';
7
+
8
+ export function target(value, defaultPort = 443) {
9
+ if (typeof value !== 'string' || value.length > 260) throw new Error('invalid target');
10
+ const match = /^([a-zA-Z0-9.-]+)(?::([0-9]{1,5}))?$/.exec(value);
11
+ if (!match) throw new Error('invalid target');
12
+ const host = match[1].toLowerCase(), port = Number(match[2] ?? defaultPort);
13
+ const labels = host.split('.');
14
+ if (net.isIP(host) || labels.length < 2 || labels.some(l => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(l)) ||
15
+ !/^[a-z]{2,63}$/.test(labels.at(-1)) ||
16
+ /(?:^|\.)(?:localhost|local|localdomain|internal|intranet|private|corp|lan|home|test|invalid|onion|arpa)$/.test(host) ||
17
+ port < 1 || port > 65535) throw new Error('invalid target');
18
+ return { host, port, authority: `${host}:${port}` };
19
+ }
20
+
21
+ // Conservative globally routable unicast only. Reject IPv6 transition/mapped
22
+ // forms too: they can encode IPv4 private destinations or depend on a relay.
23
+ export function publicAddress(address) {
24
+ if (net.isIP(address) === 4) {
25
+ const [a, b, c] = address.split('.').map(Number);
26
+ return !(a === 0 || a === 10 || a === 127 || a >= 224 || address === "168.63.129.16" ||
27
+ (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) ||
28
+ (a === 172 && b >= 16 && b <= 31) || (a === 192 && (b === 168 || b === 0 || (b === 88 && c === 99))) ||
29
+ (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
30
+ (a === 203 && b === 0 && c === 113));
31
+ }
32
+ if (net.isIP(address) !== 6) return false;
33
+ // 2000::/3, excluding special-purpose 2001::/23 and 2002::/16.
34
+ if (address.includes('.')) return false;
35
+ const halves = address.toLowerCase().split('::');
36
+ const left = halves[0] ? halves[0].split(':') : [];
37
+ const right = halves[1] ? halves[1].split(':') : [];
38
+ const parts = halves.length === 2 ? [...left, ...Array(8 - left.length - right.length).fill('0'), ...right] : left;
39
+ if (parseInt(parts[5], 16) === 0x5efe || parseInt(parts[4], 16) === 0x5efe) return false;
40
+ const first = parseInt(parts[0], 16), second = parseInt(parts[1] || '0', 16);
41
+ return first >= 0x2000 && first <= 0x3fff && first !== 0x2002 &&
42
+ !(first === 0x2001 && (second <= 0x1ff || second === 0xdb8)) &&
43
+ !(first === 0x3fff && second <= 0xfff);
44
+ }
45
+
46
+ export function createEgressProxy({ allow, token, resolve = host => lookup(host, { all: true }), log = () => {}, connect = opts => net.connect(opts), request = opts => http.request(opts) }) {
47
+ if (typeof token !== 'string' || !token) throw new Error('proxy token required');
48
+ const digest = value => createHash('sha256').update(value).digest();
49
+ const expected = digest('Basic ' + Buffer.from('nomarmy:' + token).toString('base64'));
50
+ const authenticated = req => timingSafeEqual(expected, digest(String(req.headers['proxy-authorization'] ?? '')));
51
+ const allowed = new Set(allow.map(v => target(v).authority));
52
+ async function authorize(value, port) {
53
+ let parsed;
54
+ try {
55
+ parsed = target(value, port);
56
+ if (!allowed.has(parsed.authority)) throw new Error('unlisted');
57
+ const addresses = await resolve(parsed.host);
58
+ if (!addresses.length || addresses.some(a => !publicAddress(a.address))) throw new Error('private address');
59
+ return { ...parsed, address: addresses[0].address };
60
+ } catch {
61
+ // Never echo malformed input (it may contain a URL password or payload).
62
+ log(`${parsed?.authority ?? 'invalid:0'} denied`);
63
+ throw new Error('denied');
64
+ }
65
+ }
66
+ const server = http.createServer(async (req, res) => {
67
+ if (!authenticated(req)) { res.writeHead(407, { 'Proxy-Authenticate': 'Basic realm="verification"' }); res.end(); return; }
68
+ let destination, url, checking = false;
69
+ try {
70
+ url = new URL(req.url);
71
+ if (url.protocol !== 'http:' || url.username || url.password || url.hash) throw new Error('invalid');
72
+ checking = true;
73
+ destination = await authorize(url.host, 80);
74
+ } catch { if (!checking) log("invalid:0 denied"); res.writeHead(403); res.end(); return; }
75
+ const headers = { ...req.headers, host: destination.authority };
76
+ for (const name of String(req.headers.connection ?? '').split(',').map(s => s.trim().toLowerCase())) delete headers[name];
77
+ for (const name of ['proxy-authorization', 'proxy-authenticate', 'connection', 'keep-alive', 'upgrade', 'te', 'trailer']) delete headers[name];
78
+ headers.host = destination.authority;
79
+ const upstream = request({ hostname: destination.address, port: destination.port, method: req.method, path: url.pathname + url.search, headers, agent: false });
80
+ upstream.on('socket', socket => socket.once('connect', () => log(`${destination.authority} connected`)));
81
+ upstream.on('response', reply => { res.writeHead(reply.statusCode, reply.headers); reply.pipe(res); });
82
+ upstream.on('error', () => { log(`${destination.authority} denied`); if (!res.headersSent) res.writeHead(502); res.end(); });
83
+ upstream.setTimeout(30_000, () => upstream.destroy());
84
+ req.on('aborted', () => upstream.destroy());
85
+ res.on('close', () => upstream.destroy());
86
+ req.pipe(upstream);
87
+ });
88
+ server.on('connect', async (req, client, head) => {
89
+ client.on('error', () => {});
90
+ if (!authenticated(req)) { client.end('HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="verification"\r\nConnection: close\r\n\r\n'); return; }
91
+ let destination;
92
+ try { destination = await authorize(req.url, 443); }
93
+ catch { client.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return; }
94
+ if (client.destroyed) return;
95
+ const upstream = connect({ host: destination.address, port: destination.port });
96
+ upstream.setTimeout(30_000, () => upstream.destroy());
97
+ upstream.on('error', () => { log(`${destination.authority} denied`); client.destroy(); });
98
+ client.on('close', () => upstream.destroy());
99
+ upstream.on('close', () => client.destroy());
100
+ upstream.on('connect', () => {
101
+ log(`${destination.authority} connected`);
102
+ client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
103
+ if (head.length) upstream.write(head);
104
+ client.pipe(upstream); upstream.pipe(client);
105
+ });
106
+ });
107
+ server.on('clientError', (_error, socket) => socket.destroy());
108
+ server.on('upgrade', (_req, socket) => socket.destroy());
109
+ server.headersTimeout = 10_000;
110
+ server.requestTimeout = 30_000;
111
+ return server;
112
+ }
113
+
114
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
115
+ createEgressProxy({ token: process.env.NOMARMY_EGRESS_TOKEN, allow: JSON.parse(process.env.NOMARMY_EGRESS_ALLOW), log: line => process.stdout.write(`${line}\n`) }).listen(3128, '0.0.0.0');
116
+ }