Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
51 changes: 40 additions & 11 deletions cli/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import ora from "ora";
import { loadContext, resolveSecret } from "../config.js";
import { assertRuntimeWired, buildRuntimeBlock, flagToKind } from "../runtime.js";
import { CLAIM, prepareCredentialNamespace } from "../lib/namespace-ownership.js";
import { applySourceSandbox, prepareCredentialSource, updatesFromFlags, waitForCredentialSource, type SourceRef } from "../lib/credential-source.js";
import { FLAG_ENV, SOURCE_KEYS, targetName } from "../lib/credential-source-io.js";
import {
buildInferencePolicy,
buildToolPolicy,
Expand All @@ -20,6 +22,8 @@ export function addCommand(): Command {
cmd
.description("Add a new sandboxed agent to an existing kars cluster")
.argument("<name>", "Name for the new sandbox agent")
.option("--namespace <workspace>", "Workspace for the Sandbox and policy CRs", "kars-system")
.option("--credential-source", "Use a pinned workspace credential source instead of runtime-namespace credentials")

// ── Core (all runtimes) ────────────────────────────────────────────
.option("--runtime <kind>", "Runtime kind: openclaw | openai-agents | microsoft-agent-framework | langgraph | anthropic | pydantic-ai | hermes | byo", "openclaw")
Expand Down Expand Up @@ -87,6 +91,7 @@ generating per-sandbox AGT ToolPolicy / TrustGraph CRs.
`)
.action(async (name: string, options) => {
const { execa } = await import("execa");
targetName(name, options.namespace);

const runtimeKind = flagToKind(options.runtime);
assertRuntimeWired(runtimeKind);
Expand All @@ -110,7 +115,11 @@ generating per-sandbox AGT ToolPolicy / TrustGraph CRs.
["--image", options.image],
];
if (runtimeKind !== "OpenClaw") {
const used = openClawOnlyFlags.filter(([, v]) => v !== undefined && v !== "" && v !== false).map(([f]) => f);
const sourceFlags = options.credentialSource ? new Set(Object.entries(FLAG_ENV)
.filter(([, env]) => (SOURCE_KEYS as readonly string[]).includes(env))
.map(([flag]) => `--${flag.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)}`)) : new Set<string>();
const used = openClawOnlyFlags.filter(([flag, value]) => !sourceFlags.has(flag)
&& value !== undefined && value !== "" && value !== false).map(([flag]) => flag);
if (used.length > 0) {
console.error(chalk.red(`\n Error: ${used.join(", ")} ${used.length === 1 ? "is" : "are"} only valid with --runtime openclaw.`));
console.error(chalk.dim(` Channels, skills, and plugin API keys are OpenClaw-specific entrypoint features.`));
Expand Down Expand Up @@ -146,7 +155,7 @@ generating per-sandbox AGT ToolPolicy / TrustGraph CRs.
kind: "KarsSandbox",
metadata: {
name,
namespace: "kars-system",
namespace: options.namespace,
},
spec: {
runtime: runtimeBlock,
Expand Down Expand Up @@ -292,7 +301,7 @@ generating per-sandbox AGT ToolPolicy / TrustGraph CRs.
// S13: build companion same-namespace policy CRs (sibling to KarsSandbox).
const inferencePolicy = buildInferencePolicy({
sandboxName: name,
namespace: "kars-system",
namespace: options.namespace,
model: options.model,
provider: "azure-ai-foundry",
contentSafety: true,
Expand All @@ -305,7 +314,7 @@ generating per-sandbox AGT ToolPolicy / TrustGraph CRs.
const toolPolicy = options.governance
? buildToolPolicy({
sandboxName: name,
namespace: "kars-system",
namespace: options.namespace,
profile: options.policyProfile || "default",
})
: undefined;
Expand All @@ -316,6 +325,11 @@ generating per-sandbox AGT ToolPolicy / TrustGraph CRs.
const yaml = JSON.stringify(bundle, null, 2);

if (options.dryRun) {
if (options.credentialSource) {
console.log(`Plan: create/update the opted-in source in ${options.namespace}, then bind its API-assigned UID when submitting ${name}.`);
console.log("No runnable source-bound manifest is emitted before a real UID exists. No credential values are printed.");
return;
}
console.log(chalk.bold("\nKarsSandbox manifest (dry-run):\n"));
console.log(yaml);
console.log(chalk.dim("\nApply with: kubectl apply -f <file>"));
Expand Down Expand Up @@ -447,10 +461,21 @@ generating per-sandbox AGT ToolPolicy / TrustGraph CRs.
const allSecrets = {
...channelEnvSecrets,
...pluginSecrets,
...(options.credentialSource ? updatesFromFlags(options) : {}),
};
if (Object.keys(allSecrets).length > 0) {
let sourceReference: SourceRef | undefined;
if (options.credentialSource) {
const prepared = await prepareCredentialSource(execa, name, options.namespace, allSecrets);
const policies = bundle.filter(item => item !== sandbox);
await execa("kubectl", ["apply", "-f", "-"], {
input: JSON.stringify({ apiVersion: "v1", kind: "List", items: policies }),
stdio: ["pipe", "pipe", "pipe"],
});
await applySourceSandbox(execa, sandbox, prepared);
sourceReference = prepared.reference;
} else if (Object.keys(allSecrets).length > 0) {
spinner.text = "Creating credential secret...";
const namespaceUid = await prepareCredentialNamespace(execa, name, "kars-system");
const namespaceUid = await prepareCredentialNamespace(execa, name, options.namespace);
const metadata = sandbox.metadata as Record<string, unknown>;
metadata.annotations = {
...(metadata.annotations as Record<string, string> | undefined),
Expand Down Expand Up @@ -479,10 +504,14 @@ generating per-sandbox AGT ToolPolicy / TrustGraph CRs.
kind: "List",
items: bundle,
};
await execa("kubectl", ["apply", "-f", "-"], {
input: JSON.stringify(bundleManifest),
stdio: ["pipe", "pipe", "pipe"],
});
if (sourceReference) {
await waitForCredentialSource(execa, name, options.namespace, sourceReference);
} else {
await execa("kubectl", ["apply", "-f", "-"], {
input: JSON.stringify(bundleManifest),
stdio: ["pipe", "pipe", "pipe"],
});
}

// The controller auto-mounts <name>-credentials secret via envFrom (optional: true).
// If the secret exists, env vars are injected into the sandbox container at startup.
Expand Down Expand Up @@ -602,7 +631,7 @@ generating per-sandbox AGT ToolPolicy / TrustGraph CRs.
if (options.skills) {
console.log(chalk.dim(` Skills: ${options.skills}`));
}
console.log(chalk.dim(` Status: kubectl get karssandbox ${name} -n kars-system`));
console.log(chalk.dim(` Status: kubectl get karssandbox ${name} -n ${options.namespace}`));
console.log(chalk.dim(` Connect: kars connect ${name}`));
console.log(chalk.dim(` Remove: kars destroy ${name}\n`));

Expand Down
77 changes: 77 additions & 0 deletions cli/src/commands/credentials-source.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { credentialsCommand } from "./credentials.js";
import { addCommand } from "./add.js";

const mocks = vi.hoisted(() => ({
source: vi.fn(), direct: vi.fn(), execute: vi.fn(),
spinner: { start: vi.fn(), succeed: vi.fn(), fail: vi.fn(), warn: vi.fn() },
}));
vi.mock("execa", () => ({ execa: mocks.execute }));
vi.mock("ora", () => ({ default: () => mocks.spinner }));
vi.mock("../config.js", async importOriginal => ({
...await importOriginal<typeof import("../config.js")>(),
resolveSecret: (_value: string | undefined) => undefined,
loadContext: () => undefined,
}));
vi.mock("../lib/credential-source.js", async importOriginal => ({
...await importOriginal<typeof import("../lib/credential-source.js")>(),
updateCredentialSource: mocks.source, updateDirectCredentials: mocks.direct,
}));

beforeEach(() => {
vi.clearAllMocks();
mocks.spinner.start.mockReturnValue(mocks.spinner);
mocks.execute.mockResolvedValue({ stdout: "" });
mocks.direct.mockResolvedValue(undefined);
mocks.source.mockResolvedValue({ kind: "source", reference: { name: "source", uid: "source-uid" } });
vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => vi.restoreAllMocks());

describe("credentials command source integration", () => {
it("routes opt-in flags to the workspace and never prints source values or restarts directly", async () => {
await credentialsCommand().parseAsync([
"update", "demo", "--namespace", "workspace-a", "--use-source", "--telegram-token", "SENSITIVE-INPUT",
], { from: "user" });
expect(mocks.source).toHaveBeenCalledWith(mocks.execute, "demo", "workspace-a", {
updates: { TELEGRAM_BOT_TOKEN: "SENSITIVE-INPUT" }, remove: [],
useSource: true, disableSource: undefined, restart: true,
});
expect(mocks.direct).not.toHaveBeenCalled();
expect(mocks.execute).not.toHaveBeenCalled();
expect(vi.mocked(console.log).mock.calls.flat().join(" ")).not.toContain("SENSITIVE-INPUT");
});

it("supports remote removal and explicit source disable using existing credentials update", async () => {
await credentialsCommand().parseAsync(["update", "demo", "--remove", "telegram-token"], { from: "user" });
expect(mocks.source.mock.calls[0][3].remove).toEqual(["TELEGRAM_BOT_TOKEN"]);
await credentialsCommand().parseAsync(["update", "demo", "--disable-source"], { from: "user" });
expect(mocks.source.mock.calls[1][3].disableSource).toBe(true);
});

it("keeps legacy flags and --no-restart working on the direct path", async () => {
mocks.source.mockResolvedValue(undefined);
await credentialsCommand().parseAsync([
"update", "demo", "--openai-api-key", "legacy-provider", "--no-restart",
], { from: "user" });
expect(mocks.direct).toHaveBeenCalledWith(mocks.execute, "demo", { OPENAI_API_KEY: "legacy-provider" }, []);
expect(mocks.execute).not.toHaveBeenCalled();
});
});

describe("source-mode add flags", () => {
it.each(["openclaw", "openai-agents", "microsoft-agent-framework", "langgraph", "anthropic", "pydantic-ai", "hermes"])(
"permits agent credential environment flags for %s without publishing a fake UID", async runtime => {
await addCommand().parseAsync([
"demo", "--runtime", runtime, "--credential-source", "--telegram-token", "SENSITIVE-INPUT", "--dry-run",
], { from: "user" });
expect(mocks.execute).not.toHaveBeenCalled();
const output = vi.mocked(console.log).mock.calls.flat().join(" ");
expect(output).toContain("No runnable source-bound manifest");
expect(output).not.toContain("SENSITIVE-INPUT");
},
);
});
47 changes: 21 additions & 26 deletions cli/src/commands/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { Command } from "commander";
import chalk from "chalk";
import { removedKeys, updateCredentialSource, updateDirectCredentials } from "../lib/credential-source.js";
import { banner, section } from "../stepper.js";
import {
promptAndSaveCredentials, SECRETS_FILE,
Expand Down Expand Up @@ -233,8 +234,12 @@ export function credentialsCommand(): Command {
// Subcommand: update credentials for a running AKS sandbox
const update = new Command("update");
update
.description("Update credentials for a running AKS sandbox (updates secret + restarts pod)")
.description("Update sandbox credentials (direct Secret or pinned workspace source)")
.argument("<name>", "Sandbox name")
.option("--namespace <workspace>", "Workspace containing the Sandbox CR", "kars-system")
.option("--use-source", "Opt in to a UID-pinned workspace source (migrates direct credentials once)")
.option("--disable-source", "Explicitly restore the unchanged legacy credential collection")
.option("--remove <keys>", "Remove keys (credential flag names or environment keys, comma separated)")
.option("--telegram-token <token>", "New Telegram bot token")
.option("--telegram-allow-from <ids>", "Telegram allowed user IDs (comma-separated)")
.option("--slack-token <token>", "New Slack bot token")
Expand Down Expand Up @@ -269,47 +274,37 @@ export function credentialsCommand(): Command {
if (options[flag]) updates[env] = options[flag];
}

if (Object.keys(updates).length === 0) {
const remove = removedKeys(options.remove);
if (Object.keys(updates).length === 0 && remove.length === 0 && !options.useSource && !options.disableSource) {
console.error(chalk.red(" No credentials specified. Use --telegram-token, --brave-api-key, etc."));
process.exit(1);
}

const namespace = `kars-${name}`;
const secretName = `${name}-credentials`;
const spinner = ora(`Updating credentials for '${name}'...`).start();

try {
// Read existing secret (if any) and merge with new values
let existing: Record<string, string> = {};
try {
const { stdout } = await execa("kubectl", [
"get", "secret", secretName, "-n", namespace,
"-o", "jsonpath={.data}",
], { stdio: "pipe" });
if (stdout && stdout !== "{}") {
const data = JSON.parse(stdout);
for (const [k, v] of Object.entries(data)) {
existing[k] = Buffer.from(v as string, "base64").toString();
}
}
} catch { /* secret doesn't exist yet */ }

const merged = { ...existing, ...updates };

// Create/replace the secret
const secretArgs = ["create", "secret", "generic", secretName, "-n", namespace, "--dry-run=client", "-o", "yaml"];
for (const [env, val] of Object.entries(merged)) {
secretArgs.push(`--from-literal=${env}=${val}`);
const source = await updateCredentialSource(execa, name, options.namespace, {
updates, remove, useSource: options.useSource, disableSource: options.disableSource,
restart: options.restart,
});
if (source) {
spinner.succeed(source.staged
? `Source staged; bind it with kars add ${name} --credential-source`
: source.reference ? "Credential source reconciled; controller owns runtime refresh"
: "Source disabled; controller will restore direct credentials");
if (source.reference) console.log(chalk.dim(` Source: ${options.namespace}/${source.reference.name} (UID ${source.reference.uid})`));
return;
}
const { stdout: yaml } = await execa("kubectl", secretArgs, { stdio: "pipe" });
await execa("kubectl", ["apply", "-f", "-"], { input: yaml, stdio: ["pipe", "pipe", "pipe"] });
await updateDirectCredentials(execa, name, updates, remove);

spinner.succeed("Secret updated");

// Show what changed
for (const [env, val] of Object.entries(updates)) {
console.log(chalk.dim(` ${env} = ••••${val.slice(-4)}`));
}
for (const key of remove) console.log(chalk.dim(` Removed ${key}`));

// Restart pod unless --no-restart
if (options.restart !== false) {
Expand Down
Loading
Loading