Skip to content
71 changes: 57 additions & 14 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,11 @@ function upsertProvider(name, type, credentialEnv, baseUrl, env = {}) {
}
}

function verifyProviderExists(name) {
const result = runOpenshell(["provider", "get", name], { ignoreError: true });
return result.status === 0;
}
Comment on lines +480 to +483
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This verification can pass even when the sandbox binding is broken.

verifyProviderExists() only does a global provider get text check. It treats any non-empty output that does not literally contain "not found" as success, and it never inspects whether the just-created sandbox is actually bound to that provider. A CLI/gateway error or a dropped --provider attachment will still pass here, so this warning gives false confidence.

At minimum, base the existence check on command status; ideally, verify the sandbox’s attached providers rather than only global provider existence.

Also applies to: 1910-1916

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bin/lib/onboard.js` around lines 480 - 483, The current verifyProviderExists
function only checks output text for "not found" which can hide command failures
or an unbound sandbox; change verifyProviderExists (and the duplicate call site
around the other provider-check code) to rely on the command exit status from
runCaptureOpenshell (or its underlying spawn API) instead of string matching,
and additionally validate the provider is attached to the target sandbox by
running a sandbox-scoped query (e.g., list/inspect the sandbox's providers) and
confirming the provider name appears in that result; update logic in
verifyProviderExists to return false on non-zero exit or when the
sandbox-attached provider list does not include the given name.


function verifyInferenceRoute(_provider, _model) {
const output = runCaptureOpenshell(["inference", "get"], { ignoreError: true });
if (!output || /Gateway inference:\s*[\r\n]+\s*Not configured/i.test(output)) {
Expand Down Expand Up @@ -1778,27 +1783,56 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null,
];
// --gpu is intentionally omitted. See comment in startGateway().

console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`);
const chatUiUrl = process.env.CHAT_UI_URL || "http://127.0.0.1:18789";
patchStagedDockerfile(stagedDockerfile, model, chatUiUrl, String(Date.now()), provider, preferredInferenceApi);
// Only pass non-sensitive env vars to the sandbox. NVIDIA_API_KEY is NOT
// needed inside the sandbox — inference is proxied through the OpenShell
// gateway which injects the stored credential server-side. The gateway
// also strips any Authorization headers sent by the sandbox client.
// See: crates/openshell-sandbox/src/proxy.rs (header stripping),
// crates/openshell-router/src/backend.rs (server-side auth injection).
const envArgs = [formatEnvAssignment("CHAT_UI_URL", chatUiUrl)];
const sandboxEnv = { ...process.env };
delete sandboxEnv.NVIDIA_API_KEY;
// Create OpenShell providers for messaging credentials so they flow through
// the provider/placeholder system instead of raw env vars. The L7 proxy
// rewrites Authorization headers (Bearer/Bot) with real secrets at egress.
// Telegram provider is created for credential storage but the host-side bridge
// still reads from host env — Telegram uses URL-path auth (/bot{TOKEN}/) which
// the proxy can't rewrite yet.
const messagingProviders = [];
const discordToken = getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN;
if (discordToken) {
sandboxEnv.DISCORD_BOT_TOKEN = discordToken;
upsertProvider("discord-bridge", "generic", "DISCORD_BOT_TOKEN", null, { DISCORD_BOT_TOKEN: discordToken });
messagingProviders.push("discord-bridge");
}
const slackToken = getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN;
if (slackToken) {
sandboxEnv.SLACK_BOT_TOKEN = slackToken;
upsertProvider("slack-bridge", "generic", "SLACK_BOT_TOKEN", null, { SLACK_BOT_TOKEN: slackToken });
messagingProviders.push("slack-bridge");
}
const telegramToken = hydrateCredentialEnv("TELEGRAM_BOT_TOKEN") || process.env.TELEGRAM_BOT_TOKEN;
if (telegramToken) {
upsertProvider("telegram-bridge", "generic", "TELEGRAM_BOT_TOKEN", null, { TELEGRAM_BOT_TOKEN: telegramToken });
messagingProviders.push("telegram-bridge");
}
for (const p of messagingProviders) {
createArgs.push("--provider", p);
}

console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`);
const chatUiUrl = process.env.CHAT_UI_URL || "http://127.0.0.1:18789";
patchStagedDockerfile(stagedDockerfile, model, chatUiUrl, String(Date.now()), provider, preferredInferenceApi);
// Only pass non-sensitive env vars to the sandbox. Credentials flow through
// OpenShell providers — the gateway injects them as placeholders and the L7
// proxy rewrites Authorization headers with real secrets at egress.
// See: crates/openshell-sandbox/src/secrets.rs (placeholder rewriting),
// crates/openshell-router/src/backend.rs (inference auth injection).
const envArgs = [formatEnvAssignment("CHAT_UI_URL", chatUiUrl)];
const blockedSandboxEnvNames = new Set([
"NVIDIA_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GEMINI_API_KEY",
"COMPATIBLE_API_KEY",
"COMPATIBLE_ANTHROPIC_API_KEY",
"DISCORD_BOT_TOKEN",
"SLACK_BOT_TOKEN",
"TELEGRAM_BOT_TOKEN",
]);
const sandboxEnv = Object.fromEntries(
Object.entries(process.env).filter(([name]) => !blockedSandboxEnvNames.has(name))
);

// Run without piping through awk — the pipe masked non-zero exit codes
// from openshell because bash returns the status of the last pipeline
// command (awk, always 0) unless pipefail is set. Removing the pipe
Expand Down Expand Up @@ -1883,6 +1917,15 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null,
console.log(" Setting up sandbox DNS proxy...");
run(`bash "${path.join(SCRIPTS, "setup-dns-proxy.sh")}" ${GATEWAY_NAME} "${sandboxName}" 2>&1 || true`, { ignoreError: true });

// Verify messaging providers are attached to the sandbox
for (const p of messagingProviders) {
if (!verifyProviderExists(p)) {
console.error(` ⚠ Messaging provider '${p}' was not found in the gateway.`);
console.error(` The credential may not be available inside the sandbox.`);
console.error(` To fix: openshell provider create --name ${p} --type generic --credential <KEY>`);
}
}

console.log(` ✓ Sandbox '${sandboxName}' created`);
return sandboxName;
}
Expand Down
7 changes: 6 additions & 1 deletion test/credential-exposure.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,12 @@ describe("credential exposure in process arguments", () => {
it("onboard.js does not embed sandbox secrets in the sandbox create command line", () => {
const src = fs.readFileSync(ONBOARD_JS, "utf-8");

expect(src).toMatch(/const sandboxEnv = \{ \.\.\.process\.env \};/);
// sandboxEnv must be built with a blocklist that strips all credential env vars
expect(src).toMatch(/blockedSandboxEnvNames/);
expect(src).toMatch(/NVIDIA_API_KEY/);
expect(src).toMatch(/DISCORD_BOT_TOKEN/);
expect(src).toMatch(/SLACK_BOT_TOKEN/);
expect(src).toMatch(/TELEGRAM_BOT_TOKEN/);
expect(src).toMatch(/streamSandboxCreate\(createCommand, sandboxEnv(?:, \{)?/);
expect(src).not.toMatch(/envArgs\.push\(formatEnvAssignment\("NVIDIA_API_KEY"/);
expect(src).not.toMatch(/envArgs\.push\(formatEnvAssignment\("DISCORD_BOT_TOKEN"/);
Expand Down
114 changes: 114 additions & 0 deletions test/onboard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,120 @@ const { createSandbox } = require(${onboardPath});
assert.doesNotMatch(createCommand.command, /SLACK_BOT_TOKEN=/);
});

it("creates providers for messaging tokens and attaches them to the sandbox", async () => {
const repoRoot = path.join(import.meta.dirname, "..");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-messaging-providers-"));
const fakeBin = path.join(tmpDir, "bin");
const scriptPath = path.join(tmpDir, "messaging-provider-check.js");
const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js"));
const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js"));
const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js"));
const preflightPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "preflight.js"));
const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js"));

fs.mkdirSync(fakeBin, { recursive: true });
fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 });

const script = String.raw`
const runner = require(${runnerPath});
const registry = require(${registryPath});
const preflight = require(${preflightPath});
const credentials = require(${credentialsPath});
const childProcess = require("node:child_process");
const { EventEmitter } = require("node:events");

const commands = [];
runner.run = (command, opts = {}) => {
commands.push({ command, env: opts.env || null });
return { status: 0 };
};
runner.runCapture = (command) => {
if (command.includes("'sandbox' 'get' 'my-assistant'")) return "";
if (command.includes("'sandbox' 'list'")) return "my-assistant Ready";
if (command.includes("'provider' 'get'")) return "Provider: discord-bridge";
return "";
};
registry.registerSandbox = () => true;
registry.removeSandbox = () => true;
preflight.checkPortAvailable = async () => ({ ok: true });
credentials.prompt = async () => "";

childProcess.spawn = (...args) => {
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
commands.push({ command: args[1][1], env: args[2]?.env || null });
process.nextTick(() => {
child.stdout.emit("data", Buffer.from("Created sandbox: my-assistant\n"));
child.emit("close", 0);
});
return child;
};

const { createSandbox } = require(${onboardPath});

(async () => {
process.env.OPENSHELL_GATEWAY = "nemoclaw";
process.env.DISCORD_BOT_TOKEN = "test-discord-token-value";
process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token-value";
process.env.TELEGRAM_BOT_TOKEN = "123456:ABC-test-telegram-token";
const sandboxName = await createSandbox(null, "gpt-5.4");
console.log(JSON.stringify({ sandboxName, commands }));
})().catch((error) => {
console.error(error);
process.exit(1);
});
`;
fs.writeFileSync(scriptPath, script);

const result = spawnSync(process.execPath, [scriptPath], {
cwd: repoRoot,
encoding: "utf-8",
env: {
...process.env,
HOME: tmpDir,
PATH: `${fakeBin}:${process.env.PATH || ""}`,
NEMOCLAW_NON_INTERACTIVE: "1",
},
});

assert.equal(result.status, 0, result.stderr);
const payloadLine = result.stdout
.trim()
.split("\n")
.slice()
.reverse()
.find((line) => line.startsWith("{") && line.endsWith("}"));
assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`);
const payload = JSON.parse(payloadLine);

// Verify providers were created with the right credential keys
const providerCommands = payload.commands.filter((e) => e.command.includes("'provider' 'create'"));
const discordProvider = providerCommands.find((e) => e.command.includes("discord-bridge"));
assert.ok(discordProvider, "expected discord-bridge provider create command");
assert.match(discordProvider.command, /'--credential' 'DISCORD_BOT_TOKEN'/);

const slackProvider = providerCommands.find((e) => e.command.includes("slack-bridge"));
assert.ok(slackProvider, "expected slack-bridge provider create command");
assert.match(slackProvider.command, /'--credential' 'SLACK_BOT_TOKEN'/);

const telegramProvider = providerCommands.find((e) => e.command.includes("telegram-bridge"));
assert.ok(telegramProvider, "expected telegram-bridge provider create command");
assert.match(telegramProvider.command, /'--credential' 'TELEGRAM_BOT_TOKEN'/);

// Verify sandbox create includes --provider flags for all three
const createCommand = payload.commands.find((e) => e.command.includes("'sandbox' 'create'"));
assert.ok(createCommand, "expected sandbox create command");
assert.match(createCommand.command, /'--provider' 'discord-bridge'/);
assert.match(createCommand.command, /'--provider' 'slack-bridge'/);
assert.match(createCommand.command, /'--provider' 'telegram-bridge'/);

// Verify real token values are NOT in the sandbox create command or env
assert.doesNotMatch(createCommand.command, /test-discord-token-value/);
assert.doesNotMatch(createCommand.command, /xoxb-test-slack-token-value/);
assert.doesNotMatch(createCommand.command, /123456:ABC-test-telegram-token/);
});
Comment on lines +1032 to +1144
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

The secret-scrubbing path is still untested.

Line 1140 says “command or env”, but the assertions only inspect createCommand.command. If sandboxEnv starts forwarding DISCORD_BOT_TOKEN, SLACK_BOT_TOKEN, TELEGRAM_BOT_TOKEN, or the new API-key blocklist again, this test still passes. Please seed those vars in the harness and assert they are absent from createCommand.env.

🧪 Suggested test hardening
 (async () => {
   process.env.OPENSHELL_GATEWAY = "nemoclaw";
-  process.env.DISCORD_BOT_TOKEN = "test-discord-token-value";
-  process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token-value";
-  process.env.TELEGRAM_BOT_TOKEN = "123456:ABC-test-telegram-token";
+  Object.assign(process.env, {
+    DISCORD_BOT_TOKEN: "test-discord-token-value",
+    SLACK_BOT_TOKEN: "xoxb-test-slack-token-value",
+    TELEGRAM_BOT_TOKEN: "123456:ABC-test-telegram-token",
+    OPENAI_API_KEY: "sk-openai-test",
+    ANTHROPIC_API_KEY: "sk-ant-test",
+    GEMINI_API_KEY: "gemini-test",
+    COMPATIBLE_API_KEY: "compatible-test",
+    COMPATIBLE_ANTHROPIC_API_KEY: "compatible-ant-test",
+  });
   const sandboxName = await createSandbox(null, "gpt-5.4");
   console.log(JSON.stringify({ sandboxName, commands }));
 })().catch((error) => {
@@
     assert.doesNotMatch(createCommand.command, /test-discord-token-value/);
     assert.doesNotMatch(createCommand.command, /xoxb-test-slack-token-value/);
     assert.doesNotMatch(createCommand.command, /123456:ABC-test-telegram-token/);
+    for (const name of [
+      "DISCORD_BOT_TOKEN",
+      "SLACK_BOT_TOKEN",
+      "TELEGRAM_BOT_TOKEN",
+      "OPENAI_API_KEY",
+      "ANTHROPIC_API_KEY",
+      "GEMINI_API_KEY",
+      "COMPATIBLE_API_KEY",
+      "COMPATIBLE_ANTHROPIC_API_KEY",
+    ]) {
+      assert.equal(createCommand.env?.[name], undefined, `${name} leaked into sandbox env`);
+    }
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/onboard.test.js` around lines 1032 - 1144, The test must also assert
that sensitive env vars are not forwarded in the sandbox env: after locating
payload.commands and the createCommand entry (as you already do), add assertions
that createCommand.env is either null/undefined or does not contain the
keys/values for DISCORD_BOT_TOKEN, SLACK_BOT_TOKEN, TELEGRAM_BOT_TOKEN (and the
new API-key blocklist key if applicable); reference the existing
payload.commands array and the createCommand variable and assert
createCommand.env does not include the secret names or the literal token values
used earlier in the harness.


it("continues once the sandbox is Ready even if the create stream never closes", async () => {
const repoRoot = path.join(import.meta.dirname, "..");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-create-ready-"));
Expand Down
Loading