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
2 changes: 2 additions & 0 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { upgradeCommand } from "./commands/upgrade.js";
import { devCommand } from "./commands/dev.js";
import { addCommand } from "./commands/add.js";
import { credentialsCommand } from "./commands/credentials.js";
import { namespaceCommand } from "./commands/namespace.js";
import { configCommand } from "./commands/config.js";
import { connectCommand } from "./commands/connect.js";
import { statusCommand } from "./commands/status.js";
Expand Down Expand Up @@ -71,6 +72,7 @@ export function createCli(): Command {

// Configuration
program.addCommand(credentialsCommand());
program.addCommand(namespaceCommand());
program.addCommand(configCommand());
program.addCommand(modelCommand());
program.addCommand(policyCommand());
Expand Down
9 changes: 7 additions & 2 deletions cli/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import chalk from "chalk";
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 {
buildInferencePolicy,
buildToolPolicy,
Expand Down Expand Up @@ -449,9 +450,13 @@ generating per-sandbox AGT ToolPolicy / TrustGraph CRs.
};
if (Object.keys(allSecrets).length > 0) {
spinner.text = "Creating credential secret...";
const namespaceUid = await prepareCredentialNamespace(execa, name, "kars-system");
const metadata = sandbox.metadata as Record<string, unknown>;
metadata.annotations = {
...(metadata.annotations as Record<string, string> | undefined),
[CLAIM.namespaceUid]: namespaceUid,
};
try {
// Ensure namespace exists
await execa("kubectl", ["create", "namespace", namespace], { stdio: "pipe" }).catch(() => {});
const secretArgs = ["create", "secret", "generic", `${name}-credentials`, "-n", namespace];
for (const [envVar, value] of Object.entries(allSecrets)) {
secretArgs.push(`--from-literal=${envVar}=${value}`);
Expand Down
125 changes: 125 additions & 0 deletions cli/src/commands/dev/local-k8s-namespace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { beforeEach, describe, expect, it, vi } from "vitest";
import { parseAllDocuments } from "yaml";
import { autoCreateSandbox, type LocalK8sOptions } from "./local-k8s.js";
import { CLAIM, namespacePrestaged, type OwnershipObject } from "../../lib/namespace-ownership.js";

const { execute } = vi.hoisted(() => ({
execute: vi.fn<(file: string, args: readonly string[], options?: { input?: string }) => Promise<{ stdout: string }>>(),
}));
vi.mock("execa", () => ({ execa: execute }));
vi.mock("../../config.js", () => ({
loadConfig: vi.fn(),
getSecret: (name: string) => name === "telegram-token" ? "test-channel-token" : undefined,
}));
vi.mock("../../refs.js", () => ({ loadAgtProfile: () => "version: 1\n" }));

const tools = {
kind: "kind", kubectl: "/test/bin/kubectl", helm: "helm",
runtime: "docker", runtimeName: "docker" as const, env: {},
};
const options: LocalK8sOptions = {
name: "demo", clusterName: "isolated-test", image: "example.test/sandbox:latest",
ephemeral: false, noBuild: true, channels: "telegram",
};
const credentials = { endpoint: "https://example.test", model: "test-model", apiKey: "" };

function cluster() {
const state = {
sandboxes: [] as OwnershipObject[],
namespace: undefined as OwnershipObject | undefined,
applied: [] as OwnershipObject[],
error: "",
};
execute.mockImplementation(async (file, args, commandOptions) => {
expect(file).toBe(tools.kubectl);
expect(args.slice(0, 2)).toEqual(["--context", "kind-isolated-test"]);
const operation = args[2];
if ((operation === "get" && state.error === "Forbidden (403)")
|| (operation === "create" && state.error === "AlreadyExists (409)")) {
throw new Error(state.error);
}
if (operation === "get") {
const result = args[3] === "karssandboxes" ? { items: state.sandboxes } : state.namespace;
return { stdout: result ? JSON.stringify(result) : "" };
}
if (operation === "create") {
expect(state.namespace).toBeUndefined();
state.namespace = JSON.parse(commandOptions!.input!) as OwnershipObject;
state.namespace.metadata.uid = "reserved-uid";
state.namespace.metadata.resourceVersion = "1";
state.namespace.metadata.creationTimestamp = "2026-09-07T10:00:00Z";
return { stdout: JSON.stringify(state.namespace) };
}
if (operation === "apply") {
state.applied = parseAllDocuments(commandOptions!.input!).map(document => {
if (document.errors.length) throw document.errors[0];
return document.toJSON();
}).filter(Boolean);
return { stdout: "" };
}
throw new Error(`Unexpected operation ${operation}`);
});
return state;
}

beforeEach(() => { execute.mockReset(); });

describe("local-k8s first-party namespace producer", () => {
it("reserves atomically before credentials and puts the exact UID on the Sandbox", async () => {
const state = cluster();
await autoCreateSandbox(tools, options, credentials);
expect(execute.mock.calls.map(([, args]) => args[2])).toEqual(["get", "get", "create", "apply"]);
expect(state.applied.some(resource => resource.kind === "Namespace")).toBe(false);
const sandbox = state.applied.find(resource => resource.kind === "KarsSandbox")!;
sandbox.metadata.uid = "sandbox-uid";
sandbox.metadata.resourceVersion = "2";
sandbox.metadata.creationTimestamp = "2026-09-07T10:00:00Z";
expect(sandbox.metadata.annotations?.[CLAIM.namespaceUid]).toBe("reserved-uid");
expect(namespacePrestaged(state.namespace!, sandbox)).toBe(true);
const secret = state.applied.find(resource => resource.kind === "Secret")!;
expect(secret.metadata).toMatchObject({ name: "demo-credentials", namespace: "kars-demo" });
expect(state.applied.indexOf(secret)).toBeLessThan(state.applied.indexOf(sandbox));

state.sandboxes = [sandbox];
state.namespace!.metadata.annotations![CLAIM.uid] = "sandbox-uid";
delete state.namespace!.metadata.annotations![CLAIM.prestage];
await autoCreateSandbox(tools, options, credentials);
expect(execute.mock.calls.filter(([, args]) => args[2] === "create")).toHaveLength(1);
expect(state.namespace!.metadata.uid).toBe("reserved-uid");
});

it("resumes only the explicit reservation after an interrupted creation", async () => {
const state = cluster();
await autoCreateSandbox(tools, options, credentials);
await autoCreateSandbox(tools, options, credentials);
expect(execute.mock.calls.filter(([, args]) => args[2] === "create")).toHaveLength(1);
expect(state.applied.find(resource => resource.kind === "KarsSandbox")?.metadata.annotations)
.toEqual({ [CLAIM.namespaceUid]: "reserved-uid" });
});

it("never stages credentials into an arbitrary pre-existing namespace", async () => {
const state = cluster();
state.namespace = { metadata: { name: "kars-demo", uid: "customer-uid", resourceVersion: "1" } };
await expect(autoCreateSandbox(tools, options, credentials)).rejects.toThrow("explicit adoption");
expect(execute.mock.calls.every(([, args]) => args[2] === "get")).toBe(true);
});

it("rejects a same-name Sandbox in another workspace before any writes", async () => {
const state = cluster();
state.sandboxes = [{
metadata: { name: "demo", namespace: "other", uid: "other-uid", resourceVersion: "1" },
}];
await expect(autoCreateSandbox(tools, options, credentials)).rejects.toThrow("another workspace");
expect(execute.mock.calls).toHaveLength(1);
});

it.each(["Forbidden (403)", "AlreadyExists (409)"])("surfaces %s without applying credentials", async error => {
const state = cluster();
state.error = error;
await expect(autoCreateSandbox(tools, options, credentials)).rejects.toThrow(error);
expect(execute.mock.calls.some(([, args]) => args[2] === "apply")).toBe(false);
});
});
25 changes: 13 additions & 12 deletions cli/src/commands/dev/local-k8s.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { stageMeshPlugin } from "../../lib/stage-mesh-plugin.js";
import { ensureAgtRepo, ensureAgtWheels } from "../../lib/agt-bootstrap.js";
import { resolveBundledAsset, requireBundledAsset, findRepoRootOrNull } from "../../lib/repo-assets.js";
import { buildCopilotFallbackChain } from "../../github-copilot.js";
import { CLAIM, prepareCredentialNamespace } from "../../lib/namespace-ownership.js";

export interface LocalK8sOptions {
/** Sandbox / agent name. Reused as Helm release name suffix. */
Expand Down Expand Up @@ -2581,25 +2582,30 @@ function resolveTelegramAllowFrom(channels: string | undefined): string | undefi
}

/**
* Auto-create the sandbox in the cluster: a one-shot YAML bundle with
* the namespace, optional credentials Secret (telegram/slack/discord
* tokens), the InferencePolicy CR, and the KarsSandbox CR. Server-side
* apply so re-running `kars dev` is idempotent.
* Reserve the runtime namespace before staging credentials, then apply the
* policies and Sandbox with the namespace UID backlink. Re-running `kars dev`
* preserves an existing, proven Sandbox namespace.
*
* The InferencePolicy `provider` field is just a tag — the actual
* upstream is governed by the controller env (set by the per-run
* dynamic overlay in `provisionDevCreds`). All upstream auth flows
* (Foundry / GitHub Models / GitHub Copilot) end up in the same
* `azure-openai` provider tag here.
*/
async function autoCreateSandbox(
export async function autoCreateSandbox(
tools: Tooling,
opts: LocalK8sOptions,
creds: KarsConfig,
mcpGithub: GithubMcpDecision = { enabled: false, envVarName: "COPILOT_GITHUB_TOKEN" },
): Promise<void> {
const ns = `kars-${opts.name}`;
const policyName = `${opts.name}-inference`;
const namespaceUid = await prepareCredentialNamespace(
(_file, args, options) => execa(tools.kubectl, [
"--context", `kind-${opts.clusterName}`, ...args,
], options),
opts.name, "kars-system",
);

// Channels: convert tokens to a base64-encoded Secret block. The
// controller mounts `<name>-credentials` via `envFrom: secretRef`
Expand Down Expand Up @@ -2662,13 +2668,6 @@ async function autoCreateSandbox(
const memoryStoreName = `memory-${opts.name.substring(0, 56)}`;

const yaml = [
"---",
"apiVersion: v1",
"kind: Namespace",
"metadata:",
` name: ${ns}`,
" labels:",
` kars.azure.com/sandbox: ${opts.name}`,
credsBlock,
"---",
"apiVersion: kars.azure.com/v1alpha1",
Expand Down Expand Up @@ -2805,6 +2804,8 @@ async function autoCreateSandbox(
"metadata:",
` name: ${opts.name}`,
" namespace: kars-system",
" annotations:",
` ${CLAIM.namespaceUid}: ${JSON.stringify(namespaceUid)}`,
...(mcpGithub.enabled
? [" labels:", " mcp-github: allow"]
: []),
Expand Down
39 changes: 39 additions & 0 deletions cli/src/commands/namespace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { afterEach, describe, expect, it, vi } from "vitest";
import { execa } from "execa";
import { namespaceCommand } from "./namespace.js";

vi.mock("execa", () => ({ execa: vi.fn() }));
afterEach(() => vi.restoreAllMocks());

describe("namespace commands", () => {
it("runs a read-only generic-context preflight", async () => {
vi.mocked(execa).mockResolvedValue({ stdout: '{"items":[]}' } as never);
vi.spyOn(console, "log").mockImplementation(() => {});
await namespaceCommand().parseAsync(["preflight"], { from: "user" });
expect(execa).toHaveBeenCalledWith("kubectl", [
"get", "karssandboxes", "-A", "--show-managed-fields=true", "-o", "json",
], { stdio: "pipe" });
});

it("refuses adoption unless the administrator supplies both reviewed UIDs", async () => {
const command = namespaceCommand().exitOverride().configureOutput({
writeErr: () => {}, writeOut: () => {},
});
for (const child of command.commands) {
child.exitOverride().configureOutput({ writeErr: () => {}, writeOut: () => {} });
}
await expect(command.parseAsync(["adopt", "demo", "--namespace", "workspace-a"], {
from: "user",
})).rejects.toThrow("required option");
});

it("propagates API failures instead of printing preflight success", async () => {
vi.mocked(execa).mockRejectedValue(new Error("Forbidden"));
const output = vi.spyOn(console, "log").mockImplementation(() => {});
await expect(namespaceCommand().parseAsync(["preflight"], { from: "user" })).rejects.toThrow("Forbidden");
expect(output).not.toHaveBeenCalled();
});
});
27 changes: 27 additions & 0 deletions cli/src/commands/namespace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { Command } from "commander";
import { execa } from "execa";
import { adoptNamespace, inspectNamespaceOwnership } from "../lib/namespace-ownership.js";

export function namespaceCommand(): Command {
const command = new Command("namespace").description("Inspect and explicitly adopt sandbox namespace ownership");
command.command("preflight")
.description("Read-only ownership checks in the current Kubernetes context (no Secrets are read)")
.action(async () => {
for (const result of await inspectNamespaceOwnership(execa)) console.log(result);
console.log("Namespace ownership preflight passed");
});
command.command("adopt")
.description("Explicit administrator adoption of an unclaimed legacy namespace; preserves all workloads and data")
.argument("<name>", "Existing Sandbox name")
.requiredOption("--namespace <namespace>", "Namespace containing the KarsSandbox CR")
.requiredOption("--sandbox-uid <uid>", "Reviewed live KarsSandbox UID")
.requiredOption("--namespace-uid <uid>", "Reviewed live target namespace UID")
.action(async (name: string, options: { namespace: string; sandboxUid: string; namespaceUid: string }) => {
await adoptNamespace(execa, name, options.namespace, options.sandboxUid, options.namespaceUid);
console.log("Namespace claim recorded; the controller will verify it before reconciliation");
});
return command;
}
Loading
Loading