Skip to content
109 changes: 88 additions & 21 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1143,6 +1143,57 @@ type SelectionDrift = {
unknown: boolean;
};

type InitialSandboxPolicy = {
policyPath: string;
appliedPresets: string[];
cleanup?: () => boolean;
};

const CREATE_TIME_POLICY_PRESETS_BY_CHANNEL: Record<string, string[]> = {
slack: ["slack"],
};

function prepareInitialSandboxCreatePolicy(
basePolicyPath: string,
activeMessagingChannels: string[],
): InitialSandboxPolicy {
const createTimePresets = [
...new Set(
activeMessagingChannels.flatMap(
(channel) => CREATE_TIME_POLICY_PRESETS_BY_CHANNEL[channel] || [],
),
),
];

if (createTimePresets.length === 0) {
return { policyPath: basePolicyPath, appliedPresets: [] };
}

const basePolicy = fs.readFileSync(basePolicyPath, "utf-8");
const mergedPolicy = policies.mergePresetNamesIntoPolicy(basePolicy, createTimePresets);
if (mergedPolicy.missingPresets.length > 0) {
throw new Error(
`Cannot prepare sandbox create policy; missing policy preset(s): ${mergedPolicy.missingPresets.join(", ")}`,
);
}

const policyPath = secureTempFile("nemoclaw-initial-policy", ".yaml");
fs.writeFileSync(policyPath, mergedPolicy.policy, { encoding: "utf-8", mode: 0o600 });

return {
policyPath,
appliedPresets: mergedPolicy.appliedPresets,
cleanup: () => {
try {
cleanupTempDir(policyPath, "nemoclaw-initial-policy");
return true;
} catch {
return false;
}
},
};
}

function upsertMessagingProviders(tokenDefs: MessagingTokenDef[]) {
const upserted = onboardProviders.upsertMessagingProviders(tokenDefs, runOpenshell);
// upsertMessagingProviders process.exits on failure, so reaching this
Expand Down Expand Up @@ -4397,26 +4448,6 @@ async function createSandbox(
"openclaw-sandbox.yaml",
);
const basePolicyPath = (agent && agentOnboard.getAgentPolicyPath(agent)) || defaultPolicyPath;
const createArgs = [
"--from",
`${buildCtx}/Dockerfile`,
"--name",
sandboxName,
"--policy",
basePolicyPath,
];
// --gpu is intentionally omitted. See comment in startGateway().

// 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) and URL-path segments
// (/bot{TOKEN}/) with real secrets at egress (OpenShell ≥ 0.0.20).
const messagingProviders = upsertMessagingProviders(messagingTokenDefs);
for (const p of messagingProviders) {
createArgs.push("--provider", p);
}

console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`);
if (webSearchConfig && !getCredential(webSearch.BRAVE_API_KEY_ENV)) {
console.error(" Brave Search is enabled, but BRAVE_API_KEY is not available in this process.");
console.error(
Expand All @@ -4441,8 +4472,40 @@ async function createSandbox(
if (envKey === "TELEGRAM_BOT_TOKEN") return ["telegram"];
return [];
}),
),
),
];
const initialSandboxPolicy = prepareInitialSandboxCreatePolicy(
basePolicyPath,
activeMessagingChannels,
);
if (initialSandboxPolicy.cleanup) {
process.on("exit", initialSandboxPolicy.cleanup);
}
if (initialSandboxPolicy.appliedPresets.length > 0) {
console.log(
` Including policy preset(s) at sandbox boot: ${initialSandboxPolicy.appliedPresets.join(", ")}`,
);
}
const createArgs = [
"--from",
`${buildCtx}/Dockerfile`,
"--name",
sandboxName,
"--policy",
initialSandboxPolicy.policyPath,
];
// --gpu is intentionally omitted. See comment in startGateway().

// 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) and URL-path segments
// (/bot{TOKEN}/) with real secrets at egress (OpenShell >= 0.0.20).
const messagingProviders = upsertMessagingProviders(messagingTokenDefs);
for (const p of messagingProviders) {
createArgs.push("--provider", p);
}

console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`);
// Build allowed sender IDs map from env vars set during the messaging prompt.
// Each channel with a userIdEnvKey in MESSAGING_CHANNELS may have a
// comma-separated list of IDs (e.g. TELEGRAM_ALLOWED_IDS="123,456").
Expand Down Expand Up @@ -4641,6 +4704,10 @@ async function createSandbox(
},
});

if (initialSandboxPolicy.cleanup && initialSandboxPolicy.cleanup()) {
process.removeListener("exit", initialSandboxPolicy.cleanup);
}

// Clean up build context regardless of outcome.
// Use fs.rmSync instead of run() to avoid spawning a shell process.
// Only deregister the 'exit' safety net when inline cleanup succeeded;
Expand Down
24 changes: 24 additions & 0 deletions src/lib/policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,29 @@ function mergePresetIntoPolicy(currentPolicy: string, presetEntries: string): st
return YAML.stringify(output);
}

function mergePresetNamesIntoPolicy(
currentPolicy: string,
presetNames: string[],
): { policy: string; appliedPresets: string[]; missingPresets: string[] } {
let merged = currentPolicy;
const appliedPresets: string[] = [];
const missingPresets: string[] = [];

for (const presetName of [...new Set(presetNames)]) {
const presetContent = loadPreset(presetName);
const presetEntries = extractPresetEntries(presetContent);
if (!presetEntries) {
missingPresets.push(presetName);
continue;
}

merged = mergePresetIntoPolicy(merged, presetEntries);
appliedPresets.push(presetName);
}

return { policy: merged, appliedPresets, missingPresets };
}

/**
* Remove preset entries from existing policy YAML using structured YAML
* parsing. Identifies which network_policies keys belong to the preset,
Expand Down Expand Up @@ -865,6 +888,7 @@ export {
buildPolicySetCommand,
buildPolicyGetCommand,
mergePresetIntoPolicy,
mergePresetNamesIntoPolicy,
removePresetFromPolicy,
applyPreset,
applyPresetContent,
Expand Down
33 changes: 24 additions & 9 deletions src/nemoclaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1770,16 +1770,25 @@ async function sandboxDoctor(sandboxName: string, args: string[] = []): Promise<
// eslint-disable-next-line complexity
async function sandboxStatus(sandboxName: string) {
const sb = registry.getSandbox(sandboxName);
const liveResult = await captureOpenshellForStatus(["inference", "get"], {
ignoreError: true,
const lookup = await getReconciledSandboxGatewayState(sandboxName, {
getState: getSandboxGatewayStateForStatus,
});
const live = parseGatewayInference(
isCommandTimeout(liveResult) ? "" : liveResult.output,
);
const liveResult =
lookup.state === "present"
? await captureOpenshellForStatus(["inference", "get"], {
ignoreError: true,
})
: null;
const live =
liveResult && !isCommandTimeout(liveResult)
? parseGatewayInference(liveResult.output)
: null;
const currentModel = (live && live.model) || (sb && sb.model) || "unknown";
const currentProvider = (live && live.provider) || (sb && sb.provider) || "unknown";
const inferenceHealth =
typeof currentProvider === "string" ? probeProviderHealth(currentProvider) : null;
lookup.state === "present" && typeof currentProvider === "string"
? probeProviderHealth(currentProvider)
: null;
if (sb) {
console.log("");
console.log(` Sandbox: ${sb.name}`);
Expand All @@ -1795,6 +1804,9 @@ async function sandboxStatus(sandboxName: string) {
console.log(` ${inferenceHealth.detail}`);
}
}
if (lookup.state !== "present") {
console.log(" Inference: not verified (gateway/sandbox state not verified)");
}
console.log(` GPU: ${sb.gpuEnabled ? "yes" : "no"}`);
console.log(` Policies: ${(sb.policies || []).join(", ") || "none"}`);

Expand Down Expand Up @@ -1838,9 +1850,6 @@ async function sandboxStatus(sandboxName: string) {
}
}

const lookup = await getReconciledSandboxGatewayState(sandboxName, {
getState: getSandboxGatewayStateForStatus,
});
if (lookup.state === "present") {
console.log("");
if ("recoveredGateway" in lookup && lookup.recoveredGateway) {
Expand Down Expand Up @@ -1869,6 +1878,7 @@ async function sandboxStatus(sandboxName: string) {
: undefined;
console.log("");
printWrongGatewayActiveGuidance(sandboxName, activeGateway, console.log);
process.exit(1);
} else if (lookup.state === "missing") {
// Belt-and-suspenders: only destroy registry state if the nemoclaw gateway
// is demonstrably the healthy active gateway. Guards against regressions
Expand All @@ -1894,6 +1904,7 @@ async function sandboxStatus(sandboxName: string) {
console.log(` Sandbox '${sandboxName}' is not present in the live OpenShell gateway.`);
console.log(" Removed stale local registry entry.");
}
process.exit(1);
} else if (lookup.state === "identity_drift") {
console.log("");
console.log(
Expand All @@ -1908,6 +1919,7 @@ async function sandboxStatus(sandboxName: string) {
console.log(
` Recreate this sandbox with \`${CLI_NAME} onboard\` once the gateway runtime is stable.`,
);
process.exit(1);
} else if (lookup.state === "gateway_unreachable_after_restart") {
console.log("");
console.log(
Expand All @@ -1922,6 +1934,7 @@ async function sandboxStatus(sandboxName: string) {
console.log(
" If the gateway never becomes healthy, rebuild the gateway and then recreate the affected sandbox.",
);
process.exit(1);
} else if (lookup.state === "gateway_missing_after_restart") {
console.log("");
console.log(
Expand All @@ -1936,13 +1949,15 @@ async function sandboxStatus(sandboxName: string) {
console.log(
" If the gateway had to be rebuilt from scratch, recreate the affected sandbox afterward.",
);
process.exit(1);
} else {
console.log("");
console.log(` Could not verify sandbox '${sandboxName}' against the live OpenShell gateway.`);
if (lookup.output) {
console.log(lookup.output);
}
printGatewayLifecycleHint(lookup.output, sandboxName, console.log);
process.exit(1);
}

// OpenClaw process health inside the sandbox
Expand Down
Loading
Loading