Skip to content

Commit c2f2513

Browse files
Merge branch 'main' into fix/debug-bundle-url-credential-redaction
2 parents 4a2a6bf + 711dbdd commit c2f2513

22 files changed

Lines changed: 450 additions & 143 deletions

agents/hermes/Dockerfile

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:212de47e723e9fec1e697d4eec1db82af2d0fb7802aade4fa5dfc3f05274d3c5
1010
ARG NEMOCLAW_CORPORATE_CA_B64=
1111
ARG NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=0
12-
# Portable Podman does not provide BuildKit's automatic platform arguments.
13-
# Its accepted x86_64 path uses this default; multi-platform builders override it.
14-
ARG TARGETARCH=amd64
12+
# BuildKit supplies this automatic platform argument. The Portable staged
13+
# context pins its accepted x86_64 target because Podman does not supply it.
14+
ARG TARGETARCH
1515

1616
# The reviewed npm graph is audited in CI; image assembly copies only its
1717
# generated runtime artifacts and therefore needs neither npm nor network.
@@ -269,14 +269,12 @@ RUN if ! grep -Fq 'ensure("memory.hindsight", prompt=False)' /opt/hermes/hermes_
269269
&& rm /scripts/hermes-security-dependencies.patch
270270

271271
# The final Hermes image owns the shipped dependency boundary independently of
272-
# base freshness. Reassert the idempotent npm-private node-tar fix here. When
273-
# onboarding supplied a corporate CA, use it for the registry-backed download.
274-
RUN if [ -f /usr/local/share/nemoclaw/corporate-ca.pem ]; then \
275-
export CURL_CA_BUNDLE=/usr/local/share/nemoclaw/corporate-ca.pem; \
276-
export NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem; \
277-
fi; \
278-
node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \
279-
--npm-root /usr/local/lib/node_modules/npm
272+
# base freshness. Reassert the idempotent npm-private node-tar fix from the
273+
# locked cache seed so protected rebuilds remain network-disabled.
274+
COPY tools/mcp-tool-discovery-runtime/npm-cache-seed/tar-7.5.21.tgz /tmp/nemoclaw-bundled-npm-tar.tgz
275+
RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \
276+
--npm-root /usr/local/lib/node_modules/npm \
277+
--archive /tmp/nemoclaw-bundled-npm-tar.tgz
280278

281279
# Reassert the npm-private brace-expansion fix for the exact final filesystem.
282280
# When onboarding supplied a corporate CA, use it for the registry-backed

agents/langchain-deepagents-code/validate-read-only-mcp-call.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""Validate deterministic read-only MCP calls against the installed package."""
44

55
import datetime
6+
import errno
67
import ipaddress
78
import json
89
import signal
@@ -183,14 +184,22 @@ def ambiguous_b_c() -> str:
183184
)
184185

185186

186-
def _container_address() -> str:
187-
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
188-
probe.connect(("10.255.255.254", 1))
189-
address = probe.getsockname()[0]
187+
def _validation_hosts() -> tuple[str, str]:
188+
try:
189+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
190+
probe.connect(("10.255.255.254", 1))
191+
address = probe.getsockname()[0]
192+
except OSError as error:
193+
if error.errno != errno.ENETUNREACH:
194+
raise
195+
# Protected image rebuilds deliberately disable BuildKit networking.
196+
# Bind to loopback while using a canonical DNS name so the validation
197+
# still exercises the managed destination and local TLS/MCP contracts.
198+
return "127.0.0.1", "localhost"
190199
parsed = ipaddress.ip_address(address)
191200
if parsed.version != 4 or parsed.is_loopback or parsed.is_link_local:
192201
raise RuntimeError("validation server did not resolve a routed IPv4 address")
193-
return address
202+
return address, address
194203

195204

196205
def _free_port(host: str) -> int:
@@ -208,6 +217,10 @@ def _write_certificate(directory: Path, host: str) -> tuple[Path, Path]:
208217
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
209218
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, host)])
210219
now = datetime.datetime.now(datetime.timezone.utc)
220+
try:
221+
alternative_name: x509.GeneralName = x509.IPAddress(ipaddress.ip_address(host))
222+
except ValueError:
223+
alternative_name = x509.DNSName(host)
211224
certificate = (
212225
x509.CertificateBuilder()
213226
.subject_name(name)
@@ -217,7 +230,7 @@ def _write_certificate(directory: Path, host: str) -> tuple[Path, Path]:
217230
.not_valid_before(now - datetime.timedelta(minutes=1))
218231
.not_valid_after(now + datetime.timedelta(minutes=10))
219232
.add_extension(
220-
x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address(host))]),
233+
x509.SubjectAlternativeName([alternative_name]),
221234
critical=False,
222235
)
223236
.sign(key, hashes.SHA256())
@@ -493,14 +506,14 @@ def main() -> None:
493506
if len(sys.argv) != 1:
494507
raise RuntimeError("invalid validation command")
495508

496-
host = _container_address()
497-
port = _free_port(host)
498-
malformed_port = _free_port(host)
509+
bind_host, url_host = _validation_hosts()
510+
port = _free_port(bind_host)
511+
malformed_port = _free_port(bind_host)
499512
while malformed_port == port:
500-
malformed_port = _free_port(host)
513+
malformed_port = _free_port(bind_host)
501514
with tempfile.TemporaryDirectory(prefix="nemoclaw-read-only-mcp-") as raw_directory:
502515
directory = Path(raw_directory)
503-
cert, key = _write_certificate(directory, host)
516+
cert, key = _write_certificate(directory, url_host)
504517
marker = directory / "calls"
505518
malformed_marker = directory / "malformed-calls"
506519
processes = [
@@ -511,7 +524,7 @@ def main() -> None:
511524
str(Path(__file__)),
512525
"--server",
513526
mode,
514-
host,
527+
bind_host,
515528
str(server_port),
516529
str(cert),
517530
str(key),
@@ -527,10 +540,10 @@ def main() -> None:
527540
)
528541
]
529542
try:
530-
_wait_for_server(host, port, cert, processes[0])
531-
_wait_for_server(host, malformed_port, cert, processes[1])
543+
_wait_for_server(url_host, port, cert, processes[0])
544+
_wait_for_server(url_host, malformed_port, cert, processes[1])
532545
_validate(
533-
host,
546+
url_host,
534547
port,
535548
malformed_port,
536549
cert,

docs/manage-sandboxes/manage-messaging-channels.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,9 @@ The next rebuild reuses the bridge provider without requiring the service-accoun
113113
Hermes Google Chat does not use the dedicated webhook endpoint or `$$nemoclaw tunnel` commands.
114114
</AgentOnly>
115115

116-
When `channels start` re-enables a channel, NemoClaw reapplies the matching built-in policy preset before rebuild.
117-
If policy restoration fails, the command keeps the channel disabled and exits without rebuilding into a partially active state.
116+
When `channels start` re-enables a channel, NemoClaw records the channel as enabled in the messaging plan.
117+
The rebuild attaches the existing bridge provider before applying its matching built-in policy preset to the replacement sandbox.
118+
If the command queues the change without rebuilding, the running sandbox keeps its existing bridge and network policy until you rebuild it.
118119

119120
## Avoid Cross-Sandbox Conflicts
120121

docs/reference/commands.mdx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2981,12 +2981,13 @@ Use `channels stop` instead of `channels remove` when you want to pause a bridge
29812981

29822982
### `$$nemoclaw <name> channels start <channel>`
29832983

2984-
Re-enable a channel previously paused with `channels stop`. The channel is removed from the disabled list, the sandbox is rebuilt, and the bridge registers with the gateway again using the stored credentials.
2984+
Re-enable a channel previously paused with `channels stop`.
29852985
The command verifies that the sandbox's agent runtime supports the channel before reading configured or disabled channel state.
29862986
It then requires the channel to be configured for the sandbox.
2987-
Before the rebuild, NemoClaw reapplies the matching built-in network policy preset so the restored bridge has egress to its upstream API.
2988-
Before updating the disabled list or applying the policy, NemoClaw prints the exact effective egress scope when the preset would open or replace access, or reports that no new egress would be opened when the preset is already effective.
2989-
If policy restoration fails, NemoClaw rolls the channel back to disabled and exits without rebuilding into a partially active state.
2987+
NemoClaw removes the channel from the disabled list and records it as enabled in the messaging plan.
2988+
The rebuild uses that plan to attach the existing bridge provider before applying its matching built-in network policy preset to the replacement sandbox.
2989+
Before updating the disabled list, NemoClaw prints the exact effective egress scope when the preset would open or replace access, or reports that no new egress would be opened when the preset is already effective.
2990+
If the command queues the change without rebuilding, the running sandbox keeps its existing bridge and network policy until you run `$$nemoclaw <name> rebuild`.
29902991

29912992
```bash
29922993
$$nemoclaw my-assistant channels start telegram

scripts/patch-bundled-npm-tar.mts

Lines changed: 83 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ import {
1616
readFileSync,
1717
renameSync,
1818
rmSync,
19+
writeFileSync,
1920
} from "node:fs";
2021
import { tmpdir } from "node:os";
21-
import { dirname, join, resolve } from "node:path";
22+
import { dirname, isAbsolute, join, resolve } from "node:path";
2223
import { fileURLToPath } from "node:url";
2324

2425
import {
@@ -136,7 +137,7 @@ export function patchBundledNpmTar(options: {
136137
const stagingRoot = mkdtempSync(join(dirname(livePath), ".tar.nemoclaw-stage-"));
137138
const stagedPath = join(stagingRoot, "replacement");
138139
const backupPath = `${livePath}.nemoclaw-backup-${transactionId}`;
139-
let mutationStarted = false;
140+
let rollbackRequired = false;
140141
try {
141142
cpSync(replacementRoot, stagedPath, { dereference: false, recursive: true });
142143
cpSync(livePath, backupPath, {
@@ -146,17 +147,18 @@ export function patchBundledNpmTar(options: {
146147
preserveTimestamps: true,
147148
recursive: true,
148149
});
149-
mutationStarted = true;
150+
rollbackRequired = true;
150151
rmSync(livePath, { recursive: true });
151152
renameSync(stagedPath, livePath);
152153
const fixed = verifyBundledNpmTar(npmRoot);
153154
if (fixed.tarVersion !== FIXED_TAR_VERSION) {
154155
throw new Error(`npm bundled tar replacement did not reach tar@${FIXED_TAR_VERSION}`);
155156
}
157+
rollbackRequired = false;
156158
rmSync(backupPath, { force: true, recursive: true });
157159
return fixed;
158160
} catch (error) {
159-
if (mutationStarted) {
161+
if (rollbackRequired) {
160162
rmSync(livePath, { force: true, recursive: true });
161163
renameSync(backupPath, livePath);
162164
}
@@ -190,24 +192,17 @@ export type BundledNpmTarRegistryDependencies = Readonly<{
190192
prepareReplacement?: (commandRunner: BundledNpmTarCommandRunner) => PreparedReplacement;
191193
}>;
192194

193-
function prepareFixedTarReplacement(
195+
function prepareFixedTarReplacementFromArchive(
196+
archivePath: string,
194197
commandRunner: BundledNpmTarCommandRunner,
195198
): PreparedReplacement {
199+
if (!isAbsolute(archivePath)) {
200+
throw new Error("npm bundled tar replacement archive path must be absolute");
201+
}
196202
const rootDirectory = mkdtempSync(join(tmpdir(), "nemoclaw-npm-tar-bootstrap-"));
197-
const archivePath = join(rootDirectory, `tar-${FIXED_TAR_VERSION}.tgz`);
203+
const verifiedArchivePath = join(rootDirectory, `tar-${FIXED_TAR_VERSION}.tgz`);
198204
const replacementRoot = join(rootDirectory, "replacement");
199205
try {
200-
commandRunner("curl", [
201-
"--proto",
202-
"=https",
203-
"--tlsv1.2",
204-
"--fail",
205-
"--silent",
206-
"--show-error",
207-
"--output",
208-
archivePath,
209-
FIXED_TAR_TARBALL,
210-
]);
211206
const archiveDescriptor = openSync(archivePath, constants.O_RDONLY | constants.O_NOFOLLOW);
212207
let archiveBytes: Buffer;
213208
try {
@@ -225,12 +220,13 @@ function prepareFixedTarReplacement(
225220
);
226221
}
227222

223+
writeFileSync(verifiedArchivePath, archiveBytes, { flag: "wx", mode: 0o600 });
228224
mkdirSync(replacementRoot, { mode: 0o700 });
229225
commandRunner("tar", [
230226
"--extract",
231227
"--gzip",
232228
"--file",
233-
archivePath,
229+
verifiedArchivePath,
234230
"--directory",
235231
replacementRoot,
236232
"--strip-components=1",
@@ -247,18 +243,49 @@ function prepareFixedTarReplacement(
247243
}
248244
}
249245

250-
export function patchBundledNpmTarFromRegistry(
246+
function prepareFixedTarReplacement(
247+
commandRunner: BundledNpmTarCommandRunner,
248+
): PreparedReplacement {
249+
const rootDirectory = mkdtempSync(join(tmpdir(), "nemoclaw-npm-tar-download-"));
250+
const archivePath = join(rootDirectory, `tar-${FIXED_TAR_VERSION}.tgz`);
251+
try {
252+
commandRunner("curl", [
253+
"--proto",
254+
"=https",
255+
"--tlsv1.2",
256+
"--fail",
257+
"--silent",
258+
"--show-error",
259+
"--output",
260+
archivePath,
261+
FIXED_TAR_TARBALL,
262+
]);
263+
const prepared = prepareFixedTarReplacementFromArchive(archivePath, commandRunner);
264+
return {
265+
cleanup: () => {
266+
prepared.cleanup();
267+
rmSync(rootDirectory, { force: true, recursive: true });
268+
},
269+
replacementRoot: prepared.replacementRoot,
270+
};
271+
} catch (error) {
272+
rmSync(rootDirectory, { force: true, recursive: true });
273+
throw error;
274+
}
275+
}
276+
277+
function patchBundledNpmTarWithPreparedReplacement(
251278
npmRoot: string,
252-
dependencies: BundledNpmTarRegistryDependencies = {},
279+
commandRunner: BundledNpmTarCommandRunner,
280+
prepareReplacement: () => PreparedReplacement,
253281
): BundledNpmTarState {
254-
const commandRunner = dependencies.commandRunner ?? run;
255282
const current = inspectBundledNpmTar(npmRoot);
256283
if (current.state === "fixed") {
257284
commandRunner("npm", ["--version"]);
258285
commandRunner("npx", ["--version"]);
259286
return current;
260287
}
261-
const prepared = (dependencies.prepareReplacement ?? prepareFixedTarReplacement)(commandRunner);
288+
const prepared = prepareReplacement();
262289
try {
263290
const result = patchBundledNpmTar({
264291
npmRoot,
@@ -272,20 +299,53 @@ export function patchBundledNpmTarFromRegistry(
272299
}
273300
}
274301

302+
export function patchBundledNpmTarFromRegistry(
303+
npmRoot: string,
304+
dependencies: BundledNpmTarRegistryDependencies = {},
305+
): BundledNpmTarState {
306+
const commandRunner = dependencies.commandRunner ?? run;
307+
return patchBundledNpmTarWithPreparedReplacement(npmRoot, commandRunner, () =>
308+
(dependencies.prepareReplacement ?? prepareFixedTarReplacement)(commandRunner),
309+
);
310+
}
311+
312+
export function patchBundledNpmTarFromArchive(
313+
npmRoot: string,
314+
archivePath: string,
315+
commandRunner: BundledNpmTarCommandRunner = run,
316+
): BundledNpmTarState {
317+
return patchBundledNpmTarWithPreparedReplacement(npmRoot, commandRunner, () =>
318+
prepareFixedTarReplacementFromArchive(archivePath, commandRunner),
319+
);
320+
}
321+
275322
function argument(name: string): string {
276323
const index = process.argv.indexOf(name);
277324
const value = index >= 0 ? process.argv[index + 1] : undefined;
278325
if (!value || value.startsWith("--")) throw new Error(`${name} is required`);
279326
return value;
280327
}
281328

329+
function optionalArgument(name: string): string | undefined {
330+
const index = process.argv.indexOf(name);
331+
if (index < 0) return undefined;
332+
const value = process.argv[index + 1];
333+
if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
334+
return value;
335+
}
336+
282337
function isMainModule(): boolean {
283338
return process.argv[1] ? fileURLToPath(import.meta.url) === resolve(process.argv[1]) : false;
284339
}
285340

286341
if (isMainModule()) {
287342
try {
288-
const result = patchBundledNpmTarFromRegistry(argument("--npm-root"));
343+
const npmRoot = argument("--npm-root");
344+
const archivePath = optionalArgument("--archive");
345+
const result = archivePath
346+
? patchBundledNpmTarFromArchive(npmRoot, archivePath)
347+
: patchBundledNpmTarFromRegistry(npmRoot);
348+
if (archivePath) rmSync(archivePath);
289349
process.stdout.write(
290350
`Verified npm@${result.npmVersion} bundled tar@${result.tarVersion} (minimum ${MINIMUM_SAFE_TAR_VERSION})\n`,
291351
);

0 commit comments

Comments
 (0)