add k8s read-only troubleshooting extension

A new extensions/k8s/ extension giving the agent read-only Kubernetes
tools (k8s_get, k8s_describe, k8s_logs, k8s_events, k8s_top, k8s_explain,
k8s_context). Credentials: KUBE_TOKEN from the env, cluster URL from
KUBE_APISERVER env or ~/.pi/agent/k8s.json (editable via the /k8s panel).

All kubectl invocations go through one exec.ts chokepoint that prepends
--server/--token; the token never reaches any render path. A /k8s panel
runs reachability + can-i probes and lets the user set the cluster URL
(press e). A bundled "k8s" mode restricts the toolset to read + grep +
k8s_* for focused troubleshooting. README ships the SA + RBAC manifest.

Container changes (Dockerfile):
  - install kubectl
  - bake customizations into /app (OpenShell Landlock allowlists /app,
    not /opt) and chmod world-readable so the remapped sandbox uid can
    read them
  - openshell-policy.yaml: baseline filesystem/landlock/process policy

Also deletes the unused status-line.ts demo extension.

Known limitation: does NOT work inside an OpenShell sandbox yet —
OpenShell hardcodes the k8s control-plane ports (6443 et al.) as
universally blocked in its SSRF engine with no override. Documented in
extensions/k8s/README.md ("Why not OpenShell yet"). The extension works
when pi runs directly on a host with cluster access.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
EislM0203
2026-05-28 21:34:44 +02:00
co-authored by Claude Opus 4.7
parent 7c650dd13e
commit 415150c6a3
27 changed files with 1743 additions and 37 deletions
+16 -5
View File
@@ -22,12 +22,23 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ARG PI_VERSION=0.75.3
RUN npm install -g @earendil-works/pi-coding-agent@${PI_VERSION}
# kubectl for the k8s extension. Pinned for reproducible builds; bump with
# --build-arg KUBECTL_VERSION=vX.Y.Z. The client is version-skew tolerant
# within ±1 minor against the target cluster.
ARG KUBECTL_VERSION=v1.29.3
RUN curl -fsSL "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" \
-o /usr/local/bin/kubectl && \
chmod 0755 /usr/local/bin/kubectl
RUN userdel -r node && groupdel node 2>/dev/null; \
groupadd -g 1000 sandbox && \
useradd -u 1000 -g 1000 -d /sandbox -s /bin/bash sandbox
# Bake this pi-customizations package into the image.
COPY --chown=sandbox:sandbox . /opt/pi-customizations
# Bake this pi-customizations package into the image at /app/pi-customizations.
# OpenShell's default Landlock policy allowlists /app for read access from the
# sandbox; /opt is not on the allowlist, so installing there causes
# "Permission denied" inside the sandbox even with permissive Unix perms.
COPY --chown=sandbox:sandbox . /app/pi-customizations
# Writable sandbox workdir owned by the sandbox user.
RUN install -d -o sandbox -g sandbox /sandbox
@@ -35,15 +46,15 @@ WORKDIR /sandbox
USER sandbox
# Install this package's runtime dependencies into /opt/pi-customizations/node_modules.
# Install this package's runtime dependencies into /app/pi-customizations/node_modules.
# pi-provided imports (@earendil-works/*, typebox) resolve from pi's global install,
# but the websearch extension also pulls in linkedom + readability + turndown +
# turndown-plugin-gfm (declared in dependencies); those need to be present locally.
RUN cd /opt/pi-customizations && npm ci --omit=dev --no-audit --no-fund
RUN cd /app/pi-customizations && npm ci --omit=dev --no-audit --no-fund
# Register the customizations with pi. A local-path install is recorded in
# ~/.pi/agent/settings.json without copying.
RUN pi install /opt/pi-customizations
RUN pi install /app/pi-customizations
# Informational only — OpenShell's supervisor overrides this; launch with `-- pi`.
CMD ["pi"]
+107
View File
@@ -0,0 +1,107 @@
# k8s extension — setup
Read-only Kubernetes troubleshooting via `kubectl`.
> **OpenShell status:** does NOT work inside an NVIDIA OpenShell sandbox yet (see [Why not OpenShell yet](#why-not-openshell-yet) below). Use this extension by running pi directly on a host that already has cluster access.
## Cluster-side setup
Apply this manifest to your cluster (one-time, by an admin). The ServiceAccount lives in the `apps` namespace but the bindings are cluster-scoped (`ClusterRoleBinding`), so the SA can read across all namespaces:
```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: openshell-k8s-readonly
namespace: apps
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: openshell-k8s-readonly-view
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: view
subjects:
- kind: ServiceAccount
name: openshell-k8s-readonly
namespace: apps
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: openshell-k8s-readonly-extras
rules:
- apiGroups: [""]
resources: ["pods/log", "nodes"]
verbs: ["get", "list"]
- apiGroups: ["metrics.k8s.io"]
resources: ["pods", "nodes"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: openshell-k8s-readonly-extras
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: openshell-k8s-readonly-extras
subjects:
- kind: ServiceAccount
name: openshell-k8s-readonly
namespace: apps
```
Mint a long-lived token (Kubernetes ≥1.24):
```sh
kubectl create token openshell-k8s-readonly -n apps --duration=8760h
```
## Running pi against the cluster
Set the two env vars before launching pi:
```sh
export KUBE_APISERVER=https://your-cluster-api:6443
export KUBE_TOKEN="$(cat /path/to/sa.token)"
pi
```
Or set only `KUBE_TOKEN` and configure the URL from inside pi via `/k8s` → press `e`. The URL is persisted to `~/.pi/agent/k8s.json`.
## Inside pi
Tools are active when `KUBE_TOKEN` is present in the env and a cluster URL is configured (either via `KUBE_APISERVER` env or via `/k8s` → edit cluster URL). Otherwise tools register but return a not-configured message.
`/k8s` opens a diagnostic panel: server reachability, identity, and `can-i` probes for the verbs the extension uses. From the panel:
- press `r` to refresh
- press `e` to set / edit the cluster URL (saved to `~/.pi/agent/k8s.json`)
- press `Esc` to close
The URL precedence is `KUBE_APISERVER` env first, then `~/.pi/agent/k8s.json`. The token is always taken from `KUBE_TOKEN` env.
`/mode k8s` switches to the focused k8s troubleshooting mode (read/grep/find/ls + k8s_*).
## Tools
- `k8s_get` — list/fetch resources (default `-A`)
- `k8s_describe` — detail for one resource
- `k8s_logs` — pod logs (tail capped at 2000)
- `k8s_events` — recent events
- `k8s_top` — CPU/mem (requires metrics-server)
- `k8s_explain` — resource schema
- `k8s_context` — report server + identity
No write verbs. No `exec` / `port-forward` / `cp`. The cluster-side SA RBAC also denies these as defense in depth.
## Why not OpenShell yet
The OpenShell sandbox SSRF engine hardcodes the standard Kubernetes control-plane ports — including `6443` — as universally blocked, with no policy field or setting to relax it (see `BLOCKED_CONTROL_PLANE_PORTS` in `crates/openshell-sandbox/src/proxy.rs` in NVIDIA/OpenShell). The sandbox proxy refuses to open a TCP connection to those ports regardless of `network_policies` or provider profiles.
We tried a TCP/TLS proxy on a non-blocked port (8443) backed by a custom OpenShell provider profile with `auth_style: bearer`. The OPA layer allowed it once the profile was in place, but credential rewriting then runs into proxy ↔ apiserver TLS verification + bearer-substitution edge cases that aren't cleanly solvable without either patching OpenShell or running a more involved L7-aware proxy in front of the apiserver. Not worth the complexity for a personal setup yet.
Re-open this once OpenShell exposes a way to opt out of (or scope) the control-plane port block, e.g. via the policy YAML.
+68
View File
@@ -0,0 +1,68 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { configPath, readK8sConfig, writeK8sConfig } from "./config.js";
let tmpHome: string;
let originalHome: string | undefined;
beforeEach(() => {
originalHome = process.env.HOME;
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-k8s-config-"));
process.env.HOME = tmpHome;
});
afterEach(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
});
describe("k8s config", () => {
it("configPath lives under ~/.pi/agent/k8s.json", () => {
expect(configPath()).toBe(path.join(tmpHome, ".pi", "agent", "k8s.json"));
});
it("returns an empty config when the file is missing", () => {
expect(readK8sConfig()).toEqual({ version: 1 });
});
it("writeK8sConfig then readK8sConfig round-trips apiServer", () => {
writeK8sConfig({ version: 1, apiServer: "https://api.cluster.example:6443" });
expect(readK8sConfig()).toEqual({
version: 1,
apiServer: "https://api.cluster.example:6443",
});
});
it("writeK8sConfig sets file mode to 0600", () => {
writeK8sConfig({ version: 1, apiServer: "https://x" });
const mode = fs.statSync(configPath()).mode & 0o777;
expect(mode).toBe(0o600);
});
it("ignores a config with the wrong version", () => {
fs.mkdirSync(path.dirname(configPath()), { recursive: true });
fs.writeFileSync(
configPath(),
JSON.stringify({ version: 2, apiServer: "https://x" }),
);
expect(readK8sConfig()).toEqual({ version: 1 });
});
it("ignores an unparseable file", () => {
fs.mkdirSync(path.dirname(configPath()), { recursive: true });
fs.writeFileSync(configPath(), "not json");
expect(readK8sConfig()).toEqual({ version: 1 });
});
it("treats empty / whitespace-only apiServer as undefined", () => {
fs.mkdirSync(path.dirname(configPath()), { recursive: true });
fs.writeFileSync(
configPath(),
JSON.stringify({ version: 1, apiServer: " " }),
);
expect(readK8sConfig().apiServer).toBeUndefined();
});
});
+42
View File
@@ -0,0 +1,42 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
export interface K8sConfig {
version: 1;
apiServer?: string;
}
export function configPath(): string {
return path.join(getAgentDir(), "k8s.json");
}
export function readK8sConfig(): K8sConfig {
try {
const raw = fs.readFileSync(configPath(), "utf8");
const parsed = JSON.parse(raw) as unknown;
if (
parsed &&
typeof parsed === "object" &&
(parsed as { version?: unknown }).version === 1
) {
const p = parsed as { apiServer?: unknown };
const apiServer =
typeof p.apiServer === "string" && p.apiServer.trim() !== ""
? p.apiServer.trim()
: undefined;
return { version: 1, apiServer };
}
} catch {}
return { version: 1 };
}
export function writeK8sConfig(cfg: K8sConfig): void {
const dir = getAgentDir();
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
configPath(),
`${JSON.stringify(cfg, null, 2)}\n`,
{ mode: 0o600 },
);
}
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { runDiagnostics, type DiagRunner } from "./diag.js";
import type { K8sEnv } from "./exec.js";
const env: K8sEnv = {
apiServer: "https://api.cluster.example:6443",
token: "tok",
insecureSkipTls: false,
};
function makeRunner(
responses: Record<string, { stdout: string; stderr: string; kind: "ok" | "forbidden" | "notfound" | "network" | "timeout" | "other" }>,
): DiagRunner {
return async (verb, args) => {
const key = `${verb} ${args.join(" ")}`;
const r = responses[key];
if (!r) throw new Error(`unstubbed: ${key}`);
return {
ok: r.kind === "ok",
exitCode: r.kind === "ok" ? 0 : 1,
kind: r.kind,
stdout: r.stdout,
stderr: r.stderr,
verb,
};
};
}
describe("runDiagnostics", () => {
it("collects server version, identity, and per-row can-i answers", async () => {
const responses: Record<string, any> = {
"version ": { kind: "ok", stdout: "Server Version: v1.29.3\n", stderr: "" },
"auth whoami -o jsonpath={.status.userInfo.username}": {
kind: "ok",
stdout: "system:serviceaccount:pi/pi-readonly",
stderr: "",
},
};
for (const [verb, resource] of [
["get", "pods"],
["list", "pods"],
["get", "pods/log"],
["get", "events"],
["get", "nodes"],
["get", "pods.metrics.k8s.io"],
["create", "pods"],
["delete", "pods"],
["patch", "deployments"],
]) {
const allow = !["create", "delete", "patch"].includes(verb);
responses[`auth can-i ${verb} ${resource}`] = {
kind: allow ? "ok" : "forbidden",
stdout: allow ? "yes\n" : "",
stderr: allow ? "" : "Forbidden",
};
}
const report = await runDiagnostics(makeRunner(responses), env);
expect(report.reachable).toBe(true);
expect(report.serverVersion).toContain("v1.29.3");
expect(report.identity).toBe("system:serviceaccount:pi/pi-readonly");
expect(report.allowed.find((p) => p.label.includes("get pods"))?.allowed).toBe(true);
expect(report.denied.find((p) => p.label.includes("create pods"))?.allowed).toBe(false);
});
it("falls back from auth whoami when forbidden / unavailable", async () => {
const responses: Record<string, any> = {
"version ": { kind: "ok", stdout: "v1.27.0", stderr: "" },
"auth whoami -o jsonpath={.status.userInfo.username}": {
kind: "forbidden",
stdout: "",
stderr: "Forbidden",
},
"auth can-i --list": {
kind: "ok",
stdout: "Resources Non-Resource URLs Resource Names Verbs\n",
stderr: "",
},
};
for (const [verb, resource] of [
["get", "pods"], ["list", "pods"], ["get", "pods/log"],
["get", "events"], ["get", "nodes"], ["get", "pods.metrics.k8s.io"],
["create", "pods"], ["delete", "pods"], ["patch", "deployments"],
]) {
responses[`auth can-i ${verb} ${resource}`] = { kind: "ok", stdout: "yes", stderr: "" };
}
const report = await runDiagnostics(makeRunner(responses), env);
expect(report.identity).toMatch(/unknown|forbidden/i);
});
it("returns reachable=false when the first probe fails with network", async () => {
const runner: DiagRunner = async () => ({
ok: false,
exitCode: 1,
kind: "network",
stdout: "",
stderr: "Unable to connect to the server",
verb: "version",
});
const report = await runDiagnostics(runner, env);
expect(report.reachable).toBe(false);
expect(report.error).toContain("network");
});
});
+87
View File
@@ -0,0 +1,87 @@
import type { K8sEnv, KubectlResult } from "./exec.js";
export type DiagRunner = (verb: string, args: string[]) => Promise<KubectlResult>;
export interface PermissionRow {
label: string;
allowed: boolean;
}
export interface DiagnosticsReport {
server: string;
reachable: boolean;
serverVersion?: string;
identity: string;
allowed: PermissionRow[];
denied: PermissionRow[];
error?: string;
}
const ALLOW_PROBES: Array<[string, string, string]> = [
["get", "pods", "can-i get pods"],
["list", "pods", "can-i list pods"],
["get", "pods/log", "can-i get pods/log"],
["get", "events", "can-i get events"],
["get", "nodes", "can-i get nodes"],
["get", "pods.metrics.k8s.io", "can-i get pods.metrics.k8s.io"],
];
const DENY_PROBES: Array<[string, string, string]> = [
["create", "pods", "can-i create pods"],
["delete", "pods", "can-i delete pods"],
["patch", "deployments", "can-i patch deployments"],
];
export async function runDiagnostics(
run: DiagRunner,
env: K8sEnv,
): Promise<DiagnosticsReport> {
const versionRes = await run("version", []);
if (versionRes.kind === "network" || versionRes.kind === "timeout") {
return {
server: env.apiServer,
reachable: false,
identity: "(unknown — server unreachable)",
allowed: [],
denied: [],
error: `${versionRes.kind}: ${versionRes.stderr || "no response"}`,
};
}
const whoamiRes = await run("auth", [
"whoami",
"-o",
"jsonpath={.status.userInfo.username}",
]);
let identity = "(unknown identity)";
if (whoamiRes.kind === "ok" && whoamiRes.stdout.trim()) {
identity = whoamiRes.stdout.trim();
} else {
const fallback = await run("auth", ["can-i", "--list"]);
if (fallback.kind === "forbidden") identity = "(forbidden to introspect identity)";
else if (fallback.kind !== "ok") identity = "(unknown identity)";
}
const probe = async (
entries: Array<[string, string, string]>,
): Promise<PermissionRow[]> => {
const rows: PermissionRow[] = [];
for (const [verb, resource, label] of entries) {
const res = await run("auth", ["can-i", verb, resource]);
rows.push({ label, allowed: res.kind === "ok" });
}
return rows;
};
const allowed = await probe(ALLOW_PROBES);
const denied = await probe(DENY_PROBES);
return {
server: env.apiServer,
reachable: true,
serverVersion: versionRes.stdout.trim() || undefined,
identity,
allowed,
denied,
};
}
+209
View File
@@ -0,0 +1,209 @@
import { describe, expect, it } from "vitest";
import {
buildKubectlArgs,
classifyKubectlResult,
readK8sEnv,
runKubectl,
type K8sEnv,
} from "./exec.js";
describe("readK8sEnv", () => {
it("returns the env when both vars are set and URL is valid", () => {
const r = readK8sEnv({
KUBE_APISERVER: "https://api.cluster.example:6443",
KUBE_TOKEN: "tok",
});
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.env.apiServer).toBe("https://api.cluster.example:6443");
expect(r.env.token).toBe("tok");
expect(r.env.insecureSkipTls).toBe(false);
});
it("flags missing KUBE_APISERVER", () => {
const r = readK8sEnv({ KUBE_TOKEN: "tok" });
expect(r.ok).toBe(false);
});
it("flags missing KUBE_TOKEN", () => {
const r = readK8sEnv({ KUBE_APISERVER: "https://x" });
expect(r.ok).toBe(false);
});
it("flags a malformed KUBE_APISERVER URL", () => {
const r = readK8sEnv({
KUBE_APISERVER: "not-a-url",
KUBE_TOKEN: "tok",
});
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.error.toLowerCase()).toContain("url");
});
it("honors PI_K8S_INSECURE_SKIP_TLS=1", () => {
const r = readK8sEnv({
KUBE_APISERVER: "https://x",
KUBE_TOKEN: "tok",
PI_K8S_INSECURE_SKIP_TLS: "1",
});
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.env.insecureSkipTls).toBe(true);
});
});
describe("buildKubectlArgs", () => {
const env: K8sEnv = {
apiServer: "https://api.cluster.example:6443",
token: "tok",
insecureSkipTls: false,
};
it("prepends --server and --token before verb args", () => {
const args = buildKubectlArgs(env, "get", ["pod", "-A"]);
expect(args).toEqual([
"--server=https://api.cluster.example:6443",
"--token=tok",
"get",
"pod",
"-A",
]);
});
it("adds --insecure-skip-tls-verify when env.insecureSkipTls is true", () => {
const args = buildKubectlArgs(
{ ...env, insecureSkipTls: true },
"get",
["pod"],
);
expect(args).toContain("--insecure-skip-tls-verify=true");
});
it("contains the token only inside the --token= flag (hygiene)", () => {
const secret = "super-secret-token-value-xyz";
const args = buildKubectlArgs(
{
apiServer: "https://api.cluster.example:6443",
token: secret,
insecureSkipTls: false,
},
"get",
["pod", "-A", "-o", "wide"],
);
const tokenOccurrences = args.filter((a) => a.includes(secret));
expect(tokenOccurrences).toEqual([`--token=${secret}`]);
expect(args.slice(2).some((a) => a.includes(secret))).toBe(false);
});
});
describe("classifyKubectlResult", () => {
it("exit 0 → ok", () => {
const r = classifyKubectlResult(
{ stdout: "data", stderr: "", code: 0, killed: false },
"get",
);
expect(r.kind).toBe("ok");
expect(r.ok).toBe(true);
});
it("killed=true → timeout", () => {
const r = classifyKubectlResult(
{ stdout: "", stderr: "", code: 1, killed: true },
"get",
);
expect(r.kind).toBe("timeout");
});
it('stderr containing "forbidden" → forbidden', () => {
const r = classifyKubectlResult(
{
stdout: "",
stderr: 'Error from server (Forbidden): pods is forbidden',
code: 1,
killed: false,
},
"get",
);
expect(r.kind).toBe("forbidden");
});
it('stderr containing "not found" → notfound', () => {
const r = classifyKubectlResult(
{
stdout: "",
stderr: 'Error from server (NotFound): pods "api" not found',
code: 1,
killed: false,
},
"get",
);
expect(r.kind).toBe("notfound");
});
it("stderr mentioning network failure → network", () => {
const r = classifyKubectlResult(
{
stdout: "",
stderr: "Unable to connect to the server: dial tcp ...",
code: 1,
killed: false,
},
"get",
);
expect(r.kind).toBe("network");
});
it("non-zero exit with no marker → other", () => {
const r = classifyKubectlResult(
{ stdout: "", stderr: "weird", code: 1, killed: false },
"get",
);
expect(r.kind).toBe("other");
});
});
describe("runKubectl", () => {
const env: K8sEnv = {
apiServer: "https://api.cluster.example:6443",
token: "tok",
insecureSkipTls: false,
};
it("invokes pi.exec with kubectl + built args and returns a classified result", async () => {
let capturedCommand = "";
let capturedArgs: string[] = [];
const fakePi = {
exec: async (cmd: string, args: string[]) => {
capturedCommand = cmd;
capturedArgs = args;
return { stdout: "ok-out", stderr: "", code: 0, killed: false };
},
};
const r = await runKubectl(fakePi as any, env, "get", ["pod", "-A"]);
expect(capturedCommand).toBe("kubectl");
expect(capturedArgs.slice(0, 2)).toEqual([
"--server=https://api.cluster.example:6443",
"--token=tok",
]);
expect(capturedArgs.slice(2)).toEqual(["get", "pod", "-A"]);
expect(r.kind).toBe("ok");
expect(r.stdout).toBe("ok-out");
});
it("passes signal and timeout through to pi.exec", async () => {
let capturedOpts: any;
const fakePi = {
exec: async (_c: string, _a: string[], opts?: any) => {
capturedOpts = opts;
return { stdout: "", stderr: "", code: 0, killed: false };
},
};
const signal = new AbortController().signal;
await runKubectl(fakePi as any, env, "get", ["pod"], {
signal,
timeoutMs: 12345,
});
expect(capturedOpts.signal).toBe(signal);
expect(capturedOpts.timeout).toBe(12345);
});
});
+98
View File
@@ -0,0 +1,98 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export interface K8sEnv {
apiServer: string;
token: string;
insecureSkipTls: boolean;
}
export type ReadK8sEnvResult =
| { ok: true; env: K8sEnv }
| { ok: false; error: string };
export function readK8sEnv(env: NodeJS.ProcessEnv | Record<string, string | undefined>): ReadK8sEnvResult {
const apiServer = env.KUBE_APISERVER;
const token = env.KUBE_TOKEN;
if (!apiServer) return { ok: false, error: "KUBE_APISERVER is not set" };
if (!token) return { ok: false, error: "KUBE_TOKEN is not set" };
let parsed: URL;
try {
parsed = new URL(apiServer);
} catch {
return { ok: false, error: `KUBE_APISERVER is not a valid URL: ${apiServer}` };
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return { ok: false, error: `KUBE_APISERVER must be http(s); got ${parsed.protocol}` };
}
const insecureSkipTls = env.PI_K8S_INSECURE_SKIP_TLS === "1";
return { ok: true, env: { apiServer, token, insecureSkipTls } };
}
export function buildKubectlArgs(env: K8sEnv, verb: string, verbArgs: string[]): string[] {
const args = [`--server=${env.apiServer}`, `--token=${env.token}`];
if (env.insecureSkipTls) args.push("--insecure-skip-tls-verify=true");
args.push(verb, ...verbArgs);
return args;
}
export type KubectlKind =
| "ok"
| "forbidden"
| "notfound"
| "network"
| "timeout"
| "other";
export interface KubectlResult {
ok: boolean;
exitCode: number;
kind: KubectlKind;
stdout: string;
stderr: string;
verb: string;
}
export function classifyKubectlResult(
exec: { stdout: string; stderr: string; code: number; killed: boolean },
verb: string,
): KubectlResult {
const base = {
exitCode: exec.code,
stdout: exec.stdout,
stderr: exec.stderr,
verb,
};
if (exec.killed) return { ...base, ok: false, kind: "timeout" };
if (exec.code === 0) return { ...base, ok: true, kind: "ok" };
const s = exec.stderr.toLowerCase();
if (/forbidden/.test(s) || /cannot.*access/.test(s)) {
return { ...base, ok: false, kind: "forbidden" };
}
if (/notfound/.test(s) || /not found/.test(s)) {
return { ...base, ok: false, kind: "notfound" };
}
if (/unable to connect to the server|dial tcp|connection refused|no such host/.test(s)) {
return { ...base, ok: false, kind: "network" };
}
return { ...base, ok: false, kind: "other" };
}
export interface RunKubectlOptions {
signal?: AbortSignal;
timeoutMs?: number;
}
export async function runKubectl(
pi: Pick<ExtensionAPI, "exec">,
env: K8sEnv,
verb: string,
verbArgs: string[],
opts?: RunKubectlOptions,
): Promise<KubectlResult> {
const args = buildKubectlArgs(env, verb, verbArgs);
const exec = await pi.exec("kubectl", args, {
signal: opts?.signal,
timeout: opts?.timeoutMs ?? 30_000,
});
return classifyKubectlResult(exec, verb);
}
+370
View File
@@ -0,0 +1,370 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import { buildDescribeArgs, K8sDescribeSchema } from "./tools/describe.js";
import { buildEventsArgs, K8sEventsSchema } from "./tools/events.js";
import { buildExplainArgs, K8sExplainSchema } from "./tools/explain.js";
import { buildGetArgs, K8sGetSchema } from "./tools/get.js";
import { buildLogsArgs, K8sLogsSchema } from "./tools/logs.js";
import { buildTopArgs, K8sTopSchema } from "./tools/top.js";
import { formatContext, K8sContextSchema } from "./tools/context.js";
import { type K8sEnv, readK8sEnv, runKubectl } from "./exec.js";
import { type DiagRunner } from "./diag.js";
import { readK8sConfig } from "./config.js";
import { openK8sPanel } from "./panel.js";
const NOT_CONFIGURED =
"k8s credentials not present in this sandbox. Set KUBE_APISERVER and KUBE_TOKEN by attaching the k8s OpenShell provider, or set the cluster URL via /k8s.";
function resolveK8sEnv(): ReturnType<typeof readK8sEnv> {
const envOnly = readK8sEnv(process.env);
if (envOnly.ok) return envOnly;
if (!process.env.KUBE_APISERVER) {
const cfg = readK8sConfig();
if (cfg.apiServer) {
return readK8sEnv({
...process.env,
KUBE_APISERVER: cfg.apiServer,
});
}
}
return envOnly;
}
export default function (pi: ExtensionAPI) {
let currentEnv: K8sEnv | null = null;
function refreshEnv(): K8sEnv | null {
const result = resolveK8sEnv();
currentEnv = result.ok ? result.env : null;
return currentEnv;
}
function buildRunner(env: K8sEnv | null): DiagRunner | null {
if (!env) return null;
return (verb, args) => runKubectl(pi, env, verb, args, { timeoutMs: 10_000 });
}
pi.on("session_start", async (_event, ctx) => {
const result = resolveK8sEnv();
if (result.ok) {
currentEnv = result.env;
if (result.env.insecureSkipTls && ctx.hasUI) {
ctx.ui.notify(
"PI_K8S_INSECURE_SKIP_TLS=1 — kubectl will skip TLS verification",
"warning",
);
}
} else {
currentEnv = null;
if (process.env.KUBE_APISERVER && ctx.hasUI) {
ctx.ui.notify(`k8s: ${result.error}`, "warning");
}
}
});
pi.registerCommand("k8s", {
description: "Open the read-only k8s diagnostics panel",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("/k8s needs an interactive session", "warning");
return;
}
try {
await openK8sPanel(ctx, refreshEnv, buildRunner);
} catch (err) {
ctx.ui.notify(`/k8s error: ${(err as Error).message}`, "error");
}
},
});
pi.registerTool({
name: "k8s_get",
label: "k8s: get",
description:
"List or fetch Kubernetes resources read-only. Default lists across all namespaces (-A); narrow with namespace, selector, or name.",
parameters: K8sGetSchema,
async execute(_id, params, signal, _onUpdate, _ctx) {
if (!currentEnv) {
return {
content: [{ type: "text", text: NOT_CONFIGURED }],
details: { ok: false, kind: "other", exitCode: -1, verb: "(none)" },
};
}
const res = await runKubectl(pi, currentEnv, "get", buildGetArgs(params), { signal });
const text = res.ok
? res.stdout.trim()
: res.stderr.trim() + (res.stdout.trim() ? `\n${res.stdout.trim()}` : "");
return {
content: [{ type: "text", text }],
details: { ok: res.ok, kind: res.kind, exitCode: res.exitCode, verb: res.verb },
};
},
renderCall(args, theme, _context) {
const ns = args.namespace ? ` -n ${args.namespace}` : " -A";
const name = args.name ? `/${args.name}` : "";
return new Text(
theme.fg("toolTitle", theme.bold("k8s get ")) +
theme.fg("accent", `${args.kind}${name}`) +
theme.fg("muted", ns),
0,
0,
);
},
renderResult(result, _options, theme, _context) {
const d = result.details as { ok: boolean; kind: string } | undefined;
const marker = d?.ok ? theme.fg("success", "✓") : theme.fg("warning", `${d?.kind ?? "err"}`);
return new Text(marker, 0, 0);
},
});
pi.registerTool({
name: "k8s_describe",
label: "k8s: describe",
description: "Show detailed state for a specific resource.",
parameters: K8sDescribeSchema,
async execute(_id, params, signal, _onUpdate, _ctx) {
if (!currentEnv) {
return {
content: [{ type: "text", text: NOT_CONFIGURED }],
details: { ok: false, kind: "other", exitCode: -1, verb: "(none)" },
};
}
const res = await runKubectl(pi, currentEnv, "describe", buildDescribeArgs(params), { signal });
const text = res.ok
? res.stdout.trim()
: res.stderr.trim() + (res.stdout.trim() ? `\n${res.stdout.trim()}` : "");
return {
content: [{ type: "text", text }],
details: { ok: res.ok, kind: res.kind, exitCode: res.exitCode, verb: res.verb },
};
},
renderCall(args, theme, _context) {
const ns = args.namespace ? ` -n ${args.namespace}` : "";
return new Text(
theme.fg("toolTitle", theme.bold("k8s describe ")) +
theme.fg("accent", `${args.kind}/${args.name}`) +
theme.fg("muted", ns),
0,
0,
);
},
renderResult(result, _options, theme, _context) {
const d = result.details as { ok: boolean; kind: string } | undefined;
const marker = d?.ok ? theme.fg("success", "✓") : theme.fg("warning", `${d?.kind ?? "err"}`);
return new Text(marker, 0, 0);
},
});
pi.registerTool({
name: "k8s_logs",
label: "k8s: logs",
description:
"Fetch logs for a pod. Use tail to cap output (default 200, max 2000); previous=true for the prior crash.",
parameters: K8sLogsSchema,
async execute(_id, params, signal, _onUpdate, _ctx) {
if (!currentEnv) {
return {
content: [{ type: "text", text: NOT_CONFIGURED }],
details: { ok: false, kind: "other", exitCode: -1, verb: "(none)" },
};
}
const res = await runKubectl(pi, currentEnv, "logs", buildLogsArgs(params), { signal });
const text = res.ok
? res.stdout.trim()
: res.stderr.trim() + (res.stdout.trim() ? `\n${res.stdout.trim()}` : "");
return {
content: [{ type: "text", text }],
details: { ok: res.ok, kind: res.kind, exitCode: res.exitCode, verb: res.verb },
};
},
renderCall(args, theme, _context) {
const ns = args.namespace ? ` -n ${args.namespace}` : "";
const tail = args.tail ? ` --tail=${args.tail}` : "";
return new Text(
theme.fg("toolTitle", theme.bold("k8s logs ")) +
theme.fg("accent", args.pod) +
theme.fg("muted", `${ns}${tail}`),
0,
0,
);
},
renderResult(result, _options, theme, _context) {
const d = result.details as { ok: boolean; kind: string } | undefined;
const marker = d?.ok ? theme.fg("success", "✓") : theme.fg("warning", `${d?.kind ?? "err"}`);
return new Text(marker, 0, 0);
},
});
pi.registerTool({
name: "k8s_events",
label: "k8s: events",
description: "Recent cluster events from the last hour (default), sorted by lastTimestamp.",
parameters: K8sEventsSchema,
async execute(_id, params, signal, _onUpdate, _ctx) {
if (!currentEnv) {
return {
content: [{ type: "text", text: NOT_CONFIGURED }],
details: { ok: false, kind: "other", exitCode: -1, verb: "(none)" },
};
}
const res = await runKubectl(pi, currentEnv, "events", buildEventsArgs(params), { signal });
const text = res.ok
? res.stdout.trim()
: res.stderr.trim() + (res.stdout.trim() ? `\n${res.stdout.trim()}` : "");
return {
content: [{ type: "text", text }],
details: { ok: res.ok, kind: res.kind, exitCode: res.exitCode, verb: res.verb },
};
},
renderCall(args, theme, _context) {
const ns = args.namespace ? ` -n ${args.namespace}` : " -A";
return new Text(
theme.fg("toolTitle", theme.bold("k8s events")) +
theme.fg("muted", ns),
0,
0,
);
},
renderResult(result, _options, theme, _context) {
const d = result.details as { ok: boolean; kind: string } | undefined;
const marker = d?.ok ? theme.fg("success", "✓") : theme.fg("warning", `${d?.kind ?? "err"}`);
return new Text(marker, 0, 0);
},
});
pi.registerTool({
name: "k8s_top",
label: "k8s: top",
description: "Show resource usage (CPU/mem) for pods or nodes. Requires metrics-server.",
parameters: K8sTopSchema,
async execute(_id, params, signal, _onUpdate, _ctx) {
if (!currentEnv) {
return {
content: [{ type: "text", text: NOT_CONFIGURED }],
details: { ok: false, kind: "other", exitCode: -1, verb: "(none)" },
};
}
const res = await runKubectl(pi, currentEnv, "top", buildTopArgs(params), { signal });
const text = res.ok
? res.stdout.trim()
: res.stderr.trim() + (res.stdout.trim() ? `\n${res.stdout.trim()}` : "");
return {
content: [{ type: "text", text }],
details: { ok: res.ok, kind: res.kind, exitCode: res.exitCode, verb: res.verb },
};
},
renderCall(args, theme, _context) {
const ns = args.namespace ? ` -n ${args.namespace}` : args.kind === "pod" ? " -A" : "";
return new Text(
theme.fg("toolTitle", theme.bold("k8s top ")) +
theme.fg("accent", args.kind) +
theme.fg("muted", ns),
0,
0,
);
},
renderResult(result, _options, theme, _context) {
const d = result.details as { ok: boolean; kind: string } | undefined;
const marker = d?.ok ? theme.fg("success", "✓") : theme.fg("warning", `${d?.kind ?? "err"}`);
return new Text(marker, 0, 0);
},
});
pi.registerTool({
name: "k8s_explain",
label: "k8s: explain",
description: "Show the schema for a resource (or a path inside one).",
parameters: K8sExplainSchema,
async execute(_id, params, signal, _onUpdate, _ctx) {
if (!currentEnv) {
return {
content: [{ type: "text", text: NOT_CONFIGURED }],
details: { ok: false, kind: "other", exitCode: -1, verb: "(none)" },
};
}
const res = await runKubectl(pi, currentEnv, "explain", buildExplainArgs(params), { signal });
const text = res.ok
? res.stdout.trim()
: res.stderr.trim() + (res.stdout.trim() ? `\n${res.stdout.trim()}` : "");
return {
content: [{ type: "text", text }],
details: { ok: res.ok, kind: res.kind, exitCode: res.exitCode, verb: res.verb },
};
},
renderCall(args, theme, _context) {
return new Text(
theme.fg("toolTitle", theme.bold("k8s explain ")) +
theme.fg("accent", args.resource),
0,
0,
);
},
renderResult(result, _options, theme, _context) {
const d = result.details as { ok: boolean; kind: string } | undefined;
const marker = d?.ok ? theme.fg("success", "✓") : theme.fg("warning", `${d?.kind ?? "err"}`);
return new Text(marker, 0, 0);
},
});
pi.registerTool({
name: "k8s_context",
label: "k8s: context",
description:
"Report the current cluster connection (server + identity). No context switching: each sandbox holds one set of k8s credentials.",
parameters: K8sContextSchema,
async execute(_id, params, signal, _onUpdate, _ctx) {
if (!currentEnv) {
return {
content: [{ type: "text", text: NOT_CONFIGURED }],
details: { ok: false, kind: "other", exitCode: -1, verb: "(none)" },
};
}
const whoamiRes = await runKubectl(
pi,
currentEnv,
"auth",
["whoami", "-o", "jsonpath={.status.userInfo.username}"],
{ signal },
);
const whoami = whoamiRes.kind === "ok" ? whoamiRes.stdout : "";
return {
content: [{ type: "text", text: formatContext(params.action, currentEnv.apiServer, whoami) }],
details: { ok: true, kind: "ok", exitCode: 0, verb: "context" },
};
},
renderCall(args, theme, _context) {
return new Text(
theme.fg("toolTitle", theme.bold("k8s context ")) +
theme.fg("muted", args.action),
0,
0,
);
},
renderResult(result, _options, theme, _context) {
const d = result.details as { ok: boolean; kind: string } | undefined;
const marker = d?.ok ? theme.fg("success", "✓") : theme.fg("warning", `${d?.kind ?? "err"}`);
return new Text(marker, 0, 0);
},
});
}
+185
View File
@@ -0,0 +1,185 @@
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
import type { K8sEnv } from "./exec.js";
import { type DiagRunner, type DiagnosticsReport, runDiagnostics } from "./diag.js";
import { readK8sConfig, writeK8sConfig } from "./config.js";
type PanelAction = { kind: "close" } | { kind: "refresh" } | { kind: "edit-url" };
function renderNoEnv(
ctx: ExtensionCommandContext,
currentApiServer: string | undefined,
): Promise<PanelAction> {
return ctx.ui.custom<PanelAction>((_tui, theme, _kb, done) => {
function handleInput(data: string): void {
if (matchesKey(data, Key.escape) || matchesKey(data, Key.enter)) {
done({ kind: "close" });
return;
}
if (data === "e") done({ kind: "edit-url" });
}
function render(width: number): string[] {
const lines: string[] = [];
const add = (s: string) => lines.push(truncateToWidth(s, width));
add(theme.fg("accent", "─".repeat(width)));
add(theme.fg("text", " Kubernetes Diagnostics"));
lines.push("");
add(theme.fg("warning", " No k8s credentials in this sandbox."));
lines.push("");
add(theme.fg("dim", " KUBE_TOKEN must come from an OpenShell provider:"));
add(theme.fg("dim", " openshell provider create --name k8s-read --type generic \\"));
add(theme.fg("dim", ' --credential KUBE_TOKEN="$(cat /path/to/sa.token)"'));
lines.push("");
add(
theme.fg("dim", " Cluster URL: ") +
theme.fg("text", currentApiServer ?? "(unset)"),
);
lines.push("");
add(theme.fg("dim", " e edit cluster URL • Esc / Enter close"));
add(theme.fg("accent", "─".repeat(width)));
return lines;
}
return { render, invalidate: () => {}, handleInput };
});
}
function renderReport(
ctx: ExtensionCommandContext,
report: DiagnosticsReport,
): Promise<PanelAction> {
return ctx.ui.custom<PanelAction>((_tui, theme, _kb, done) => {
let cached: string[] | undefined;
function handleInput(data: string): void {
if (matchesKey(data, Key.escape)) {
done({ kind: "close" });
return;
}
if (data === "r") {
done({ kind: "refresh" });
return;
}
if (data === "e") done({ kind: "edit-url" });
}
function render(width: number): string[] {
if (cached) return cached;
const lines: string[] = [];
const add = (s: string) => lines.push(truncateToWidth(s, width));
add(theme.fg("accent", "─".repeat(width)));
add(theme.fg("text", " Kubernetes Diagnostics"));
lines.push("");
const labelW = 11;
const serverLabel = theme.fg("muted", "Server:".padEnd(labelW));
add(" " + serverLabel + theme.fg("text", report.server));
let reachLine: string;
if (report.reachable) {
const version = report.serverVersion ? ` ${report.serverVersion}` : "";
reachLine = theme.fg("success", `${version}`);
} else {
const err = report.error ? ` ${report.error}` : "";
reachLine = theme.fg("warning", `${err}`);
}
add(" " + theme.fg("muted", "Reachable:".padEnd(labelW)) + reachLine);
lines.push("");
add(" " + theme.fg("muted", "Identity:".padEnd(labelW)) + theme.fg("text", report.identity));
lines.push("");
add(theme.fg("dim", " Permissions (read paths)"));
for (const row of report.allowed) {
const check = row.allowed
? theme.fg("success", "✓")
: theme.fg("warning", "✗");
add(" " + theme.fg("text", row.label.padEnd(34)) + check);
}
if (report.denied.length > 0) {
lines.push("");
add(theme.fg("dim", " Safety (should be denied)"));
for (const row of report.denied) {
let status: string;
if (!row.allowed) {
status = theme.fg("success", "✗ (good)");
} else {
status = theme.fg("warning", "✓ allowed — escalation risk!");
}
add(" " + theme.fg("text", row.label.padEnd(34)) + status);
}
}
lines.push("");
add(theme.fg("dim", " r refresh • e edit cluster URL • Esc close"));
add(theme.fg("accent", "─".repeat(width)));
cached = lines;
return lines;
}
return {
render,
invalidate: () => {
cached = undefined;
},
handleInput,
};
});
}
async function promptApiServer(
ctx: ExtensionCommandContext,
current: string | undefined,
): Promise<void> {
const entered = await ctx.ui.input(
"Cluster API server URL (https://host:port)",
current ?? "",
);
if (entered === undefined) return;
const trimmed = entered.trim();
if (trimmed === "") {
ctx.ui.notify("Cluster URL cannot be empty — unchanged", "warning");
return;
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
ctx.ui.notify(`Cluster URL must be http(s); got ${parsed.protocol}`, "warning");
return;
}
} catch {
ctx.ui.notify(`Not a valid URL: ${trimmed}`, "warning");
return;
}
writeK8sConfig({ version: 1, apiServer: trimmed });
ctx.ui.notify(`Saved cluster URL to k8s.json`, "info");
}
export async function openK8sPanel(
ctx: ExtensionCommandContext,
refreshEnv: () => K8sEnv | null,
buildRunner: (env: K8sEnv | null) => DiagRunner | null,
): Promise<void> {
for (;;) {
const env = refreshEnv();
const runner = buildRunner(env);
let action: PanelAction;
if (!env || !runner) {
const fileUrl = readK8sConfig().apiServer;
action = await renderNoEnv(ctx, process.env.KUBE_APISERVER ?? fileUrl);
} else {
const report = await runDiagnostics(runner, env);
action = await renderReport(ctx, report);
}
if (action.kind === "close") break;
if (action.kind === "edit-url") {
const current =
process.env.KUBE_APISERVER ?? readK8sConfig().apiServer ?? "";
await promptApiServer(ctx, current);
continue;
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { formatContext } from "./context.js";
describe("formatContext", () => {
it('action="current" formats server + identity', () => {
const out = formatContext(
"current",
"https://api.cluster.example:6443",
"system:serviceaccount:pi/pi-readonly",
);
expect(out).toContain("https://api.cluster.example:6443");
expect(out).toContain("system:serviceaccount:pi/pi-readonly");
});
it('action="list" notes there is only one context', () => {
const out = formatContext(
"list",
"https://api.cluster.example:6443",
"system:serviceaccount:pi/pi-readonly",
);
expect(out.toLowerCase()).toContain("only one context");
});
it("handles unknown identity gracefully", () => {
const out = formatContext("current", "https://x", "");
expect(out).toContain("https://x");
expect(out.toLowerCase()).toContain("unknown");
});
});
+21
View File
@@ -0,0 +1,21 @@
import { StringEnum } from "@earendil-works/pi-ai";
import { type Static, Type } from "typebox";
export const K8sContextSchema = Type.Object({
action: StringEnum(["current", "list"] as const, {
description: "What to report about the current cluster connection.",
}),
});
export type K8sContextParams = Static<typeof K8sContextSchema>;
export function formatContext(
action: "current" | "list",
server: string,
whoami: string,
): string {
const identity = whoami.trim() || "(unknown identity)";
const current = `${server} as ${identity}`;
if (action === "current") return current;
return `${current}\n(only one context: this sandbox)`;
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { buildDescribeArgs } from "./describe.js";
describe("buildDescribeArgs", () => {
it("kind + name only, no namespace (kubectl defaults to current ns)", () => {
expect(buildDescribeArgs({ kind: "pod", name: "api" })).toEqual([
"pod",
"api",
]);
});
it("with namespace adds -n", () => {
expect(
buildDescribeArgs({ kind: "pod", name: "api", namespace: "prod" }),
).toEqual(["pod", "api", "-n", "prod"]);
});
it("cluster-scoped resource (node) needs no namespace", () => {
expect(buildDescribeArgs({ kind: "node", name: "n01" })).toEqual([
"node",
"n01",
]);
});
});
+19
View File
@@ -0,0 +1,19 @@
import { type Static, Type } from "typebox";
export const K8sDescribeSchema = Type.Object({
kind: Type.String({ description: "Resource kind (pod, deployment, node, ...)." }),
name: Type.String({ description: "Specific resource name." }),
namespace: Type.Optional(
Type.String({
description: "Namespace. Required for namespaced resources unless they live in 'default'.",
}),
),
});
export type K8sDescribeParams = Static<typeof K8sDescribeSchema>;
export function buildDescribeArgs(p: K8sDescribeParams): string[] {
const args = [p.kind, p.name];
if (p.namespace) args.push("-n", p.namespace);
return args;
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { buildEventsArgs } from "./events.js";
describe("buildEventsArgs", () => {
it("no args → events -A --since=1h", () => {
expect(buildEventsArgs({})).toEqual(["-A", "--since=1h", "--sort-by=.lastTimestamp"]);
});
it("namespace narrows scope", () => {
expect(buildEventsArgs({ namespace: "prod" })).toEqual([
"-n",
"prod",
"--since=1h",
"--sort-by=.lastTimestamp",
]);
});
it("explicit since overrides default", () => {
expect(buildEventsArgs({ since: "10m" })).toEqual([
"-A",
"--since=10m",
"--sort-by=.lastTimestamp",
]);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { type Static, Type } from "typebox";
export const K8sEventsSchema = Type.Object({
namespace: Type.Optional(
Type.String({ description: "Namespace; omit for -A." }),
),
since: Type.Optional(
Type.String({ description: 'Duration window (default "1h").' }),
),
});
export type K8sEventsParams = Static<typeof K8sEventsSchema>;
export function buildEventsArgs(p: K8sEventsParams): string[] {
const args: string[] = [];
if (p.namespace) {
args.push("-n", p.namespace);
} else {
args.push("-A");
}
args.push(`--since=${p.since ?? "1h"}`);
args.push("--sort-by=.lastTimestamp");
return args;
}
+10
View File
@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { buildExplainArgs } from "./explain.js";
describe("buildExplainArgs", () => {
it("passes the resource string through", () => {
expect(buildExplainArgs({ resource: "deployment.spec.strategy" })).toEqual([
"deployment.spec.strategy",
]);
});
});
+13
View File
@@ -0,0 +1,13 @@
import { type Static, Type } from "typebox";
export const K8sExplainSchema = Type.Object({
resource: Type.String({
description: 'Resource (and optional path), e.g. "pod" or "deployment.spec.strategy".',
}),
});
export type K8sExplainParams = Static<typeof K8sExplainSchema>;
export function buildExplainArgs(p: K8sExplainParams): string[] {
return [p.resource];
}
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { buildGetArgs } from "./get.js";
describe("buildGetArgs", () => {
it("kind only → -A and default -o wide", () => {
expect(buildGetArgs({ kind: "pod" })).toEqual([
"pod",
"-A",
"-o",
"wide",
]);
});
it("namespace narrows to -n", () => {
expect(buildGetArgs({ kind: "pod", namespace: "prod" })).toEqual([
"pod",
"-n",
"prod",
"-o",
"wide",
]);
});
it("all=true overrides namespace back to -A", () => {
expect(buildGetArgs({ kind: "pod", namespace: "prod", all: true })).toEqual([
"pod",
"-A",
"-o",
"wide",
]);
});
it("selector adds -l", () => {
expect(buildGetArgs({ kind: "pod", selector: "app=api" })).toEqual([
"pod",
"-A",
"-l",
"app=api",
"-o",
"wide",
]);
});
it("name positions positionally and defaults output to yaml", () => {
expect(
buildGetArgs({ kind: "pod", name: "api-7f9b", namespace: "prod" }),
).toEqual(["pod", "api-7f9b", "-n", "prod", "-o", "yaml"]);
});
it("name wins over selector (selector is dropped)", () => {
expect(
buildGetArgs({ kind: "pod", name: "api-7f9b", selector: "app=api" }),
).toEqual(["pod", "api-7f9b", "-A", "-o", "yaml"]);
});
it("explicit output overrides defaults", () => {
expect(
buildGetArgs({ kind: "pod", output: "json" }),
).toEqual(["pod", "-A", "-o", "json"]);
});
it("output=name works for list", () => {
expect(
buildGetArgs({ kind: "pod", output: "name" }),
).toEqual(["pod", "-A", "-o", "name"]);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { StringEnum } from "@earendil-works/pi-ai";
import { type Static, Type } from "typebox";
export const K8sGetSchema = Type.Object({
kind: Type.String({
description: "Resource kind (e.g. pod, deployment, node, or a CRD).",
}),
namespace: Type.Optional(
Type.String({ description: "Namespace; omit for -A across all namespaces." }),
),
selector: Type.Optional(
Type.String({ description: "Label selector (e.g. app=api). Ignored when name is set." }),
),
name: Type.Optional(
Type.String({ description: "Specific resource name. Wins over selector." }),
),
all: Type.Optional(
Type.Boolean({ description: "Force -A even when namespace is given." }),
),
output: Type.Optional(
StringEnum(["name", "wide", "yaml", "json"] as const, {
description: "Output format. Default 'wide' for lists, 'yaml' when name is set.",
}),
),
});
export type K8sGetParams = Static<typeof K8sGetSchema>;
export function buildGetArgs(p: K8sGetParams): string[] {
const args: string[] = [p.kind];
if (p.name) args.push(p.name);
const useAll = p.all || !p.namespace;
if (useAll) {
args.push("-A");
} else {
args.push("-n", p.namespace!);
}
if (!p.name && p.selector) args.push("-l", p.selector);
const output = p.output ?? (p.name ? "yaml" : "wide");
args.push("-o", output);
return args;
}
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { buildLogsArgs } from "./logs.js";
describe("buildLogsArgs", () => {
it("pod only → default tail=200", () => {
expect(buildLogsArgs({ pod: "api-7f9b" })).toEqual([
"api-7f9b",
"--tail=200",
]);
});
it("container adds -c", () => {
expect(
buildLogsArgs({ pod: "api-7f9b", container: "app" }),
).toEqual(["api-7f9b", "-c", "app", "--tail=200"]);
});
it("namespace adds -n", () => {
expect(
buildLogsArgs({ pod: "api-7f9b", namespace: "prod" }),
).toEqual(["api-7f9b", "-n", "prod", "--tail=200"]);
});
it("explicit tail is honored", () => {
expect(
buildLogsArgs({ pod: "api-7f9b", tail: 500 }),
).toEqual(["api-7f9b", "--tail=500"]);
});
it("tail > 2000 is clamped to 2000", () => {
expect(
buildLogsArgs({ pod: "api-7f9b", tail: 5000 }),
).toEqual(["api-7f9b", "--tail=2000"]);
});
it("tail = 0 is clamped to 1 (kubectl rejects 0)", () => {
expect(
buildLogsArgs({ pod: "api-7f9b", tail: 0 }),
).toEqual(["api-7f9b", "--tail=1"]);
});
it("since adds --since", () => {
expect(
buildLogsArgs({ pod: "api-7f9b", since: "10m" }),
).toEqual(["api-7f9b", "--tail=200", "--since=10m"]);
});
it("previous adds --previous", () => {
expect(
buildLogsArgs({ pod: "api-7f9b", previous: true }),
).toEqual(["api-7f9b", "--tail=200", "--previous"]);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { type Static, Type } from "typebox";
export const K8sLogsSchema = Type.Object({
pod: Type.String({ description: "Pod name." }),
container: Type.Optional(
Type.String({ description: "Container within the pod (omit when pod has one container)." }),
),
namespace: Type.Optional(Type.String({ description: "Pod's namespace." })),
tail: Type.Optional(
Type.Number({
description: "Number of recent log lines. Default 200, clamped to [1, 2000].",
}),
),
since: Type.Optional(
Type.String({ description: 'Duration (e.g. "10m") or RFC3339 timestamp.' }),
),
previous: Type.Optional(
Type.Boolean({ description: "Read logs from the previously crashed container instance." }),
),
});
export type K8sLogsParams = Static<typeof K8sLogsSchema>;
export function buildLogsArgs(p: K8sLogsParams): string[] {
const args = [p.pod];
if (p.container) args.push("-c", p.container);
if (p.namespace) args.push("-n", p.namespace);
const requested = p.tail ?? 200;
const tail = Math.max(1, Math.min(2000, requested));
args.push(`--tail=${tail}`);
if (p.since) args.push(`--since=${p.since}`);
if (p.previous) args.push("--previous");
return args;
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { buildTopArgs } from "./top.js";
describe("buildTopArgs", () => {
it("kind=pod with -A by default", () => {
expect(buildTopArgs({ kind: "pod" })).toEqual(["pod", "-A"]);
});
it("kind=pod with namespace", () => {
expect(buildTopArgs({ kind: "pod", namespace: "prod" })).toEqual([
"pod",
"-n",
"prod",
]);
});
it("kind=node ignores namespace (cluster-scoped)", () => {
expect(buildTopArgs({ kind: "node" })).toEqual(["node"]);
expect(buildTopArgs({ kind: "node", namespace: "prod" })).toEqual([
"node",
]);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { StringEnum } from "@earendil-works/pi-ai";
import { type Static, Type } from "typebox";
export const K8sTopSchema = Type.Object({
kind: StringEnum(["pod", "node"] as const, {
description: "What to show resource usage for.",
}),
namespace: Type.Optional(
Type.String({ description: "For kind=pod only; ignored for nodes." }),
),
});
export type K8sTopParams = Static<typeof K8sTopSchema>;
export function buildTopArgs(p: K8sTopParams): string[] {
if (p.kind === "node") return ["node"];
const args = ["pod"];
if (p.namespace) {
args.push("-n", p.namespace);
} else {
args.push("-A");
}
return args;
}
+25
View File
@@ -49,4 +49,29 @@ export const BUNDLED_MODES: ModeFileFields[] = [
"Don't modify files. Return a compressed brief — quote, don't allude.",
].join(" "),
},
{
name: "k8s",
description: "Read-only Kubernetes troubleshooting",
tools: [
"read",
"ls",
"grep",
"find",
"web_search",
"memory_read",
"k8s_get",
"k8s_describe",
"k8s_logs",
"k8s_events",
"k8s_top",
"k8s_explain",
"k8s_context",
],
prompt: [
"You are in k8s troubleshooting mode.",
"Investigate the cluster read-only via the k8s_* tools.",
"Start broad (events, then pods across namespaces), narrow once you have evidence.",
"Don't propose write actions — you cannot perform them. Report findings and suggest the human's next move.",
].join(" "),
},
];
-32
View File
@@ -1,32 +0,0 @@
/**
* Status Line Extension
*
* Demonstrates ctx.ui.setStatus() for displaying persistent status text in the footer.
* Shows turn progress with themed colors.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
let turnCount = 0;
pi.on("session_start", async (_event, ctx) => {
const theme = ctx.ui.theme;
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
});
pi.on("turn_start", async (_event, ctx) => {
turnCount++;
const theme = ctx.ui.theme;
const spinner = theme.fg("accent", "●");
const text = theme.fg("dim", ` Turn ${turnCount}...`);
ctx.ui.setStatus("status-demo", spinner + text);
});
pi.on("turn_end", async (_event, ctx) => {
const theme = ctx.ui.theme;
const check = theme.fg("success", "✓");
const text = theme.fg("dim", ` Turn ${turnCount} complete`);
ctx.ui.setStatus("status-demo", check + text);
});
}
+25
View File
@@ -0,0 +1,25 @@
# Baseline OpenShell sandbox policy for the pi-customizations container.
#
# Pi-specific provider endpoints (e.g. the k8s read-only API) come from
# provider profiles, which auto-emit network_policies entries when the
# matching provider is attached. See openshell-k8s-profile.yaml.
#
# `filesystem_policy` / `landlock` / `process` mirror the OpenShell
# quickstart defaults; sync if those change upstream.
#
# Usage:
# openshell sandbox create --policy openshell-policy.yaml ...
version: 1
filesystem_policy:
include_workdir: true
read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log]
read_write: [/sandbox, /tmp, /dev/null]
landlock:
compatibility: best_effort
process:
run_as_user: sandbox
run_as_group: sandbox