Skip to content

Commit 06b846d

Browse files
authored
Merge branch 'main' into feat/hermes-googlechat
2 parents 3668e10 + 0134412 commit 06b846d

15 files changed

Lines changed: 263 additions & 58 deletions

ci/source-architecture-budget.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"src/lib/cli/branding.ts": 87,
1515
"src/lib/cli/nemoclaw-oclif-command.ts": 106,
1616
"src/lib/cli/terminal-style.ts": 43,
17-
"src/lib/core/json-types.ts": 36,
17+
"src/lib/core/json-types.ts": 37,
1818
"src/lib/core/ports.ts": 89,
1919
"src/lib/core/shell-quote.ts": 28,
2020
"src/lib/core/url-utils.ts": 30,
@@ -46,7 +46,7 @@
4646
"src/lib/actions/sandbox/rebuild-pipeline.ts": 29,
4747
"src/lib/actions/sandbox/snapshot.ts": 40,
4848
"src/lib/actions/uninstall/run-plan.ts": 25,
49-
"src/lib/inference/local.ts": 21,
49+
"src/lib/inference/local.ts": 22,
5050
"src/lib/inference/onboard-probes.ts": 21,
5151
"src/lib/inference/vllm.ts": 21,
5252
"src/lib/onboard.ts": 201,

scripts/dev-setup.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,7 @@ check_git_configuration() {
446446
"Set repository-local user.name and user.email before committing."
447447
fi
448448

449-
sign_enabled="$(git_config commit.gpgsign)"
449+
sign_enabled="$(git -C "${REPO_ROOT}" config --get --type=bool commit.gpgsign 2>/dev/null || true)"
450450
if ! sign_format="$(git -C "${REPO_ROOT}" config --get gpg.format 2>/dev/null)"; then
451451
sign_format="openpgp"
452452
fi

src/lib/inference/local.test.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -782,16 +782,19 @@ describe("local inference helpers", () => {
782782
// /api/tags should not register as healthy when the response body is not the
783783
// Ollama wire format — a captive HTTP_PROXY or stale listener can otherwise
784784
// answer with arbitrary 2xx that the curl-status-only check accepts.
785-
it("rejects a backend 200 whose body is not the Ollama /api/tags JSON shape", () => {
785+
it.each([
786+
["an HTML body", "<html><body>Privoxy</body></html>"],
787+
["a null model entry", '{"models":[null]}'],
788+
["a primitive model entry", '{"models":[1]}'],
789+
["a nested-array model entry", '{"models":[[]]}'],
790+
])("rejects a backend 200 with %s", (_label, body) => {
786791
const result = probeLocalProviderHealth("ollama-local", {
787792
loadOllamaProxyTokenImpl: () => null,
788793
runCurlProbeImpl: () => ({
789794
ok: true,
790795
httpStatus: 200,
791796
curlStatus: 0,
792-
// E.g. a corporate HTTP proxy that intercepts loopback and serves an
793-
// HTML landing page on every URL, or a stale unrelated listener.
794-
body: "<html><body>Privoxy</body></html>",
797+
body,
795798
stderr: "",
796799
message: "HTTP 200",
797800
}),
@@ -802,7 +805,12 @@ describe("local inference helpers", () => {
802805
expect(result?.detail).toContain("HTTP_PROXY");
803806
});
804807

805-
it("rejects an auth-proxy 200 whose body is not the Ollama /api/tags JSON shape", () => {
808+
it.each([
809+
["an invalid object", '{"error":"backend unreachable"}'],
810+
["a null model entry", '{"models":[null]}'],
811+
["a primitive model entry", '{"models":[1]}'],
812+
["a nested-array model entry", '{"models":[[]]}'],
813+
])("rejects an auth-proxy 200 with %s", (_label, body) => {
806814
const result = probeLocalProviderHealth("ollama-local", {
807815
loadOllamaProxyTokenImpl: () => "token",
808816
runCurlProbeImpl: (argv: string[]) => {
@@ -811,9 +819,7 @@ describe("local inference helpers", () => {
811819
ok: true,
812820
httpStatus: 200,
813821
curlStatus: 0,
814-
// Proxy is up but its upstream Ollama backend is gone; the proxy
815-
// returns a stub 200 with no models array.
816-
body: isProxy ? '{"error":"backend unreachable"}' : '{"models":[]}',
822+
body: isProxy ? body : '{"models":[]}',
817823
stderr: "",
818824
message: "HTTP 200",
819825
};

src/lib/inference/local.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { CONTAINER_REACHABILITY_IMAGE } from "../adapters/http/container-curl-pr
1515
import { buildValidatedCurlCommandArgs } from "../adapters/http/curl-args";
1616
import type { CurlProbeOptions, CurlProbeResult } from "../adapters/http/probe";
1717
import { runCurlProbe } from "../adapters/http/probe";
18+
import { isObjectRecord } from "../core/json-types";
1819
import { OLLAMA_PORT, OLLAMA_PROXY_PORT, VLLM_PORT } from "../core/ports";
1920

2021
import { retryUntil } from "../core/retry";
@@ -357,32 +358,27 @@ export function probeVllmModels(
357358
}
358359
}
359360

360-
// A 200 response on `/api/tags` alone is not enough to call Ollama healthy —
361-
// a captive HTTP_PROXY, a stale listener, or a stub on the loopback port can
362-
// all answer with arbitrary 2xx bodies that look healthy at the curl-status
363-
// level. The authoritative signal is the Ollama wire format itself:
364-
// `{ "models": [...] }`. An empty array is fine — that just means no models
365-
// pulled yet — but a body that doesn't parse as JSON-with-array-`models` did
366-
// not come from Ollama and the probe should not call it healthy. (#4275)
361+
// A successful `/api/tags` response proves Ollama health only when its body
362+
// contains a `models` array of objects. An empty array is valid. (#4275)
367363
export function isValidOllamaTagsResponseBody(body: string): boolean {
368364
if (!body) return false;
369365
try {
370366
const parsed = JSON.parse(body);
371-
return parsed !== null && typeof parsed === "object" && Array.isArray(parsed.models);
367+
return isObjectRecord(parsed) && Array.isArray(parsed.models) && parsed.models.every(isObjectRecord);
372368
} catch {
373369
return false;
374370
}
375371
}
376372

377373
function modelInventory(provider: string, body: string): string[] | null {
378374
try {
379-
const parsed = JSON.parse(body) as Record<string, unknown>;
375+
const parsed = JSON.parse(body);
376+
if (!isObjectRecord(parsed)) return null;
380377
const entries = provider === "ollama-local" ? parsed.models : parsed.data;
381378
if (!Array.isArray(entries)) return null;
382379
return entries.flatMap((entry) => {
383-
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return [];
384-
const record = entry as Record<string, unknown>;
385-
const values = provider === "ollama-local" ? [record.name, record.model] : [record.id];
380+
if (!isObjectRecord(entry)) return [];
381+
const values = provider === "ollama-local" ? [entry.name, entry.model] : [entry.id];
386382
return values.filter((value): value is string => typeof value === "string" && value !== "");
387383
});
388384
} catch {

src/lib/onboard/dashboard-port.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ async function closeServer(server: Server): Promise<void> {
4040
});
4141
}
4242

43+
async function listenAndCloseOnLoopback(port: number): Promise<void> {
44+
const server = await listenOnLoopback(port);
45+
await closeServer(server);
46+
}
47+
4348
async function unusedLoopbackPort(): Promise<number> {
4449
const server = await listenOnLoopback(0);
4550
const address = server.address();
@@ -363,7 +368,7 @@ describe("dashboard port reservation", () => {
363368
withDashboardPortReservationScope(async (scope) => {
364369
scope.current = await reserveDashboardPort(port);
365370
await assert.rejects(
366-
listenOnLoopback(port),
371+
listenAndCloseOnLoopback(port),
367372
(error: NodeJS.ErrnoException) => error.code === "EADDRINUSE",
368373
);
369374
throw new Error("sandbox build failed");
@@ -375,6 +380,29 @@ describe("dashboard port reservation", () => {
375380
await closeServer(listener);
376381
});
377382

383+
it("releases the selected port when finalization calls the extracted scope callback (#9568)", async () => {
384+
const port = await unusedLoopbackPort();
385+
386+
await withDashboardPortReservationScope(async (scope) => {
387+
scope.current = await reserveDashboardPort(port);
388+
await assert.rejects(
389+
listenAndCloseOnLoopback(port),
390+
(error: NodeJS.ErrnoException) => error.code === "EADDRINUSE",
391+
);
392+
393+
const finalizationDashboard = { releasePort: scope.release };
394+
await finalizationDashboard.releasePort();
395+
396+
assert.equal(scope.current, null);
397+
const listener = await listenOnLoopback(port);
398+
try {
399+
assert.equal(listener.listening, true);
400+
} finally {
401+
await closeServer(listener);
402+
}
403+
});
404+
});
405+
378406
it("reselects before sandbox creation when a listener wins the allocation race (#8798)", async () => {
379407
const attempts: number[] = [];
380408
const warnings: string[] = [];

src/lib/onboard/dashboard-port.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -610,9 +610,9 @@ export async function withDashboardPortReservationScope<T>(
610610
): Promise<T> {
611611
const scope: DashboardPortReservationScope = {
612612
current: null,
613-
async release() {
614-
const reservation = this.current;
615-
this.current = null;
613+
release: async () => {
614+
const reservation = scope.current;
615+
scope.current = null;
616616
await reservation?.release();
617617
},
618618
};

src/lib/onboard/dockerfile-patch.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -161,20 +161,15 @@ describe("dockerfile patch helpers", () => {
161161
"ARG NEMOCLAW_PROVIDER_KEY=old",
162162
"ARG NEMOCLAW_PRIMARY_MODEL_REF=old",
163163
"ARG CHAT_UI_URL=old",
164-
"ARG NEMOCLAW_INFERENCE_BASE_URL=old",
165-
"ARG NEMOCLAW_INFERENCE_API=old",
166164
"ARG NEMOCLAW_INFERENCE_COMPAT_B64=old",
167165
"ARG NEMOCLAW_BUILD_ID=old",
168166
"ARG NEMOCLAW_DARWIN_VM_COMPAT=0",
169-
"ARG NEMOCLAW_PROXY_HOST=old",
170-
"ARG NEMOCLAW_PROXY_PORT=old",
171167
"ARG NEMOCLAW_WEB_SEARCH_ENABLED=0",
172168
"ARG NEMOCLAW_OPENCLAW_OTEL=0",
173-
"ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0",
174169
].join("\n"),
175170
);
176171

177-
expect(() =>
172+
const patch = (options: { agentName?: string } = {}) =>
178173
patchStagedDockerfile(
179174
dockerfilePath,
180175
"custom-model",
@@ -187,8 +182,13 @@ describe("dockerfile patch helpers", () => {
187182
false,
188183
null,
189184
[],
190-
),
191-
).toThrow(/Dockerfile is missing ARG NEMOCLAW_OPENCLAW_OTEL_ENDPOINT/);
185+
options,
186+
);
187+
188+
expect(patch).toThrow(/Dockerfile is missing ARG NEMOCLAW_OPENCLAW_OTEL_ENDPOINT/);
189+
expect(() => patch({ agentName: "hermes" })).toThrow(
190+
"NEMOCLAW_OPENCLAW_OTEL_ENDPOINT is not supported by hermes",
191+
);
192192
});
193193

194194
it("patches base image, inference, proxy, and messaging plan args", () => {

src/lib/onboard/dockerfile-patch.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -556,20 +556,28 @@ export function patchStagedDockerfile(
556556
/^ARG NEMOCLAW_WEB_SEARCH_PROVIDER=.*$/m,
557557
`ARG NEMOCLAW_WEB_SEARCH_PROVIDER=${sanitizeDockerArg(webSearchProviderForConfig(webSearchConfig))}`,
558558
);
559+
// These four ARGs configure OpenClaw's own diagnostics exporter and are
560+
// declared only by the OpenClaw Dockerfile. Another agent's staged Dockerfile
561+
// is not missing them, so report the agent mismatch the way the managed
562+
// startup path already does instead of an internal Dockerfile-authoring error.
563+
const otelAgentName = options.agentName ?? "openclaw";
559564
for (const envKey of [
560565
"NEMOCLAW_OPENCLAW_OTEL",
561566
"NEMOCLAW_OPENCLAW_OTEL_ENDPOINT",
562567
"NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME",
563568
"NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE",
564569
]) {
565570
const rawValue = process.env[envKey];
566-
if (rawValue !== undefined && rawValue.trim() !== "") {
567-
const argPattern = new RegExp(`^ARG ${envKey}=.*$`, "m");
568-
if (!argPattern.test(dockerfile)) {
569-
throw new Error(`Dockerfile is missing ARG ${envKey}; cannot apply value ${rawValue}`);
570-
}
571-
dockerfile = dockerfile.replace(argPattern, `ARG ${envKey}=${sanitizeDockerArg(rawValue)}`);
571+
if (rawValue === undefined || rawValue.trim() === "") continue;
572+
const argPattern = new RegExp(`^ARG ${envKey}=.*$`, "m");
573+
if (!argPattern.test(dockerfile)) {
574+
throw new Error(
575+
otelAgentName === "openclaw"
576+
? `Dockerfile is missing ARG ${envKey}; cannot apply value ${rawValue}`
577+
: `${envKey} is not supported by ${otelAgentName}`,
578+
);
572579
}
580+
dockerfile = dockerfile.replace(argPattern, `ARG ${envKey}=${sanitizeDockerArg(rawValue)}`);
573581
}
574582
// Keep the managed pairing opt-out distinct from an operator's choice.
575583
dockerfile = remoteDashboardBindContract.patchManagedDeviceAuthOptOutContract(dockerfile);

src/lib/onboard/provider-host-state.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,12 @@ describe("detectInferenceProviderHostState", () => {
261261
expect(state.ollamaInstallMenu.entry?.label).toBe("Install Ollama (WSL Linux)");
262262
});
263263

264-
it("does not treat a non-Ollama body from host.docker.internal as a live Windows daemon (#9348)", () => {
264+
it.each([
265+
["an HTML response", "<html>captive portal</html>"],
266+
["a null model entry", '{"models":[null]}'],
267+
["a primitive model entry", '{"models":[1]}'],
268+
["a nested-array model entry", '{"models":[[]]}'],
269+
])("does not treat %s as a live Windows daemon (#9348)", (_label, body) => {
265270
const deps = buildDeps({
266271
isWsl: vi.fn(() => true),
267272
getContainerRuntime: vi.fn<DetectInferenceProviderHostStateDeps["getContainerRuntime"]>(
@@ -272,9 +277,7 @@ describe("detectInferenceProviderHostState", () => {
272277
installedPath: "C:\\Users\\me\\AppData\\Local\\Programs\\Ollama\\ollama.exe",
273278
loopbackOnly: false,
274279
})),
275-
dockerCapture: vi.fn<DetectInferenceProviderHostStateDeps["dockerCapture"]>(() =>
276-
"<html>captive portal</html>",
277-
),
280+
dockerCapture: vi.fn<DetectInferenceProviderHostStateDeps["dockerCapture"]>(() => body),
278281
});
279282

280283
const state = detectWithDeps(deps);

test/dev-setup-doctor.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,18 @@ fi`,
174174
if [ "\${FAKE_GIT_IDENTITY_MISSING:-}" = "1" ]; then exit 1; fi
175175
echo "contributor@example.com"
176176
;;
177+
*" config --get --type=bool commit.gpgsign "*)
178+
if [ "\${FAKE_GIT_SIGNING_MISSING:-}" = "1" ]; then exit 1; fi
179+
if [ "\${FAKE_GIT_SIGNING_UNSET:-}" = "1" ]; then exit 1; fi
180+
if [ "\${FAKE_GIT_SIGNING_INVALID:-}" = "1" ]; then
181+
echo "fatal: bad boolean config value" >&2
182+
exit 128
183+
fi
184+
echo "\${FAKE_GIT_SIGNING_BOOL-true}"
185+
;;
177186
*" config --get commit.gpgsign "*)
178187
if [ "\${FAKE_GIT_SIGNING_MISSING:-}" = "1" ]; then exit 1; fi
179-
echo "true"
188+
echo "1"
180189
;;
181190
*" config --get gpg.format "*)
182191
if [ "\${FAKE_GIT_SIGN_FORMAT_UNSET:-}" = "1" ]; then exit 1; fi
@@ -444,6 +453,39 @@ describe("contributor environment doctor", () => {
444453
expect(result.output).toContain("Git pre-push hook is missing");
445454
});
446455

456+
it.each([
457+
["disabled", { FAKE_GIT_SIGNING_BOOL: "false" }],
458+
["unset", { FAKE_GIT_SIGNING_UNSET: "1" }],
459+
["invalid", { FAKE_GIT_SIGNING_INVALID: "1" }],
460+
])("rejects %s commit signing", (_scenario, env) => {
461+
const fixture = createFixture();
462+
463+
const result = runDoctor(fixture, env);
464+
465+
expect(result.status).toBe(1);
466+
expect(result.output).toContain("Git commit signing is incomplete");
467+
expect(result.output).not.toContain("Git commit signing configured");
468+
expect(result.output).not.toContain("Ready to create a feature branch.");
469+
});
470+
471+
it.each([
472+
["true", "commit.gpgsign=true"],
473+
["yes", "commit.gpgsign=yes"],
474+
["on", "commit.gpgsign=on"],
475+
["1", "commit.gpgsign=1"],
476+
["uppercase", "commit.gpgsign=TRUE"],
477+
["valueless", "commit.gpgsign"],
478+
])("lets Git normalize the %s commit-signing spelling", (_scenario, configArg) => {
479+
const result = spawnSync(
480+
"git",
481+
["-c", configArg, "config", "--get", "--type=bool", "commit.gpgsign"],
482+
{ encoding: "utf-8" },
483+
);
484+
485+
expect(result.status).toBe(0);
486+
expect(result.stdout.trim()).toBe("true");
487+
});
488+
447489
it("rejects an unsupported git signing format with a precise remediation", () => {
448490
const fixture = createFixture();
449491

0 commit comments

Comments
 (0)