nomarmy 0.1.0-alpha.18 → 0.1.0-alpha.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/bin/nomarmy.mjs +100 -10
- package/docker/Dockerfile +2 -2
- package/docker/Dockerfile.go +1 -1
- package/docker/Dockerfile.rust +1 -1
- package/install.sh +9 -3
- package/lib/agents.mjs +2 -1
- package/lib/connect.mjs +32 -2
- package/lib/diff-checks.mjs +18 -0
- package/lib/dispatch-schema.mjs +2 -1
- package/lib/doctor.mjs +13 -5
- package/lib/execute.mjs +4 -4
- package/lib/notify.mjs +30 -3
- package/lib/openclaw-run.mjs +2 -1
- package/lib/path-utils.mjs +4 -0
- package/lib/registry-python.mjs +3 -1
- package/lib/registry-secrets.mjs +16 -4
- package/lib/same-path.mjs +14 -0
- package/lib/sandbox-images.mjs +20 -7
- package/lib/scan.mjs +5 -1
- package/lib/thinking.mjs +23 -0
- package/lib/validators.mjs +41 -0
- package/lib/wsl-cli.mjs +152 -0
- package/lib/wsl.mjs +230 -0
- package/mcp/server.mjs +30 -15
- package/package.json +1 -1
- package/scripts/select-model.mjs +4 -1
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ AI coding workers are confident. Their "done, all tests pass" is a claim, not ev
|
|
|
16
16
|
## TL;DR
|
|
17
17
|
|
|
18
18
|
1. **Have** Git, Node 24.16+ (or 26.1+) and [Podman](https://podman.io). On macOS, give Podman 8 GiB: `brew install podman && podman machine init --memory 8192 && podman machine start`.
|
|
19
|
+
Windows is supported with the engine in WSL2. Follow the [Windows setup](https://github.com/rayson-tech/nomarmy/blob/main/docs/install.md#windows).
|
|
19
20
|
2. **Install and set up:**
|
|
20
21
|
```bash
|
|
21
22
|
npm install -g nomarmy@alpha
|
|
@@ -74,7 +75,7 @@ Developed and maintained by Rayson Technologies. This is an alpha (`0.1.0-alpha`
|
|
|
74
75
|
|
|
75
76
|
| | |
|
|
76
77
|
|---|---|
|
|
77
|
-
| [Install](https://github.com/rayson-tech/nomarmy/blob/main/docs/install.md) | Every setup: hosted (API keys and subscriptions), a local model on macOS, Linux, Windows or DGX Spark, a shared model server, Bedrock |
|
|
78
|
+
| [Install](https://github.com/rayson-tech/nomarmy/blob/main/docs/install.md) | Every setup: hosted (API keys and subscriptions), a local model on macOS, Linux, [Windows with the engine in WSL2](https://github.com/rayson-tech/nomarmy/blob/main/docs/install.md#windows) or DGX Spark, a shared model server, Bedrock |
|
|
78
79
|
| [Example setup](https://github.com/rayson-tech/nomarmy/blob/main/docs/setup/example.md) | Claude Code, Codex and an API key, command by command |
|
|
79
80
|
| [Agents and the army](https://github.com/rayson-tech/nomarmy/blob/main/docs/agents-and-army.md) | Where a job can run, who does what, usage limits, picking an agent |
|
|
80
81
|
| [`/feature` runs](https://github.com/rayson-tech/nomarmy/blob/main/docs/feature-runs.md) | A feature end to end, and watching what nomArmy is doing |
|
package/bin/nomarmy.mjs
CHANGED
|
@@ -18,11 +18,13 @@ import { buildConfigProposal } from "../lib/propose.mjs";
|
|
|
18
18
|
import { detectHardware } from "../lib/hardware.mjs";
|
|
19
19
|
import { readGGUFMetadata, resolveModelPath, totalSplitBytes } from "../lib/gguf.mjs";
|
|
20
20
|
import { recommend, customRecommendation, evaluateConfig, bytesPerKvElementForCacheTypes, MIN_CONTEXT_PER_NOM } from "../lib/sizing.mjs";
|
|
21
|
-
import {
|
|
21
|
+
import { windowsFrontEnd, dropWindowsPath, markWindowsCoordinator, pickDistro, resolveWslNomarmy, mcpBridgeLaunch, writeWindowsSettings } from "../lib/wsl.mjs";
|
|
22
|
+
import { windowsPlan, windowsForward, windowsSetup, windowsDoctor } from "../lib/wsl-cli.mjs";
|
|
23
|
+
import { connectViaWsl, connectClaude, connectCodex, connectCursor, cursorAlreadyConnected, deriveWorkerModelEnv, defaultInstallDir, installMcpCopy, SCOPES, claudeUserScoped, portableServerLaunch } from "../lib/connect.mjs";
|
|
22
24
|
import { compareVersions, readPackageVersion, readInstallVersions, copyIsStale } from "../lib/install-freshness.mjs";
|
|
23
25
|
import { loadJobRecords, computeStats, formatStats, formatStatsSummary, parseSince, resolveRepo, agentLookup } from "../lib/stats.mjs";
|
|
24
26
|
import { requestJobStop } from "../lib/openclaw-run.mjs";
|
|
25
|
-
import { loadValidators, saveJevKey, removeJev, jevSettings, askJev, validatorsPath, JEV_CHECKS, saveJudge, removeJudge, judgeSettings } from "../lib/validators.mjs";
|
|
27
|
+
import { loadValidators, saveJevKey, removeJev, jevSettings, askJev, validatorsPath, JEV_CHECKS, saveJudge, removeJudge, judgeSettings, judgeAgentChoices, chooseJudgeAgent, confirmJudgeHostTools } from "../lib/validators.mjs";
|
|
26
28
|
import { probeModel } from "../lib/model-probe.mjs";
|
|
27
29
|
import { ID_RE, AUTH_ENV_NAME_RE, OPENCLAW_PROVIDER_ID_RE, openclawProviderId, isNativeProviderType } from "../lib/dispatch-schema.mjs";
|
|
28
30
|
import { loadAgents, readAgentsFile, writeAgentsFile, agentsConfigPath, apiAgentAsPoolEntry, describeAgent as describeAgentLabel, agentRunsToolsOnHost, agentProviderId, AGENT_KINDS, API_PROVIDER_TYPES, RESERVED_AGENT_NAMES, BUILTIN_LOCAL_AGENT } from "../lib/agents.mjs";
|
|
@@ -40,8 +42,22 @@ import { recordProbeSuccess } from "../lib/health.mjs";
|
|
|
40
42
|
import { pruneJobRuntime } from "../lib/prune.mjs";
|
|
41
43
|
import { SUBSCRIPTION_VENDORS, parseOpenclawVersion, versionAtLeast, parseCatalogModels, parseCliLoginStatus, probeOutcome, parseMuseAuthDescriptor, extractMintedKey } from "../lib/subscription-setup.mjs";
|
|
42
44
|
import { ensureOpenClawOnPath } from "../lib/openclaw-path.mjs";
|
|
45
|
+
import { THINKING_LEVELS } from "../lib/thinking.mjs";
|
|
46
|
+
import { fileURLToPath } from "node:url";
|
|
47
|
+
// Inside WSL, only the distro's own tools (see lib/wsl.mjs).
|
|
48
|
+
dropWindowsPath();
|
|
49
|
+
// The Windows front end: whatever it forwards knows the coordinators are on Windows.
|
|
50
|
+
if (windowsFrontEnd()) markWindowsCoordinator();
|
|
43
51
|
// OpenClaw in ~/.npm-global/bin (no writable npm prefix) is found without the operator editing PATH.
|
|
44
52
|
ensureOpenClawOnPath();
|
|
53
|
+
// Windows starts the engine as `wsl.exe --exec <node> nomarmy.mjs`, with no
|
|
54
|
+
// login shell, so an nvm Node's directory isn't on PATH. Children that need
|
|
55
|
+
// `node` (install.sh, OpenClaw's `#!/usr/bin/env node`) get the one running.
|
|
56
|
+
{
|
|
57
|
+
const nodeDir = path.dirname(process.execPath);
|
|
58
|
+
const entries = (process.env.PATH ?? "").split(path.delimiter);
|
|
59
|
+
if (!entries.includes(nodeDir)) process.env.PATH = [nodeDir, ...entries.filter(Boolean)].join(path.delimiter);
|
|
60
|
+
}
|
|
45
61
|
|
|
46
62
|
// Add a new coordinator: add its name here, teach commandExists/connectTarget
|
|
47
63
|
// about it below (a JSON-file target like Cursor has no PATH binary to check
|
|
@@ -71,7 +87,7 @@ const repoDir = path.resolve(value("repo", process.cwd()));
|
|
|
71
87
|
// through the job's requested reasoning); `--no-thinking` is always false.
|
|
72
88
|
// Returns undefined when none of these flags were passed at all, so the
|
|
73
89
|
// caller can tell "not touched" apart from "explicitly set".
|
|
74
|
-
|
|
90
|
+
// Every level OpenClaw accepts (lib/thinking.mjs).
|
|
75
91
|
function resolveThinkingFlag() {
|
|
76
92
|
const level = value("thinking");
|
|
77
93
|
if (level && THINKING_LEVELS.includes(level)) return level;
|
|
@@ -159,7 +175,7 @@ Usage: nomarmy <command> [options]
|
|
|
159
175
|
[--update-mcp] (api); --provider --model
|
|
160
176
|
--owner (subscription); and optionally
|
|
161
177
|
--max-concurrent --context-window
|
|
162
|
-
--thinking [low|medium|high] --no-thinking
|
|
178
|
+
--thinking [minimal|low|medium|high|xhigh|adaptive|max|ultra] --no-thinking
|
|
163
179
|
update <name>
|
|
164
180
|
change the model (picked from what OpenClaw
|
|
165
181
|
lists; a subscription gets a real test call),
|
|
@@ -252,7 +268,9 @@ Usage: nomarmy <command> [options]
|
|
|
252
268
|
and review flags. From verified records, never reports.
|
|
253
269
|
validators <list|add jev|test jev|remove jev|add judge|test judge|remove judge>
|
|
254
270
|
Optional semantic checks from a model you configure with
|
|
255
|
-
your own key.
|
|
271
|
+
your own key. Jev checks focused claims with TypeSafe;
|
|
272
|
+
Judge reviews diffs with one of your configured agents.
|
|
273
|
+
Setup asks for any consent it needs. \`add jev\` asks for the
|
|
256
274
|
key without echoing it (or reads --key-stdin), saves it
|
|
257
275
|
where only you can read it, and makes one test call. Its
|
|
258
276
|
answers only add review flags, and it sends excerpts of
|
|
@@ -463,7 +481,9 @@ async function cmdInit() {
|
|
|
463
481
|
// This file's own location, not --repo (the target repo being scanned) --
|
|
464
482
|
// setup/model need to find THIS package's config/ and scripts/ as siblings
|
|
465
483
|
// of bin/, the same way select-model.mjs resolves its own root.
|
|
466
|
-
|
|
484
|
+
// fileURLToPath, not URL.pathname: on Windows that gave "/C:/Users/Jason%20Pugh/...",
|
|
485
|
+
// a stray slash and an encoded space, and connect failed copying from C:\\C:\\...
|
|
486
|
+
const nomarmyRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
467
487
|
|
|
468
488
|
/** Read one KEY=VALUE line's value, or null if the file or key doesn't exist. */
|
|
469
489
|
function readEnvValue(filePath, key) {
|
|
@@ -653,6 +673,13 @@ function cmdInstall() {
|
|
|
653
673
|
}
|
|
654
674
|
|
|
655
675
|
async function cmdSetup() {
|
|
676
|
+
if (windowsFrontEnd()) {
|
|
677
|
+
process.exitCode = await windowsSetup(argv, { ask: async (prompt) => {
|
|
678
|
+
const rl = createInterface({ input, output });
|
|
679
|
+
try { return await rl.question(prompt); } finally { rl.close(); }
|
|
680
|
+
} });
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
656
683
|
if (flag("status") || (!flag("choose") && !flag("hosted") && !flag("llama-url") && !json)) {
|
|
657
684
|
if (flag("status") || !process.stdin.isTTY) {
|
|
658
685
|
const steps = setupChecklist();
|
|
@@ -1870,6 +1897,9 @@ async function cmdConnect() {
|
|
|
1870
1897
|
const requested = argv.slice(1).filter((a, i) => !a.startsWith("--") && !flagValues.has(i + 1));
|
|
1871
1898
|
const scope = value("scope", "user");
|
|
1872
1899
|
if (!SCOPES.includes(scope)) throw new Error(`--scope must be one of ${SCOPES.join(", ")}, got "${scope}".`);
|
|
1900
|
+
const nativeWindows = windowsFrontEnd();
|
|
1901
|
+
if (nativeWindows && scope !== "user") throw new Error("per-repo registration on Windows isn't supported yet; use the default --scope user");
|
|
1902
|
+
if (nativeWindows && flag("copy-only")) throw new Error("Windows runs nomArmy inside WSL; run nomarmy connect <target> instead of --copy-only");
|
|
1873
1903
|
let projectDir = null;
|
|
1874
1904
|
if (scope !== "user") {
|
|
1875
1905
|
try { projectDir = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
|
|
@@ -1904,6 +1934,17 @@ async function cmdConnect() {
|
|
|
1904
1934
|
}
|
|
1905
1935
|
try {
|
|
1906
1936
|
if (!json) console.log(c.bold(`\n🍪 Connecting nomArmy to ${target}...`));
|
|
1937
|
+
if (nativeWindows) {
|
|
1938
|
+
const capture = (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: ["ignore", "pipe", "pipe"], ...opts });
|
|
1939
|
+
const distro = pickDistro({ run: capture });
|
|
1940
|
+
const resolved = resolveWslNomarmy({ distro, run: capture });
|
|
1941
|
+
const launch = mcpBridgeLaunch({ distro, ...resolved });
|
|
1942
|
+
const result = connectViaWsl({ target, distro, launch, run, nomarmyRoot });
|
|
1943
|
+
writeWindowsSettings({ distro, ...resolved });
|
|
1944
|
+
results.push({ target, connected: true, ...result });
|
|
1945
|
+
if (!json) console.log(c.green(`✓ Registered nomArmy with ${target}, running inside WSL (${distro})`));
|
|
1946
|
+
continue;
|
|
1947
|
+
}
|
|
1907
1948
|
const result = connectTarget(target, { nomarmyRoot, run, scope, projectDir });
|
|
1908
1949
|
results.push({ target, connected: true, ...result });
|
|
1909
1950
|
if (!json) console.log(c.green(`✓ Registered nomarmy-local-worker with ${target}${scope === "user" ? "." : scope === "local" ? ` for ${projectDir} only (not committed).` : ` in ${path.relative(projectDir, result.configPath ?? path.join(projectDir, ".mcp.json"))}, for everyone who clones this repository.`}`));
|
|
@@ -2887,16 +2928,58 @@ async function cmdValidatorsJudge(sub) {
|
|
|
2887
2928
|
const resolve = () => judgeSettings({ agents, providerOf: agentProviderId, runsOnHost: agentRunsToolsOnHost });
|
|
2888
2929
|
const probe = async (settings) => probeModel({ provider: settings.provider, model: settings.model, stateRoot: process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents") });
|
|
2889
2930
|
if (sub === "add") {
|
|
2890
|
-
|
|
2891
|
-
|
|
2931
|
+
let agent = value("agent"), model = value("model"), dominantBuilderVendor = null;
|
|
2932
|
+
let rl = null;
|
|
2933
|
+
const question = async (prompt) => {
|
|
2934
|
+
rl ??= createInterface({ input, output });
|
|
2935
|
+
return rl.question(c.bold(prompt));
|
|
2936
|
+
};
|
|
2937
|
+
if (!agent) {
|
|
2938
|
+
if (!process.stdin.isTTY || json) throw new Error("Usage: nomarmy validators add judge --agent <name> --model <model> [--host-tools]");
|
|
2939
|
+
const roles = loadArmy({ projectDir: repoDir }).army.roles;
|
|
2940
|
+
const guided = judgeAgentChoices({ agents, roles, providerOf: agentProviderId, runsOnHost: agentRunsToolsOnHost });
|
|
2941
|
+
dominantBuilderVendor = guided.dominantBuilderVendor;
|
|
2942
|
+
if (!guided.choices.length) throw new Error("No api or subscription agents are configured in agents.yml.");
|
|
2943
|
+
agent = (await chooseJudgeAgent({ choices: guided.choices, ask: question, write: (line) => console.log(line) })).name;
|
|
2944
|
+
}
|
|
2892
2945
|
if (!agents[agent]) throw new Error(`"${agent}" isn't an agent in agents.yml. Agents: ${Object.keys(agents).join(", ") || "(none)"}`);
|
|
2893
|
-
if (
|
|
2894
|
-
|
|
2946
|
+
if (!model) {
|
|
2947
|
+
if (!process.stdin.isTTY || json) throw new Error("Usage: nomarmy validators add judge --agent <name> --model <model> [--host-tools]");
|
|
2948
|
+
const listed = catalogModelsFor(agentProviderId(agents[agent]));
|
|
2949
|
+
if (listed.length) {
|
|
2950
|
+
console.log(`Models for ${agent}:`);
|
|
2951
|
+
listed.forEach((item, index) => console.log(` ${index + 1}. ${item}`));
|
|
2952
|
+
}
|
|
2953
|
+
const fallback = agents[agent].model ?? "";
|
|
2954
|
+
const answer = String(await question(`Model id${fallback ? ` [${fallback}]` : ""}: `)).trim();
|
|
2955
|
+
model = /^\d+$/.test(answer) && listed[Number(answer) - 1] ? listed[Number(answer) - 1] : answer || fallback;
|
|
2956
|
+
if (!model) { rl?.close(); throw new Error("A model id is required."); }
|
|
2957
|
+
}
|
|
2958
|
+
let hostTools = flag("host-tools");
|
|
2959
|
+
if (agentRunsToolsOnHost(agents[agent]) && !hostTools) {
|
|
2960
|
+
if (!process.stdin.isTTY || json) {
|
|
2961
|
+
const quote = (arg) => /^[A-Za-z0-9_./:-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
|
|
2962
|
+
const rerun = ["nomarmy", ...argv, "--host-tools"].map(quote).join(" ");
|
|
2963
|
+
throw new Error(`agent "${agent}" runs its tools on this machine, and a judge reads text the worker wrote. Refusing without explicit consent. Re-run: ${rerun}`);
|
|
2964
|
+
}
|
|
2965
|
+
const allowed = await confirmJudgeHostTools({ agent, ask: question, write: (line) => console.log(line) });
|
|
2966
|
+
if (!allowed) { rl?.close(); console.log(c.dim("Canceled; nothing written.")); return; }
|
|
2967
|
+
hostTools = true;
|
|
2968
|
+
}
|
|
2969
|
+
rl?.close();
|
|
2970
|
+
const saved = saveJudge({ agent, model, hostTools });
|
|
2895
2971
|
const settings = resolve();
|
|
2896
2972
|
if (settings?.problem) throw new Error(settings.problem);
|
|
2897
2973
|
const test = await probe(settings);
|
|
2898
2974
|
if (json) return out({ saved: true, configPath: saved.configPath, test: test.ok ? "pass" : test.refused ? "refused" : "inconclusive", reason: test.reason });
|
|
2899
2975
|
console.log(c.green(`✓ The judge is ${agent}/${model}, in ${saved.configPath}.`));
|
|
2976
|
+
if (dominantBuilderVendor === null) {
|
|
2977
|
+
try {
|
|
2978
|
+
const roles = loadArmy({ projectDir: repoDir }).army.roles;
|
|
2979
|
+
dominantBuilderVendor = judgeAgentChoices({ agents, roles, providerOf: agentProviderId, runsOnHost: agentRunsToolsOnHost }).dominantBuilderVendor;
|
|
2980
|
+
} catch { /* no readable army means there is no builder comparison */ }
|
|
2981
|
+
}
|
|
2982
|
+
if (dominantBuilderVendor && agentProviderId(agents[agent]) === dominantBuilderVendor) console.log(c.yellow("Note: this judge uses the same vendor as most build roles, so its verdicts are not independent of those builders."));
|
|
2900
2983
|
console.log(test.ok ? c.green("✓ Test call answered. New implement jobs use it; restart open coordinator sessions to pick it up.") : c.red(`✗ Test call ${test.refused ? "refused" : "didn't answer"}: ${test.reason ?? "no answer"}`));
|
|
2901
2984
|
if (!test.ok) process.exitCode = 1;
|
|
2902
2985
|
return;
|
|
@@ -2930,11 +3013,18 @@ function cmdMcp() {
|
|
|
2930
3013
|
const commands = { stats: cmdStats, validators: cmdValidators, mcp: cmdMcp, scan: cmdScan, validate: cmdValidate, sizing: cmdSizing, init: cmdInit, setup: cmdSetup, install: cmdInstall, model: cmdModel, agents: cmdAgents, army: cmdArmy, jobs: cmdJobs, statusline: cmdStatusline, health: cmdHealth, config: cmdConfig, update: cmdUpdate, connect: cmdConnect, sandbox: cmdSandbox, start: cmdStart, stop: cmdStop, uninstall: cmdUninstall, help: () => usage(0) };
|
|
2931
3014
|
// doctor command
|
|
2932
3015
|
async function cmdDoctor() {
|
|
3016
|
+
if (windowsFrontEnd()) {
|
|
3017
|
+
process.exitCode = windowsDoctor({ json, argv });
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
2933
3020
|
// Import lazily to avoid circular dependencies
|
|
2934
3021
|
const { runDoctor } = await import("../lib/doctor.mjs");
|
|
2935
3022
|
await runDoctor({ json, exit: true, env: installEnv() });
|
|
2936
3023
|
}
|
|
2937
3024
|
commands.doctor = cmdDoctor;
|
|
3025
|
+
if (windowsFrontEnd() && windowsPlan(argv) === "FORWARD") {
|
|
3026
|
+
process.exit(windowsForward(argv));
|
|
3027
|
+
}
|
|
2938
3028
|
if (!command && !flag("help")) {
|
|
2939
3029
|
// New users typed `nomarmy` and got the whole command reference.
|
|
2940
3030
|
console.log(`${c.bold("nomArmy")}: bounded coding workers with independently verified results.\n\n New here? ${c.cyan("nomarmy setup")} walks you through it, one step at a time\n All commands: ${c.cyan("nomarmy help")}\n Docs: https://github.com/rayson-tech/nomarmy`);
|
package/docker/Dockerfile
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
FROM node:
|
|
1
|
+
FROM node:26-bookworm-slim
|
|
2
2
|
ENV DEBIAN_FRONTEND=noninteractive
|
|
3
3
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
4
4
|
bash ca-certificates curl git jq python3 python3-pip python3-venv ripgrep \
|
|
5
5
|
&& rm -rf /var/lib/apt/lists/*
|
|
6
|
-
# node
|
|
6
|
+
# The node:*-bookworm-slim images ship a non-root "node" user at uid/gid 1000,
|
|
7
7
|
# the same uid this Dockerfile used to try to create a "sandbox" user at.
|
|
8
8
|
# useradd silently failed on every build (masked by `|| true`), so `USER
|
|
9
9
|
# sandbox` below referred to a user that was never actually created --
|
package/docker/Dockerfile.go
CHANGED
package/docker/Dockerfile.rust
CHANGED
package/install.sh
CHANGED
|
@@ -130,10 +130,16 @@ if nomarmy_has_local_model; then openclaw plugins install @openclaw/llama-cpp-pr
|
|
|
130
130
|
# Bedrock credential unless the coder sandbox is already network-isolated.
|
|
131
131
|
"$ROOT/scripts/setup-sandbox.sh"
|
|
132
132
|
"$ROOT/scripts/configure-openclaw.sh" "$NOMARMY_PROFILE"
|
|
133
|
-
if [[ "$
|
|
134
|
-
|
|
133
|
+
if [[ "${NOMARMY_COORDINATOR_OS:-}" == windows ]]; then
|
|
134
|
+
# Run from the Windows front end: the coordinators are Windows programs,
|
|
135
|
+
# registered from Windows, not looked for inside this distro.
|
|
136
|
+
echo 'NOTE: register your coordinators from Windows PowerShell: nomarmy connect claude (or codex, cursor)'
|
|
137
|
+
else
|
|
138
|
+
if [[ "$WITH_CLAUDE" == 1 ]]; then
|
|
139
|
+
if command -v claude >/dev/null 2>&1; then node "$ROOT/bin/nomarmy.mjs" connect claude; else echo 'NOTE: Claude Code not found; worker stack installed. Install Claude Code then run: nomarmy connect claude'; fi
|
|
140
|
+
fi
|
|
141
|
+
if command -v codex >/dev/null 2>&1; then node "$ROOT/bin/nomarmy.mjs" connect codex; else echo 'NOTE: Codex not found; run: nomarmy connect codex (after installing Codex)'; fi
|
|
135
142
|
fi
|
|
136
|
-
if command -v codex >/dev/null 2>&1; then node "$ROOT/bin/nomarmy.mjs" connect codex; else echo 'NOTE: Codex not found; run: nomarmy connect codex (after installing Codex)'; fi
|
|
137
143
|
"$ROOT/scripts/verify-install.sh" "$NOMARMY_PROFILE"
|
|
138
144
|
if nomarmy_is_cloud && [[ "${NOMARMY_ORCHESTRATOR_RUNTIME:-}" == "claude-code" ]]; then
|
|
139
145
|
echo
|
package/lib/agents.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import { providerEntrySchema, formatDispatchIssues, openclawProviderId, PROVIDER
|
|
|
37
37
|
export { HOST_TOOL_PROVIDERS, agentRunsToolsOnHost };
|
|
38
38
|
import { findProviderConflicts, describeProviderConflict } from "./subscription-config.mjs";
|
|
39
39
|
import { privateConfigProblem } from "./army.mjs";
|
|
40
|
+
import { THINKING_LEVELS } from "./thinking.mjs";
|
|
40
41
|
|
|
41
42
|
export const AGENTS_FILENAME = "agents.yml";
|
|
42
43
|
export const AGENT_KINDS = Object.freeze(["local", "api", "subscription"]);
|
|
@@ -48,7 +49,7 @@ export const BUILTIN_LOCAL_AGENT = Object.freeze({ kind: "local", slot: "coder"
|
|
|
48
49
|
const requiredString = () =>
|
|
49
50
|
z.string({ error: typeError("a string") })
|
|
50
51
|
.refine((value) => value.trim().length > 0, { message: "must not be empty" });
|
|
51
|
-
const thinkingSchema = z.union([z.boolean(), z.enum(
|
|
52
|
+
const thinkingSchema = z.union([z.boolean(), z.enum(THINKING_LEVELS)]).default(true);
|
|
52
53
|
const positiveInt = () => z.number({ error: typeError("a number") }).int("must be a whole number").positive("must be a positive number");
|
|
53
54
|
|
|
54
55
|
const localAgentSchema = z.object({
|
package/lib/connect.mjs
CHANGED
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
import fs from "node:fs";
|
|
25
25
|
import os from "node:os";
|
|
26
26
|
import path from "node:path";
|
|
27
|
+
import { mcpBridgeLaunch } from "./wsl.mjs";
|
|
28
|
+
import { toPosix } from "./path-utils.mjs";
|
|
27
29
|
import { execFileSync } from "node:child_process";
|
|
28
30
|
import { loadAgents } from "./agents.mjs";
|
|
29
31
|
import { globalConfigDir } from "./army.mjs";
|
|
@@ -281,7 +283,8 @@ export function excludeFromGit(projectDir, relPaths, run = defaultRun) {
|
|
|
281
283
|
excludeFile = path.resolve(projectDir, excludeFile);
|
|
282
284
|
const existing = fs.existsSync(excludeFile) ? fs.readFileSync(excludeFile, "utf8") : "";
|
|
283
285
|
const lines = new Set(existing.split("\n").map((l) => l.trim()));
|
|
284
|
-
|
|
286
|
+
// Git exclude patterns always use forward slashes, including on Windows.
|
|
287
|
+
const added = relPaths.map((p) => `/${toPosix(p)}`).filter((p) => !lines.has(p));
|
|
285
288
|
if (added.length) {
|
|
286
289
|
fs.mkdirSync(path.dirname(excludeFile), { recursive: true });
|
|
287
290
|
fs.appendFileSync(excludeFile, `${existing && !existing.endsWith("\n") ? "\n" : ""}# nomArmy, registered for this repository only\n${added.join("\n")}\n`);
|
|
@@ -294,7 +297,8 @@ export function includeInGit(projectDir, relPaths, run = defaultRun) {
|
|
|
294
297
|
let excludeFile;
|
|
295
298
|
try { excludeFile = path.resolve(projectDir, String(run("git", ["rev-parse", "--git-path", "info/exclude"], { cwd: projectDir, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" })).trim()); } catch { return []; }
|
|
296
299
|
if (!fs.existsSync(excludeFile)) return [];
|
|
297
|
-
|
|
300
|
+
// Match the same portable spelling written by excludeFromGit on Windows.
|
|
301
|
+
const drop = new Set(relPaths.map((p) => `/${toPosix(p)}`));
|
|
298
302
|
const lines = fs.readFileSync(excludeFile, "utf8").split("\n");
|
|
299
303
|
const removed = lines.filter((l) => drop.has(l.trim()));
|
|
300
304
|
if (removed.length) fs.writeFileSync(excludeFile, lines.filter((l) => !drop.has(l.trim())).join("\n"));
|
|
@@ -551,3 +555,29 @@ export function cursorAlreadyConnected(configPath = defaultCursorConfigPath()) {
|
|
|
551
555
|
return false;
|
|
552
556
|
}
|
|
553
557
|
}
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
// Windows installs only coordinator playbooks. The engine stays in WSL.
|
|
561
|
+
export function connectViaWsl({ target, distro, launch, run = defaultRun, commandsDir = defaultCommandDirs()[target], configPath = defaultCursorConfigPath(), nomarmyRoot }) {
|
|
562
|
+
if (!["claude", "codex", "cursor"].includes(target)) throw new Error(`unknown connect target: ${target}`);
|
|
563
|
+
// Revalidate the direct launch before passing any values to a coordinator.
|
|
564
|
+
const checked = mcpBridgeLaunch({ distro, node: launch?.args?.[3], script: launch?.args?.[4] });
|
|
565
|
+
if (JSON.stringify(launch) !== JSON.stringify(checked)) throw new Error("Invalid WSL MCP bridge launch");
|
|
566
|
+
const { command, args } = checked;
|
|
567
|
+
const env = { ...checked.env, WSLENV: `${checked.env.WSLENV}:NOMARMY_COORDINATOR_OS/u`, NOMARMY_COORDINATOR_OS: "windows" };
|
|
568
|
+
if (target === "claude") {
|
|
569
|
+
quietRun(run, "claude", ["mcp", "remove", SERVER_NAME, "--scope", "user"]);
|
|
570
|
+
run("claude", ["mcp", "add", "--scope", "user", SERVER_NAME, "-e", `WSLENV=${env.WSLENV}`, "-e", `NOMARMY_COORDINATOR_OS=${env.NOMARMY_COORDINATOR_OS}`, "--", command, ...args]);
|
|
571
|
+
} else if (target === "codex") {
|
|
572
|
+
quietRun(run, "codex", ["mcp", "remove", SERVER_NAME]);
|
|
573
|
+
run("codex", ["mcp", "add", SERVER_NAME, "--env", `WSLENV=${env.WSLENV}`, "--env", `NOMARMY_COORDINATOR_OS=${env.NOMARMY_COORDINATOR_OS}`, "--", command, ...args]);
|
|
574
|
+
} else {
|
|
575
|
+
const config = readCursorConfig(configPath);
|
|
576
|
+
if (!config.mcpServers || typeof config.mcpServers !== "object" || Array.isArray(config.mcpServers)) config.mcpServers = {};
|
|
577
|
+
config.mcpServers[SERVER_NAME] = { command, args, env: { ...env, NOMARMY_PROJECT_DIR: "${workspaceFolder}" } };
|
|
578
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
579
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
580
|
+
}
|
|
581
|
+
const commands = installPlaybooks({ nomarmyRoot, target, dir: commandsDir });
|
|
582
|
+
return { target, bridge: { distro }, commands };
|
|
583
|
+
}
|
package/lib/diff-checks.mjs
CHANGED
|
@@ -29,6 +29,24 @@ export function isDocumentationPath(file) {
|
|
|
29
29
|
return DOC_EXTENSIONS.has(ext) || DOC_BASENAMES.has(dot > 0 ? base.slice(0, dot) : base);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export function isCiConfigPath(file) {
|
|
33
|
+
const normalized = String(file ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
34
|
+
return /^\.github\/(workflows|actions)\//.test(normalized)
|
|
35
|
+
|| normalized === ".github/dependabot.yml"
|
|
36
|
+
|| normalized === ".gitlab-ci.yml"
|
|
37
|
+
|| /^\.circleci\//.test(normalized)
|
|
38
|
+
|| normalized === "azure-pipelines.yml"
|
|
39
|
+
|| /^\.buildkite\//.test(normalized)
|
|
40
|
+
|| normalized === "Jenkinsfile"
|
|
41
|
+
|| normalized === "bitbucket-pipelines.yml";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function planRegressionProductionFiles(files) {
|
|
45
|
+
// Local repository tests cannot exercise CI services, so reverting CI
|
|
46
|
+
// configuration cannot provide useful regression evidence.
|
|
47
|
+
return (files ?? []).filter((file) => !isDocumentationPath(file) && !isCiConfigPath(file));
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
export function isTestPath(file) {
|
|
33
51
|
const normalized = String(file ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
34
52
|
if (!normalized) return false;
|
package/lib/dispatch-schema.mjs
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { z } from "zod";
|
|
18
18
|
import { typeError, isMissingField, isUnknownDiscriminator } from "./zod-issues.mjs";
|
|
19
|
+
import { THINKING_LEVELS } from "./thinking.mjs";
|
|
19
20
|
|
|
20
21
|
const requiredString = () =>
|
|
21
22
|
z
|
|
@@ -144,7 +145,7 @@ const urlSchema = () =>
|
|
|
144
145
|
// OpenClaw names as supported (see parseUnsupportedThinkingError) -- so a
|
|
145
146
|
// level this schema accepts is never a guarantee the provider/model
|
|
146
147
|
// combination honors it today, only what to keep asking for.
|
|
147
|
-
const thinkingSchema = z.union([z.boolean(), z.enum(
|
|
148
|
+
const thinkingSchema = z.union([z.boolean(), z.enum(THINKING_LEVELS)]).default(true);
|
|
148
149
|
|
|
149
150
|
// Optional override for the model's real context window, in tokens. Absent
|
|
150
151
|
// (the common case), lib/dispatch-config.mjs looks this up from OpenClaw's
|
package/lib/doctor.mjs
CHANGED
|
@@ -145,12 +145,19 @@ export function checkGitLongPaths(facts) {
|
|
|
145
145
|
* @param {{podmanFound: boolean}} facts
|
|
146
146
|
* @returns {{ok: boolean, message: string, fix?: string}}
|
|
147
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
|
+
|
|
148
155
|
export function checkPodmanPresent(facts) {
|
|
149
156
|
if (facts.podmanFound) return { ok: true, message: "podman is available." };
|
|
150
157
|
return {
|
|
151
158
|
ok: false,
|
|
152
159
|
message: "podman executable not found.",
|
|
153
|
-
fix:
|
|
160
|
+
fix: `${installPodmanHint(facts.platform)}, and make sure 'podman' is on your PATH.`,
|
|
154
161
|
};
|
|
155
162
|
}
|
|
156
163
|
|
|
@@ -217,7 +224,7 @@ export function checkPodmanDaemon(facts) {
|
|
|
217
224
|
return {
|
|
218
225
|
ok: false,
|
|
219
226
|
message: "Podman check skipped: podman executable not found.",
|
|
220
|
-
fix:
|
|
227
|
+
fix: `${installPodmanHint(facts.platform)}, then re-run 'nomarmy doctor'.`,
|
|
221
228
|
};
|
|
222
229
|
}
|
|
223
230
|
if (facts.podmanDaemonReachable) {
|
|
@@ -248,7 +255,7 @@ export function checkEndpoint(facts) {
|
|
|
248
255
|
};
|
|
249
256
|
}
|
|
250
257
|
if (execution === "remote") return checkRemoteEndpoint(endpoint);
|
|
251
|
-
if (execution === "local") return checkLocalEndpoint(endpoint);
|
|
258
|
+
if (execution === "local") return checkLocalEndpoint(endpoint, facts.platform);
|
|
252
259
|
return {
|
|
253
260
|
ok: false,
|
|
254
261
|
message: `NOMARMY_EXECUTION '${execution}' is not recognized.`,
|
|
@@ -266,13 +273,14 @@ function checkRemoteEndpoint(endpoint) {
|
|
|
266
273
|
};
|
|
267
274
|
}
|
|
268
275
|
|
|
269
|
-
function checkLocalEndpoint(endpoint) {
|
|
276
|
+
function checkLocalEndpoint(endpoint, platform = process.platform) {
|
|
270
277
|
const { url, healthy, error } = endpoint;
|
|
271
278
|
if (healthy) return { ok: true, message: `llama-server is healthy at ${url}.` };
|
|
272
279
|
return {
|
|
273
280
|
ok: false,
|
|
274
281
|
message: `llama-server health check failed at ${url}${error ? `: ${error}` : "."}`,
|
|
275
|
-
|
|
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'.`,
|
|
276
284
|
};
|
|
277
285
|
}
|
|
278
286
|
|
package/lib/execute.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { describeRecoveryChanges, reportRecoveryPrompt } from "./worker-prompt.m
|
|
|
21
21
|
import { parseWorkerReport } from "./report.mjs";
|
|
22
22
|
import { OUTCOMES, COORDINATOR_STATUS_BY_OUTCOME } from "./outcomes.mjs";
|
|
23
23
|
import { resolveOutcome, finalText, workerMetadata, applyRefactorContract, applyVerificationPolicy, HIGH_STAKES_NOTE } from "./outcome.mjs";
|
|
24
|
-
import { parseAddedLineNumbers, isTestPath,
|
|
24
|
+
import { parseAddedLineNumbers, isTestPath, planRegressionProductionFiles, detectScopedTestSelectionRisk, detectUnwiredNewDefinitions, detectMislabeledTestNames, detectPossibleSecrets, detectVerificationInputChanges } from "./diff-checks.mjs";
|
|
25
25
|
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
27
|
// Job status for polling. `status.json` is written at every phase transition
|
|
@@ -286,9 +286,9 @@ export function createExecutor(deps) {
|
|
|
286
286
|
// profile (resolveVerifyRegression). It doubles verification wall-clock,
|
|
287
287
|
// so it runs only when there's something to re-check: a passing
|
|
288
288
|
// first-pass verification on a diff that touched production code.
|
|
289
|
-
// Documentation
|
|
290
|
-
//
|
|
291
|
-
const codeFilesChanged = preCommit.testChanges.production_files_changed
|
|
289
|
+
// Documentation and CI configuration cannot be proven by local tests,
|
|
290
|
+
// so neither is reverted or used as a reason to run this check.
|
|
291
|
+
const codeFilesChanged = planRegressionProductionFiles(preCommit.testChanges.production_files_changed);
|
|
292
292
|
let regressionCheck = null, regressionCheckFatal = false, regressionCheckElapsedMs = null;
|
|
293
293
|
if (verifyRegression && independentVerification.status === "pass" && codeFilesChanged.length > 0) {
|
|
294
294
|
const regressionStartedMs = Date.now();
|
package/lib/notify.mjs
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
//
|
|
5
5
|
// macOS: nomArmy.app (lib/notifier-app.mjs), which shows nomArmy's own icon,
|
|
6
6
|
// or osascript's `display notification` when the app isn't built (that one
|
|
7
|
-
// shows Script Editor's icon).
|
|
7
|
+
// shows Script Editor's icon). WSL: Windows toast via PowerShell, falling
|
|
8
|
+
// back to Linux notify-send. Other Linux: notify-send. Anything else, or
|
|
8
9
|
// NOMARMY_NOTIFY=0: nothing. Fire-and-forget: a notification must never
|
|
9
10
|
// delay or fail the job that triggered it.
|
|
10
11
|
|
|
@@ -23,6 +24,23 @@ export function appleScriptString(text) {
|
|
|
23
24
|
export function notificationCommand(title, message, { platform = process.platform, env = process.env, app = null } = {}) {
|
|
24
25
|
if (env.NOMARMY_NOTIFY === "0") return null;
|
|
25
26
|
const t = String(title).slice(0, 120), m = String(message).slice(0, 400);
|
|
27
|
+
if (env.WSL_DISTRO_NAME) {
|
|
28
|
+
// EncodedCommand avoids shell/interop quoting. Single-quoted PowerShell
|
|
29
|
+
// literals and DOM InnerText keep user text out of code and XML markup.
|
|
30
|
+
const literal = text => `'${text.replace(/['\u2018\u2019\u201a\u201b]/g, quote => quote + quote)}'`;
|
|
31
|
+
const script = `$ErrorActionPreference = 'Stop'
|
|
32
|
+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
|
|
33
|
+
[Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
|
|
34
|
+
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
|
|
35
|
+
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
|
36
|
+
$xml.LoadXml('<toast><visual><binding template="ToastGeneric"><text/><text/></binding></visual></toast>')
|
|
37
|
+
$text = $xml.GetElementsByTagName('text')
|
|
38
|
+
$text.Item(0).InnerText = ${literal(t)}
|
|
39
|
+
$text.Item(1).InnerText = ${literal(m)}
|
|
40
|
+
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
|
41
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Microsoft.Windows.PowerShell').Show($toast)`;
|
|
42
|
+
return ["powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")]];
|
|
43
|
+
}
|
|
26
44
|
if (platform === "darwin" && app) return ["open", ["-g", app.app, "--args", app.spool]];
|
|
27
45
|
if (platform === "darwin") return ["osascript", ["-e", `display notification ${appleScriptString(m)} with title ${appleScriptString(t)}`]];
|
|
28
46
|
if (platform === "linux") return ["notify-send", ["--app-name=nomArmy", t, m]];
|
|
@@ -57,10 +75,19 @@ export function notify(title, message, { platform = process.platform, env = proc
|
|
|
57
75
|
try { if (app && !spoolNotification(app.spool, title, message)) return false; } catch { app = null; }
|
|
58
76
|
const cmd = notificationCommand(title, message, { platform, env, app });
|
|
59
77
|
if (!cmd) return false;
|
|
78
|
+
let fellBack = false;
|
|
79
|
+
const fallback = () => {
|
|
80
|
+
if (fellBack || cmd[0] !== "powershell.exe") return false;
|
|
81
|
+
fellBack = true;
|
|
82
|
+
return notify(title, message, { platform, env: { ...env, WSL_DISTRO_NAME: "" }, run, root });
|
|
83
|
+
};
|
|
60
84
|
try {
|
|
85
|
+
// Launching PowerShell is also the interop reachability check. Missing or
|
|
86
|
+
// disabled interop emits error; a failed toast exits nonzero. Both fall back.
|
|
61
87
|
const child = run(cmd[0], cmd[1], { stdio: "ignore", detached: true });
|
|
62
|
-
child.on?.("error",
|
|
88
|
+
child.on?.("error", fallback);
|
|
89
|
+
child.on?.("exit", (code, signal) => { if (code !== 0 || signal) fallback(); });
|
|
63
90
|
child.unref?.();
|
|
64
91
|
return true;
|
|
65
|
-
} catch { return
|
|
92
|
+
} catch { return fallback(); }
|
|
66
93
|
}
|
package/lib/openclaw-run.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { deriveBudgets } from "./budget.mjs";
|
|
|
13
13
|
import { entryContextPerNom } from "./dispatch-config.mjs";
|
|
14
14
|
import { readClaudeSessionUsage } from "./claude-transcript.mjs";
|
|
15
15
|
import { modelRejection, modelRejectionLine } from "./openclaw-errors.mjs";
|
|
16
|
+
import { nearestThinkingLevel } from "./thinking.mjs";
|
|
16
17
|
|
|
17
18
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
18
19
|
|
|
@@ -580,7 +581,7 @@ export function createOpenClawRunner(deps) {
|
|
|
580
581
|
// -- never loop on the same level, and never mask a real, unrelated
|
|
581
582
|
// failure as a thinking-level problem it isn't.
|
|
582
583
|
if (unsupported && !unsupported.supported.includes(selected.thinking)) {
|
|
583
|
-
const fallback = unsupported.supported
|
|
584
|
+
const fallback = nearestThinkingLevel(selected.thinking, unsupported.supported);
|
|
584
585
|
fs.appendFileSync(path.join(jobDir, "coordinator.log"),
|
|
585
586
|
`${new Date().toISOString()} "${selected.model}" rejected thinking level "${selected.thinking}" (OpenClaw supports: ${unsupported.supported.join(", ")}) -- retrying once with "${fallback}"\n`);
|
|
586
587
|
selected.thinking = fallback; // the manifest's requestedReasoning field should reflect what was ACTUALLY used, not the level that failed
|
package/lib/registry-python.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { toPosix } from './path-utils.mjs';
|
|
5
6
|
|
|
6
7
|
const refuse = (line, kind) => { throw new Error(`registries: Python line ${line}: ${kind} is not allowed with credentials`); };
|
|
7
8
|
function requirement(value, line, cwd) {
|
|
@@ -21,7 +22,8 @@ export function requirementsInputs(cwd, roots, validate) {
|
|
|
21
22
|
try {
|
|
22
23
|
if (!fs.realpathSync(absolute).startsWith(root + path.sep) || !fs.statSync(absolute).isFile()) refuse(line, 'escaping requirements include');
|
|
23
24
|
} catch { refuse(line, 'escaping requirements include'); }
|
|
24
|
-
|
|
25
|
+
// Docker build-context paths must not contain Windows escape backslashes.
|
|
26
|
+
source = toPosix(path.relative(cwd, absolute));
|
|
25
27
|
if (seen.has(source)) return seen.get(source);
|
|
26
28
|
const index = files.length;
|
|
27
29
|
seen.set(source, index);
|
package/lib/registry-secrets.mjs
CHANGED
|
@@ -67,24 +67,36 @@ export function readRegistrySecrets(cwd) {
|
|
|
67
67
|
const resolved = path.resolve(cwd, source.startsWith("~/") ? path.join(os.homedir(), source.slice(2)) : source);
|
|
68
68
|
let stat, bytes;
|
|
69
69
|
try {
|
|
70
|
-
stat = fs.statSync(resolved);
|
|
70
|
+
stat = fs.statSync(resolved, { bigint: true });
|
|
71
71
|
if (!stat.isFile()) fail(ecosystem + " credential must be a regular file");
|
|
72
72
|
bytes = fs.readFileSync(resolved);
|
|
73
73
|
} catch { fail(ecosystem + " credential must be an existing readable regular file"); }
|
|
74
74
|
return {
|
|
75
75
|
ecosystem, id: ecosystem === "go" ? "go-netrc" : ecosystem, source: resolved, format, privatePatterns,
|
|
76
76
|
digest: crypto.createHash("sha256").update(bytes).digest("hex"),
|
|
77
|
-
|
|
77
|
+
...fileIdentity(resolved, stat),
|
|
78
78
|
};
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// Same file, hardlinks included. Exact (bigint) IDs, as strings: Windows file
|
|
83
|
+
// IDs are 64-bit (128 on ReFS) and collide once rounded to a double, which
|
|
84
|
+
// flagged unrelated files as the credential on CI. A filesystem that reports
|
|
85
|
+
// no ID (inode 0) falls back to the real path.
|
|
86
|
+
function fileIdentity(file, stat = fs.statSync(file, { bigint: true })) {
|
|
87
|
+
const real = () => { const p = fs.realpathSync.native(file); return process.platform === "win32" ? p.toLowerCase() : p; };
|
|
88
|
+
return stat.ino === 0n ? { device: null, inode: null, realPath: real() }
|
|
89
|
+
: { device: String(stat.dev), inode: String(stat.ino), realPath: null };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const sameFile = (a, b) => a.inode !== null ? a.device === b.device && a.inode === b.inode : a.realPath === b.realPath;
|
|
93
|
+
|
|
82
94
|
// A selected credential must not also be a dependency input (including hardlinks).
|
|
83
95
|
export function assertNoRegistryCopies(cwd, files, secrets) {
|
|
84
96
|
for (const { source } of files) {
|
|
85
97
|
if (!source) continue;
|
|
86
|
-
const
|
|
87
|
-
if (secrets.some((secret) => secret
|
|
98
|
+
const input = fileIdentity(path.resolve(cwd, source));
|
|
99
|
+
if (secrets.some((secret) => sameFile(secret, input))) {
|
|
88
100
|
fail("a credential file cannot also be a build-context input");
|
|
89
101
|
}
|
|
90
102
|
}
|