Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5bb9090
fix(sandbox): reconcile readonly gateway-token trust anchor on source
yanyunl1991 Aug 6, 2026
bda0025
test(sandbox): update proxy-env golden assertions for token temp var
yanyunl1991 Aug 6, 2026
46d089e
refactor(sandbox): shorten gateway-token temp var name
yanyunl1991 Aug 6, 2026
bd2c86c
Merge remote-tracking branch 'origin/main' into fix/proxy-env-token-c…
yanyunl1991 Aug 6, 2026
125af97
Merge remote-tracking branch 'origin/main' into fix/proxy-env-token-c…
yanyunl1991 Aug 6, 2026
e91c296
fix(sandbox): harden gateway token reconciliation
apurvvkumaria Aug 6, 2026
40ff0ef
test(sandbox): keep gateway token contract linear
apurvvkumaria Aug 6, 2026
6d1e19a
test(sandbox): cover repeated token sourcing in bash
apurvvkumaria Aug 6, 2026
04b6acd
Merge remote-tracking branch 'origin/main' into codex/pr8432-feedback
apurvvkumaria Aug 6, 2026
10e6536
fix(sandbox): reject shadowed token comparisons
apurvvkumaria Aug 6, 2026
1beda0b
fix(sandbox): fail closed on shadowed status commands
apurvvkumaria Aug 7, 2026
1ae9214
test(sandbox): assert sourced conflict status directly
apurvvkumaria Aug 7, 2026
150b05d
merge(main): refresh gateway token hardening
apurvvkumaria Aug 7, 2026
79cb75d
Merge branch 'main' into fix/proxy-env-token-conflict-8428
cv Aug 7, 2026
cc7f1da
Merge remote-tracking branch 'origin/main' into fix/proxy-env-token-c…
yanyunl1991 Aug 7, 2026
7258761
Merge branch 'main' into fix/proxy-env-token-conflict-8428
cv Aug 7, 2026
e2effc2
Merge branch 'main' into fix/proxy-env-token-conflict-8428
cv Aug 7, 2026
415bf54
Merge remote-tracking branch 'origin/main' into fix/proxy-env-token-c…
yanyunl1991 Aug 7, 2026
ab833c5
merge: resolve conflicts with main
github-actions[bot] Aug 7, 2026
94e01bc
test(sandbox): cover readonly remote token conflicts
apurvvkumaria Aug 7, 2026
d4f26c4
merge(main): refresh readonly token regression
apurvvkumaria Aug 7, 2026
204ef3b
merge: resolve conflicts with main
github-actions[bot] Aug 7, 2026
8efaabb
Merge branch 'main' into fix/proxy-env-token-conflict-8428
cv Aug 9, 2026
be51e52
merge: resolve conflicts with main
github-actions[bot] Aug 10, 2026
6436a78
Merge branch 'main' into fix/proxy-env-token-conflict-8428
apurvvkumaria Aug 10, 2026
59750e2
Merge branch 'main' into fix/proxy-env-token-conflict-8428
apurvvkumaria Aug 10, 2026
fc9f95f
Merge branch 'main' into fix/proxy-env-token-conflict-8428
apurvvkumaria Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ci/test-file-size-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"src/lib/onboard/preflight.test.ts": 1875,
"test/generate-openclaw-config.test.ts": 1915,
"test/install-preflight.test.ts": 3906,
"test/nemoclaw-start.test.ts": 4791,
"test/nemoclaw-start.test.ts": 4790,
"test/onboard-messaging.test.ts": 2036,
"test/onboard-selection.test.ts": 4767
}
Expand Down
41 changes: 37 additions & 4 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3435,6 +3435,31 @@ _RUNTIME_SHELL_ENV_SHIM="[ -f ${_RUNTIME_SHELL_ENV_FILE} ] && . ${_RUNTIME_SHELL

write_runtime_shell_env() {
_PROXY_ENV_FILE="/tmp/nemoclaw-proxy-env.sh"
_emit_gateway_token_reconcile() {
local _escaped_intended_gateway_token="$1"
cat <<'GATEWAYTOKENRECONCILESTART'
# nemoclaw-gateway-token-reconcile start
# The proxy-env file is the trust anchor for OPENCLAW_GATEWAY_TOKEN. Probe
# writability in a subshell so a readonly pin cannot abort sourcing: advance the
# anchor when writable (also the fresh-source and repeated-source paths), stay
# silent when it already holds the intended value, and otherwise emit a
# controlled conflict diagnostic that never echoes the trusted token.
GATEWAYTOKENRECONCILESTART
printf "if ( OPENCLAW_GATEWAY_TOKEN='%s' ) 2>/dev/null; then\n" \
"$_escaped_intended_gateway_token"
printf " OPENCLAW_GATEWAY_TOKEN='%s'\n" "$_escaped_intended_gateway_token"
printf "else\n case \"\${OPENCLAW_GATEWAY_TOKEN-}\" in\n"
printf " '%s') : ;;\n" "$_escaped_intended_gateway_token"
cat <<'GATEWAYTOKENRECONCILEEND'
*)
/usr/bin/printf '%s\n' 'Error: conflicting trust anchor' >&2
/usr/bin/false
;;
esac
fi
# nemoclaw-gateway-token-reconcile end
GATEWAYTOKENRECONCILEEND
}
{
cat <<PROXYEOF
# Proxy configuration (overrides narrow OpenShell defaults on connect)
Expand Down Expand Up @@ -4121,22 +4146,30 @@ GUARDENVEOF
# URL, including loopback, while WhatsApp revalidates its local override
# immediately at the specialized exec boundary.
printf 'export OPENCLAW_GATEWAY_TOKEN\n'
# Bake the intended value into each URL-case arm, then reconcile it against
# any pre-existing value. Avoiding a caller-visible temporary variable is
# required because the sourcing shell can already have any variable name
# pinned readonly. A blind assignment would abort sourcing with the shell's
# raw readonly error (exit 2) — and could echo the failing assignment line
# — when OPENCLAW_GATEWAY_TOKEN is already readonly and conflicting (#8428).
cat <<'GATEWAYTOKENENVEOF'
case "${OPENCLAW_GATEWAY_URL:-}" in
*@*)
OPENCLAW_GATEWAY_TOKEN=
;;
GATEWAYTOKENENVEOF
_emit_gateway_token_reconcile ""
printf ' ;;\n'
cat <<'GATEWAYTOKENENVEOF'
'' | ws://127.0.0.1 | ws://127.0.0.1:* | ws://127.0.0.1/* | \
wss://127.0.0.1 | wss://127.0.0.1:* | wss://127.0.0.1/* | \
ws://localhost | ws://localhost:* | ws://localhost/* | \
wss://localhost | wss://localhost:* | wss://localhost/* | \
"ws://[::1]" | "ws://[::1]:"* | "ws://[::1]/"* | \
"wss://[::1]" | "wss://[::1]:"* | "wss://[::1]/"*)
GATEWAYTOKENENVEOF
printf " OPENCLAW_GATEWAY_TOKEN='%s'\n" "$_escaped_gateway_token"
_emit_gateway_token_reconcile "$_escaped_gateway_token"
printf ' ;;\n'
printf ' *)\n'
printf ' OPENCLAW_GATEWAY_TOKEN=\n'
_emit_gateway_token_reconcile ""
printf ' ;;\n'
printf 'esac\n'
fi
Expand Down
3 changes: 1 addition & 2 deletions test/nemoclaw-start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@ import os from "node:os";
import path from "node:path";
import * as ts from "typescript";
import { describe, expect, it } from "vitest";
import { openClawBootstrapSnippet } from "./support/entrypoint-script-fixture";

import { extractShellFunctionFromSource } from "./helpers/shell-source";
import { openClawBootstrapSnippet } from "./support/entrypoint-script-fixture";

const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh");
const APPROVAL_POLICY_DIR = path.join(import.meta.dirname, "..", "scripts", "lib");
Expand Down
210 changes: 210 additions & 0 deletions test/proxy-env-gateway-token-conflict.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Behavioral contract for the OPENCLAW_GATEWAY_TOKEN trust-anchor reconcile
// block emitted into /tmp/nemoclaw-proxy-env.sh by scripts/nemoclaw-start.sh.
// Exercises the actual generated file under POSIX sh and Bash. Regression: a
// blind assignment aborted sourcing with the shell's raw readonly error when
// the sourcing shell had already pinned OPENCLAW_GATEWAY_TOKEN readonly to a
// conflicting value (#8428).

import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";

import { extractShellFunctionFromSource } from "./helpers/shell-source";

const OPENCLAW_START = join(import.meta.dirname, "../scripts/nemoclaw-start.sh");
const WRITE_RUNTIME_SHELL_ENV = extractShellFunctionFromSource(
readFileSync(OPENCLAW_START, "utf-8"),
"write_runtime_shell_env",
);
const REAL_TOKEN = "REAL-GATEWAY-TOKEN-abc123";
const SHELLS = ["sh", "bash"] as const;
const EMPTY_TOKEN_URLS = [
"wss://remote.example.test",
"wss://user:password@remote.example.test",
] as const;

const tmpRoots: string[] = [];
afterEach(() => {
for (const dir of tmpRoots.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});

interface Scenario {
intended: string;
shell?: (typeof SHELLS)[number];
preset?: { value: string; readonly: boolean };
repeatSources?: boolean;
readonlyPrivateSentinel?: boolean;
shadowTestCommand?: boolean;
shadowStatusCommands?: boolean;
sourceUrl?: string;
}

function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}

function runReconcile(scenario: Scenario): {
status: number | null;
stdout: string;
stderr: string;
} {
const dir = mkdtempSync(join(tmpdir(), "nemoclaw-token-reconcile-"));
tmpRoots.push(dir);
const envFile = join(dir, "proxy-env.sh");
const generator = join(dir, "generate.sh");
writeFileSync(
generator,
[
"#!/usr/bin/env bash",
"set -e",
'emit_sandbox_sourced_file() { cat > "$1"; }',
WRITE_RUNTIME_SHELL_ENV.replaceAll("/tmp/nemoclaw-proxy-env.sh", envFile),
'_PROXY_URL="http://10.200.0.1:3128"',
'_NO_PROXY_VAL="localhost,127.0.0.1,::1,10.200.0.1"',
'_SANDBOX_SAFETY_NET="/tmp/safety-net.js"',
'_PROXY_FIX_SCRIPT="/tmp/http-proxy-fix.js"',
'_NEMOTRON_FIX_SCRIPT="/tmp/nemotron-fix.js"',
'_CIAO_GUARD_SCRIPT="/tmp/ciao-guard.js"',
"_TOOL_REDIRECTS=()",
`OPENCLAW_GATEWAY_TOKEN=${shellQuote(scenario.intended)}`,
"write_runtime_shell_env",
"",
].join("\n"),
{ mode: 0o700 },
);
spawnSync("bash", [generator], { encoding: "utf-8" });

const setup = [
scenario.preset
? `${scenario.preset.readonly ? "readonly " : ""}OPENCLAW_GATEWAY_TOKEN=${shellQuote(scenario.preset.value)}`
: "",
scenario.readonlyPrivateSentinel ? "readonly _nemoclaw_gateway_token='CALLER-SENTINEL'" : "",
scenario.shadowTestCommand ? "function [ { return 0; }" : "",
scenario.shadowStatusCommands
? "function return { builtin return 0; }; function exit { builtin return 0; }; function echo { builtin return 0; }"
: "",
scenario.sourceUrl ? `OPENCLAW_GATEWAY_URL=${shellQuote(scenario.sourceUrl)}` : "",
].filter(Boolean);
const sourceAndPrint = [
`. ${shellQuote(envFile)}`,
"_nemoclaw_test_source_status=$?",
`case "$_nemoclaw_test_source_status" in 0) /usr/bin/printf 'TOKEN=[%s] PRIVATE=[%s]\\n' "\${OPENCLAW_GATEWAY_TOKEN-<UNSET>}" "\${_nemoclaw_gateway_token-<UNSET>}" ;; *) /usr/bin/false ;; esac`,
];
const commands = scenario.repeatSources
? [...setup, ...sourceAndPrint, ...sourceAndPrint]
: [...setup, ...sourceAndPrint];
return spawnSync(scenario.shell ?? "sh", ["-c", commands.join("; ")], {
encoding: "utf-8",
});
}

describe("proxy-env OPENCLAW_GATEWAY_TOKEN trust-anchor reconcile (#8428)", () => {
it.each(SHELLS)("emits a controlled conflict diagnostic under %s", (shell) => {
const { status, stdout, stderr } = runReconcile({
intended: REAL_TOKEN,
shell,
preset: { value: "SENTINEL_CONFLICT", readonly: true },
});
expect(status).toBe(1);
expect(stderr).toContain("Error: conflicting trust anchor");
expect(stderr).not.toContain("read only");
expect(`${stdout}\n${stderr}`).not.toContain(REAL_TOKEN);
expect(stdout).not.toContain("TOKEN=");
});

it.each(SHELLS)("accepts an identical readonly trust anchor under %s", (shell) => {
const { status, stdout, stderr } = runReconcile({
intended: REAL_TOKEN,
shell,
preset: { value: REAL_TOKEN, readonly: true },
});
expect(status).toBe(0);
expect(stdout).toContain(`TOKEN=[${REAL_TOKEN}]`);
expect(stderr).not.toContain("conflicting trust anchor");
expect(stderr).not.toContain("read only");
});

it("rejects a conflicting readonly value when Bash shadows the test command", () => {
const { status, stdout, stderr } = runReconcile({
intended: REAL_TOKEN,
shell: "bash",
preset: { value: "SENTINEL_CONFLICT", readonly: true },
shadowTestCommand: true,
});
expect(status).toBe(1);
expect(stderr).toContain("Error: conflicting trust anchor");
expect(stderr).not.toContain("read only");
expect(`${stdout}\n${stderr}`).not.toContain(REAL_TOKEN);
expect(stdout).not.toContain("TOKEN=");
});

it("rejects a conflicting readonly value when Bash shadows status commands", () => {
const { status, stdout, stderr } = runReconcile({
intended: REAL_TOKEN,
shell: "bash",
preset: { value: "SENTINEL_CONFLICT", readonly: true },
shadowStatusCommands: true,
});
expect(status).toBe(1);
expect(stderr).toContain("Error: conflicting trust anchor");
expect(stderr).not.toContain("read only");
expect(`${stdout}\n${stderr}`).not.toContain(REAL_TOKEN);
expect(stdout).not.toContain("TOKEN=");
});

it.each(SHELLS)("advances a writable value across repeated sourcing under %s", (shell) => {
const { status, stdout, stderr } = runReconcile({
intended: REAL_TOKEN,
shell,
preset: { value: "WRITABLE-SENTINEL", readonly: false },
repeatSources: true,
});
expect(status).toBe(0);
expect(stdout.match(new RegExp(`TOKEN=\\[${REAL_TOKEN}\\]`, "g"))).toHaveLength(2);
expect(stderr).toBe("");
});

it("does not depend on a caller-controlled readonly temporary variable", () => {
const { status, stdout, stderr } = runReconcile({
intended: REAL_TOKEN,
readonlyPrivateSentinel: true,
});
expect(status).toBe(0);
expect(stdout).toContain(`TOKEN=[${REAL_TOKEN}] PRIVATE=[CALLER-SENTINEL]`);
expect(stderr).toBe("");
});

it("keeps the non-loopback case exported empty", () => {
const { status, stdout, stderr } = runReconcile({
intended: REAL_TOKEN,
sourceUrl: "wss://remote.example.test",
});
expect(status).toBe(0);
expect(stdout).toContain("TOKEN=[]");
expect(stderr).toBe("");
});

it.each(
SHELLS.flatMap((shell) => EMPTY_TOKEN_URLS.map((sourceUrl) => ({ shell, sourceUrl }))),
)("rejects a readonly nonempty token for $sourceUrl under $shell", ({ shell, sourceUrl }) => {
const { status, stdout, stderr } = runReconcile({
intended: REAL_TOKEN,
shell,
preset: { value: "SENTINEL_CONFLICT", readonly: true },
sourceUrl,
});
expect(status).toBe(1);
expect(stderr).toContain("Error: conflicting trust anchor");
expect(stderr).not.toContain("read only");
expect(`${stdout}\n${stderr}`).not.toContain("SENTINEL_CONFLICT");
expect(`${stdout}\n${stderr}`).not.toContain(REAL_TOKEN);
expect(stdout).not.toContain("TOKEN=");
});
});
12 changes: 11 additions & 1 deletion test/service-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,7 +662,17 @@ describe("service environment", () => {
expect(envFile).toContain("10.200.0.1");
expect(envFile).toContain('export AWS_EC2_METADATA_DISABLED="true"');
expect(envFile).toContain("export OPENCLAW_GATEWAY_TOKEN");
expect(envFile).toContain("OPENCLAW_GATEWAY_TOKEN='test-token-123'");
for (const shell of ["sh", "bash"]) {
const sourced = execFileSync(
shell,
[
"-c",
`unset OPENCLAW_GATEWAY_TOKEN OPENCLAW_GATEWAY_URL _nemoclaw_gateway_token; . '${join(fakeDataDir, "proxy-env.sh")}'; printf 'TOKEN=[%s] TEMP=[%s]\\n' "\${OPENCLAW_GATEWAY_TOKEN-<UNSET>}" "\${_nemoclaw_gateway_token-<UNSET>}"`,
],
{ encoding: "utf-8" },
);
expect(sourced).toContain("TOKEN=[test-token-123] TEMP=[<UNSET>]");
}
expect(envFile).toContain("nemoclaw-configure-guard begin");
expect(envFile).toContain('/usr/bin/env openclaw "$@"');
// Tool cache redirects should be present (#804)
Expand Down
Loading