diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f210db1..c8872e37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -281,6 +281,9 @@ jobs: - name: Run cluster server tests run: pnpm --filter @t4-code/cluster-server test + - name: Run session runtime credential preflight tests + run: bun test cluster/images/session-runtime/assert-omp-credentials-absent.test.ts + - name: Typecheck cluster server run: pnpm --filter @t4-code/cluster-server typecheck diff --git a/.woodpecker.yml b/.woodpecker.yml index 98024327..4f8c556e 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -208,6 +208,7 @@ steps: - export SSL_CERT_FILE="$PWD/.ci/ca-certificates.crt" - export NODE_EXTRA_CA_CERTS="$SSL_CERT_FILE" - (cd packages/cluster-server && bun --bun run test) + - bun test cluster/images/session-runtime/assert-omp-credentials-absent.test.ts - (cd packages/cluster-server && bun --bun run typecheck) backend_options: kubernetes: diff --git a/cluster/images/session-runtime/assert-omp-credentials-absent.test.ts b/cluster/images/session-runtime/assert-omp-credentials-absent.test.ts new file mode 100644 index 00000000..6be4a7ec --- /dev/null +++ b/cluster/images/session-runtime/assert-omp-credentials-absent.test.ts @@ -0,0 +1,122 @@ +import { Database } from "bun:sqlite"; +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { assertOMPProfileCredentialsAbsent } from "./assert-omp-credentials-absent"; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); +}); + +async function fixture(): Promise<{ root: string; home: string; agentDir: string; databasePath: string }> { + const root = await mkdtemp(path.join(tmpdir(), "t4-omp-credential-check-")); + roots.push(root); + const home = path.join(root, "home"); + const agentDir = path.join(home, ".omp", "profiles", "session-a", "agent"); + await mkdir(agentDir, { recursive: true }); + return { root, home, agentDir, databasePath: path.join(agentDir, "agent.db") }; +} + +function createPinnedSchema(databasePath: string): Database { + const database = new Database(databasePath); + database.run(` + CREATE TABLE auth_schema_version (id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL); + INSERT INTO auth_schema_version(id, version) VALUES (1, 6); + CREATE TABLE auth_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + credential_type TEXT NOT NULL, + data TEXT NOT NULL, + disabled_cause TEXT DEFAULT NULL, + identity_key TEXT DEFAULT NULL, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL DEFAULT 0); + CREATE TABLE history (id INTEGER PRIMARY KEY, value TEXT NOT NULL); + `); + return database; +} + +describe("OMP durable credential preflight", () => { + test("accepts the pinned schema only when credential state is absent", async () => { + const { home, agentDir, databasePath } = await fixture(); + const database = createPinnedSchema(databasePath); + database.query("INSERT INTO settings(key, value) VALUES (?, ?)").run("theme", JSON.stringify("dark")); + database.query("INSERT INTO history(id, value) VALUES (?, ?)").run(1, "unrelated session state"); + database.close(); + + await expect(assertOMPProfileCredentialsAbsent({ agentDir, home })).resolves.toBeUndefined(); + const verified = new Database(databasePath, { create: false, readonly: true }); + expect(verified.query("SELECT value FROM settings WHERE key = 'theme'").get()).toEqual({ value: '"dark"' }); + expect(verified.query("SELECT value FROM history WHERE id = 1").get()).toEqual({ value: "unrelated session state" }); + verified.close(); + }); + + test("rejects credential rows without mutating them", async () => { + const { home, agentDir, databasePath } = await fixture(); + const database = createPinnedSchema(databasePath); + database.query("INSERT INTO auth_credentials(provider, credential_type, data) VALUES (?, ?, ?)").run( + "anthropic", + "api_key", + JSON.stringify({ key: "must-remain-until-an-operator-removes-it" }), + ); + database.close(); + + await expect(assertOMPProfileCredentialsAbsent({ agentDir, home })).rejects.toThrow( + "OMP auth credentials must be absent", + ); + const verified = new Database(databasePath, { create: false, readonly: true }); + expect((verified.query("SELECT COUNT(*) AS count FROM auth_credentials").get() as { count: number }).count).toBe(1); + verified.close(); + }); + + test("rejects secret settings and broker files without deleting them", async () => { + const { home, agentDir, databasePath } = await fixture(); + const database = createPinnedSchema(databasePath); + database.query("INSERT INTO settings(key, value) VALUES (?, ?)").run("auth.broker.token", "present"); + database.close(); + await expect(assertOMPProfileCredentialsAbsent({ agentDir, home })).rejects.toThrow( + "OMP secret settings must be absent", + ); + + const tokenPath = path.join(home, ".omp", "auth-broker.token"); + await writeFile(tokenPath, "present", { mode: 0o600 }); + await expect(assertOMPProfileCredentialsAbsent({ agentDir, home })).rejects.toThrow( + "credential state is present: auth-broker.token", + ); + }); + + test("fails closed for unknown schemas and symlinked profile directories", async () => { + const { root, home, agentDir, databasePath } = await fixture(); + const database = new Database(databasePath); + database.run("CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, data TEXT NOT NULL)"); + database.close(); + await expect(assertOMPProfileCredentialsAbsent({ agentDir, home })).rejects.toThrow( + "unsupported auth_credentials schema", + ); + + const linkedHome = path.join(root, "linked-home"); + await symlink(home, linkedHome); + await expect( + assertOMPProfileCredentialsAbsent({ + agentDir: path.join(linkedHome, ".omp", "profiles", "session-a", "agent"), + home: linkedHome, + }), + ).rejects.toThrow("OMP profile path must be a real directory"); + }); + + test("rejects a symlinked broker cache without following it", async () => { + const { root, home, agentDir } = await fixture(); + const outsideCache = path.join(root, "outside-cache"); + await mkdir(outsideCache); + await symlink(outsideCache, path.join(home, ".omp", "cache")); + + await expect(assertOMPProfileCredentialsAbsent({ agentDir, home })).rejects.toThrow( + "OMP profile path must be a real directory: cache", + ); + }); +}); diff --git a/cluster/images/session-runtime/assert-omp-credentials-absent.ts b/cluster/images/session-runtime/assert-omp-credentials-absent.ts new file mode 100644 index 00000000..c2a9843d --- /dev/null +++ b/cluster/images/session-runtime/assert-omp-credentials-absent.ts @@ -0,0 +1,152 @@ +import { Database } from "bun:sqlite"; +import { lstat } from "node:fs/promises"; +import path from "node:path"; + +const AUTH_SCHEMA_VERSION = 6; +const AUTH_COLUMNS = [ + "id", + "provider", + "credential_type", + "data", + "disabled_cause", + "identity_key", + "created_at", + "updated_at", +] as const; +const SETTINGS_COLUMNS = ["key", "value", "updated_at"] as const; +const SECRET_SETTING_KEYS = [ + "auth.broker.token", + "hindsight.apiToken", + "searxng.token", + "dev.autoqaPush.token", +] as const; + +type ColumnRow = { name?: unknown }; +type CountRow = { count?: unknown }; +type VersionRow = { version?: unknown }; + +function tableExists(database: Database, table: string): boolean { + const row = database + .query("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(table) as { present?: unknown } | null; + return row?.present === 1; +} + +function requireExactColumns(database: Database, table: string, expected: readonly string[]): void { + const rows = database.query(`PRAGMA table_info(${table})`).all() as ColumnRow[]; + const actual = rows.map(row => row.name); + if ( + actual.length !== expected.length || + actual.some((column, index) => typeof column !== "string" || column !== expected[index]) + ) { + throw new Error(`unsupported ${table} schema`); + } +} + +async function requireDirectory(directoryPath: string): Promise { + const metadata = await lstat(directoryPath); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error(`OMP profile path must be a real directory: ${path.basename(directoryPath)}`); + } +} + +async function requireDirectoryChain(home: string, agentDir: string): Promise { + await requireDirectory(home); + const relativeAgentDir = path.relative(home, agentDir); + if ( + relativeAgentDir === "" || + relativeAgentDir.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeAgentDir) + ) { + throw new Error("OMP credential check paths are outside the profile home"); + } + let current = home; + for (const segment of relativeAgentDir.split(path.sep)) { + current = path.join(current, segment); + await requireDirectory(current); + } +} + +async function requireAbsent(filePath: string): Promise { + try { + await lstat(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + throw new Error(`credential state is present: ${path.basename(filePath)}`); +} + +export async function assertOMPProfileCredentialsAbsent(input: { + readonly agentDir: string; + readonly home: string; +}): Promise { + if (!path.isAbsolute(input.agentDir) || !path.isAbsolute(input.home)) { + throw new Error("OMP credential check paths must be absolute"); + } + const agentDir = path.resolve(input.agentDir); + const home = path.resolve(input.home); + const expectedProfilesRoot = path.join(home, ".omp", "profiles") + path.sep; + if (!agentDir.startsWith(expectedProfilesRoot)) { + throw new Error("OMP credential check paths are outside the profile home"); + } + await requireDirectoryChain(home, agentDir); + + const databasePath = path.join(agentDir, "agent.db"); + const tokenPath = path.join(home, ".omp", "auth-broker.token"); + const cachePath = path.join(home, ".omp", "cache"); + const snapshotPath = path.join(cachePath, "auth-broker-snapshot.enc"); + await requireAbsent(tokenPath); + try { + await requireDirectory(cachePath); + await requireAbsent(snapshotPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + + let databaseMetadata; + try { + databaseMetadata = await lstat(databasePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + for (const sidecar of [`${databasePath}-wal`, `${databasePath}-shm`]) await requireAbsent(sidecar); + return; + } + if (!databaseMetadata.isFile() || databaseMetadata.isSymbolicLink()) { + throw new Error("agent.db must be a regular file"); + } + + const database = new Database(databasePath, { create: false, readonly: true }); + try { + if (tableExists(database, "auth_credentials")) { + requireExactColumns(database, "auth_credentials", AUTH_COLUMNS); + if (!tableExists(database, "auth_schema_version")) throw new Error("auth schema version is missing"); + requireExactColumns(database, "auth_schema_version", ["id", "version"]); + const version = database.query("SELECT version FROM auth_schema_version WHERE id = 1").get() as VersionRow | null; + if (version?.version !== AUTH_SCHEMA_VERSION) throw new Error("unsupported auth schema version"); + const count = database.query("SELECT COUNT(*) AS count FROM auth_credentials").get() as CountRow; + if (typeof count.count !== "number" || count.count !== 0) { + throw new Error("OMP auth credentials must be absent"); + } + } + if (tableExists(database, "settings")) { + requireExactColumns(database, "settings", SETTINGS_COLUMNS); + const placeholders = SECRET_SETTING_KEYS.map(() => "?").join(", "); + const count = database + .query(`SELECT COUNT(*) AS count FROM settings WHERE key IN (${placeholders})`) + .get(...SECRET_SETTING_KEYS) as CountRow; + if (typeof count.count !== "number" || count.count !== 0) { + throw new Error("OMP secret settings must be absent"); + } + } + } finally { + database.close(); + } +} + +if (import.meta.main) { + const [agentDir, home] = process.argv.slice(2); + if (!agentDir || !home) throw new Error("usage: assert-omp-credentials-absent "); + await assertOMPProfileCredentialsAbsent({ agentDir, home }); + process.stdout.write(JSON.stringify({ component: "session-runtime", result: "credential_state_absent" }) + "\n"); +} diff --git a/cluster/images/session-runtime/session-entrypoint.sh b/cluster/images/session-runtime/session-entrypoint.sh index 43651f05..5dab3ac1 100755 --- a/cluster/images/session-runtime/session-entrypoint.sh +++ b/cluster/images/session-runtime/session-entrypoint.sh @@ -24,32 +24,16 @@ fi [[ "${T4_AUTHORITY_STATE_DIR}" == "${T4_SESSION_STATE_ROOT}/authority" ]] || { echo '{"component":"session-runtime","result":"invalid_config","condition":"authority_state_path"}' >&2; exit 64; } [[ "${T4_BROWSER_STATE_DIR}" == "${T4_SESSION_STATE_ROOT}/browser" ]] || { echo '{"component":"session-runtime","result":"invalid_config","condition":"browser_state_path"}' >&2; exit 64; } -case "${T4_OMP_ALLOW_UNAUTHENTICATED}" in - true|false) ;; - *) echo '{"component":"session-runtime","result":"invalid_config","condition":"omp_authentication_mode"}' >&2; exit 64 ;; -esac +[[ "${T4_OMP_ALLOW_UNAUTHENTICATED}" == "true" && "$#" -eq 0 ]] || { + echo '{"component":"session-runtime","result":"invalid_config","condition":"omp_credential_projection_unsupported"}' >&2 + exit 64 +} models_source="${T4_OMP_CONFIG_SOURCE_DIR}/models.yml" settings_source="${T4_OMP_CONFIG_SOURCE_DIR}/config.yml" [[ -f "${models_source}" && -r "${models_source}" && -s "${models_source}" ]] || { echo '{"component":"session-runtime","result":"invalid_config","condition":"omp_models"}' >&2; exit 64; } [[ -f "${settings_source}" && -r "${settings_source}" && -s "${settings_source}" ]] || { echo '{"component":"session-runtime","result":"invalid_config","condition":"omp_settings"}' >&2; exit 64; } -if [[ "${T4_OMP_ALLOW_UNAUTHENTICATED}" == "false" ]]; then - T4_OMP_CREDENTIAL_KEY="${1:-}" - [[ -n "${T4_OMP_CREDENTIAL_KEY}" ]] || { echo '{"component":"session-runtime","result":"invalid_config","condition":"omp_credential_key"}' >&2; exit 64; } - [[ "${T4_OMP_CREDENTIAL_KEY}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || { echo '{"component":"session-runtime","result":"invalid_config","condition":"omp_credential_key"}' >&2; exit 64; } - case "${T4_OMP_CREDENTIAL_KEY}" in - T4_*|OMP_*|PI_*|XDG_*|LD_*|HOME|DISPLAY|PATH|BASH_ENV|ENV|SHELLOPTS|NODE_OPTIONS|BUN_OPTIONS) - echo '{"component":"session-runtime","result":"invalid_config","condition":"omp_credential_key"}' >&2 - exit 64 - ;; - esac - [[ -v "${T4_OMP_CREDENTIAL_KEY}" ]] || { echo '{"component":"session-runtime","result":"invalid_config","condition":"omp_credential"}' >&2; exit 64; } - credential_value="${!T4_OMP_CREDENTIAL_KEY}" - [[ -n "${credential_value}" ]] || { echo '{"component":"session-runtime","result":"invalid_config","condition":"omp_credential"}' >&2; exit 64; } - unset credential_value -fi - export HOME="${T4_SESSION_STATE_ROOT}/home" export PI_CODING_AGENT_DIR="${HOME}/.omp/profiles/${T4_SESSION_NAME}/agent" mkdir -p "${T4_AUTHORITY_STATE_DIR}" "${T4_BROWSER_STATE_DIR}" /run/t4 /tmp/t4 @@ -62,6 +46,10 @@ install -m 0600 "${settings_source}" "${settings_private}" mv -f "${models_private}" "${PI_CODING_AGENT_DIR}/models.yml" mv -f "${settings_private}" "${PI_CODING_AGENT_DIR}/config.yml" trap - EXIT +/usr/local/bin/bun /opt/t4/cluster/images/session-runtime/assert-omp-credentials-absent.ts "${PI_CODING_AGENT_DIR}" "${HOME}" || { + echo '{"component":"session-runtime","result":"invalid_state","condition":"omp_credential_state_present"}' >&2 + exit 64 +} export DISPLAY="${DISPLAY:-:99}" [[ "${DISPLAY}" =~ ^:([0-9]{1,3})$ ]] || { echo '{"component":"session-runtime","result":"invalid_config","condition":"display"}' >&2; exit 64; } display_socket="/tmp/.X11-unix/X${BASH_REMATCH[1]}" diff --git a/deploy/charts/t4-cluster/templates/deployments.yaml b/deploy/charts/t4-cluster/templates/deployments.yaml index 923e0a58..9a131459 100644 --- a/deploy/charts/t4-cluster/templates/deployments.yaml +++ b/deploy/charts/t4-cluster/templates/deployments.yaml @@ -2,6 +2,9 @@ {{- if eq .Values.session.omp.modelsKey .Values.session.omp.settingsKey }} {{- fail "session.omp.modelsKey and session.omp.settingsKey must be different" }} {{- end }} +{{- if or (not .Values.session.omp.allowUnauthenticated) .Values.session.omp.credentialSecret .Values.session.omp.credentialKey }} +{{- fail "reusable provider credentials cannot be projected into session Pods; set session.omp.allowUnauthenticated=true, clear credentialSecret/credentialKey, and use an isolated auth-none model route" }} +{{- end }} apiVersion: apps/v1 kind: Deployment metadata: @@ -82,10 +85,6 @@ spec: value: {{ .Values.session.omp.modelsKey | quote }} - name: T4_SESSION_OMP_SETTINGS_KEY value: {{ .Values.session.omp.settingsKey | quote }} - - name: T4_SESSION_OMP_CREDENTIAL_SECRET - value: {{ .Values.session.omp.credentialSecret | quote }} - - name: T4_SESSION_OMP_CREDENTIAL_KEY - value: {{ .Values.session.omp.credentialKey | quote }} - name: T4_SESSION_OMP_ALLOW_UNAUTHENTICATED value: {{ .Values.session.omp.allowUnauthenticated | quote }} - name: T4_SESSION_SERVICE_ACCOUNT @@ -235,6 +234,8 @@ spec: value: "8080" - name: T4_CLUSTER_ADMIN_PORT value: "9090" + - name: T4_CLUSTER_IDENTITY_PROVIDER + value: {{ .Values.server.identityProvider | quote }} - name: T4_CLUSTER_TRUSTED_PROXY_ADDRESSES value: {{ join "," .Values.server.trustedProxyAddresses | quote }} - name: T4_CLUSTER_TRUSTED_PROXY_CIDRS diff --git a/deploy/charts/t4-cluster/templates/rbac.yaml b/deploy/charts/t4-cluster/templates/rbac.yaml index dbaedab3..9c8af47f 100644 --- a/deploy/charts/t4-cluster/templates/rbac.yaml +++ b/deploy/charts/t4-cluster/templates/rbac.yaml @@ -22,12 +22,6 @@ rules: resources: [configmaps] resourceNames: [{{ .Values.session.omp.configMap | quote }}] verbs: [get] - {{- if not .Values.session.omp.allowUnauthenticated }} - - apiGroups: [''] - resources: [secrets] - resourceNames: [{{ .Values.session.omp.credentialSecret | quote }}] - verbs: [get] - {{- end }} - apiGroups: [''] resources: [events] verbs: [create, patch] diff --git a/deploy/charts/t4-cluster/values.schema.json b/deploy/charts/t4-cluster/values.schema.json index 9869ccb5..9dd8e8ed 100644 --- a/deploy/charts/t4-cluster/values.schema.json +++ b/deploy/charts/t4-cluster/values.schema.json @@ -71,10 +71,11 @@ }, "server": { "type": "object", - "required": ["replicas", "terminationGracePeriodSeconds", "trustedProxyAddresses", "trustedProxyCIDRs", "resources"], + "required": ["replicas", "terminationGracePeriodSeconds", "identityProvider", "trustedProxyAddresses", "trustedProxyCIDRs", "resources"], "properties": { "replicas": { "type": "integer", "minimum": 2, "maximum": 9 }, "terminationGracePeriodSeconds": { "type": "integer", "minimum": 30, "maximum": 300 }, + "identityProvider": { "type": "string", "const": "tailscale" }, "trustedProxyAddresses": { "type": "array", "maxItems": 64, @@ -378,24 +379,11 @@ "properties": { "configMap": { "minLength": 1 }, "modelsKey": { "minLength": 1 }, - "settingsKey": { "minLength": 1 } - }, - "oneOf": [ - { - "properties": { - "allowUnauthenticated": { "const": false }, - "credentialSecret": { "minLength": 1 }, - "credentialKey": { "minLength": 1 } - } - }, - { - "properties": { - "allowUnauthenticated": { "const": true }, - "credentialSecret": { "maxLength": 0 }, - "credentialKey": { "maxLength": 0 } - } - } - ] + "settingsKey": { "minLength": 1 }, + "allowUnauthenticated": { "const": true }, + "credentialSecret": { "maxLength": 0 }, + "credentialKey": { "maxLength": 0 } + } } } }, @@ -415,24 +403,15 @@ "properties": { "ingress": { "properties": { - "className": { "minLength": 1 }, + "className": { "const": "tailscale" }, "host": { "minLength": 1 }, - "tls": { "properties": { "enabled": { "const": true } } } - }, - "oneOf": [ - { - "properties": { - "className": { "const": "tailscale" }, - "tls": { "properties": { "secretName": { "maxLength": 0 } } } - } - }, - { + "tls": { "properties": { - "className": { "not": { "const": "tailscale" } }, - "tls": { "properties": { "secretName": { "minLength": 1 } } } + "enabled": { "const": true }, + "secretName": { "maxLength": 0 } } } - ] + } } } } diff --git a/deploy/charts/t4-cluster/values.yaml b/deploy/charts/t4-cluster/values.yaml index 5224ced7..b4c2bd1e 100644 --- a/deploy/charts/t4-cluster/values.yaml +++ b/deploy/charts/t4-cluster/values.yaml @@ -39,6 +39,8 @@ controller: server: replicas: 3 terminationGracePeriodSeconds: 60 + # The gateway accepts identity only from Tailscale Serve or the Tailscale Kubernetes operator. + identityProvider: tailscale # At least one trusted Tailscale proxy address or canonical CIDR is required for client access. trustedProxyAddresses: [] trustedProxyCIDRs: [] @@ -68,10 +70,13 @@ session: configMap: "" modelsKey: "" settingsKey: "" + # Deprecated migration sentinels. Nonempty values are rejected because a + # session's arbitrary-code tools share the workload boundary with OMP. credentialSecret: "" credentialKey: "" - # Explicit opt-in for a private, policy-isolated models.yml provider using auth: none. - allowUnauthenticated: false + # Required: use a private, identity- and NetworkPolicy-isolated models.yml + # provider with auth: none. A reusable credential must stay outside the Pod. + allowUnauthenticated: true storage: # Required when enabled. The administrator creates this backend-neutral RWX StorageClass. diff --git a/docs/CLUSTER_OPERATOR.md b/docs/CLUSTER_OPERATOR.md index c942e997..67598da5 100644 --- a/docs/CLUSTER_OPERATOR.md +++ b/docs/CLUSTER_OPERATOR.md @@ -7,7 +7,7 @@ The portable `t4-cluster` chart runs the infrastructure control plane for T4 wor - Kubernetes 1.30 or newer. - An administrator-managed StorageClass that actually provisions `ReadWriteMany` volumes. - Three immutable image digests: `t4-cluster-operator`, `t4-cluster-server`, and `t4-session-runtime`. -- An administrator-owned same-namespace ConfigMap containing the OMP `models.yml` and `config.yml` inputs, plus either a same-namespace credential Secret or an explicit private unauthenticated-provider opt-in. +- An administrator-owned same-namespace ConfigMap containing the OMP `models.yml` and `config.yml` inputs. Every provider in `models.yml` must use `auth: none` and resolve to a private, identity- and NetworkPolicy-isolated model route. - Narrow Kubernetes API, model-route, CI-provider, ingress-controller, and metrics-scraper network identities and ports for the enabled integrations. - A local T4 client explicitly configured to request the default-false `cluster.operator` feature. Installing this chart does not enable the client feature. @@ -54,14 +54,13 @@ kubernetes: session: nodeExclude: [k3s-worker-02] omp: - # Existing same-namespace objects; the chart creates neither one. + # Existing same-namespace ConfigMap; the chart does not create it. configMap: omp-runtime-config modelsKey: models.yml settingsKey: config.yml - credentialSecret: omp-runtime-credential - # This existing Secret key is also the environment variable name seen by OMP. - credentialKey: PI_MODEL_API_KEY - allowUnauthenticated: false + credentialSecret: "" + credentialKey: "" + allowUnauthenticated: true networkPolicy: kubernetesApiCIDRs: [192.0.2.10/32] modelRouteCIDRs: [198.51.100.20/32] @@ -107,11 +106,11 @@ networkPolicy: The sample destination CIDRs, TCP ports, model/CI endpoint selectors, and gateway/observability labels are documentation values, not usable defaults. Keep the chart backend-neutral and set destinations and integration sources to the actual cluster endpoints. Model and CI route CIDRs and their optional paired namespace/pod selectors are allowed only on the corresponding exact port lists. An empty model port list renders no model route; the default CI port remains inert until a CI CIDR or complete selector pair is supplied. Empty paired selectors render no selector rule, and other empty destination lists likewise deny those flows. -The chart never creates the referenced OMP ConfigMap or Secret and never accepts their contents. The administrator must create them in the chart namespace. `modelsKey` is projected as read-only `models.yml`, `settingsKey` as read-only `config.yml`, and the configured `credentialKey` selects the same Secret key and OMP environment-variable name. The entrypoint validates both projected files and the nonempty credential without logging values, then atomically installs private copies under the OMP authority child's actual named-profile directory `${T4_SESSION_STATE_ROOT}/home/.omp/profiles/${T4_SESSION_NAME}/agent` before starting Xvfb or OMP. This must match the `OMP_PROFILE=${T4_SESSION_NAME}` passed by the session host to the pinned authority bridge child. +The chart never creates the referenced OMP ConfigMap. The administrator must create it in the chart namespace. `modelsKey` is projected as read-only `models.yml` and `settingsKey` as read-only `config.yml`. Before creating a session Pod, the controller parses both files as YAML, rejects aliases and duplicate keys, requires a nonempty `providers` mapping with `auth: none` on every provider, and rejects credential-bearing fields such as `apiKey`, `authHeader`, `Authorization`, custom header maps, tokens, passwords, credentials, secrets, and URL userinfo/query data. The same credential-field rules apply to `config.yml`, including dotted or nested settings such as `auth.broker.token`. The entrypoint then atomically installs private copies under the OMP authority child's actual named-profile directory `${T4_SESSION_STATE_ROOT}/home/.omp/profiles/${T4_SESSION_NAME}/agent` before starting Xvfb or OMP. This must match the `OMP_PROFILE=${T4_SESSION_NAME}` passed by the session host to the pinned authority bridge child. -The controller uses uncached, exact-name `get` permissions for only those two administrator-owned objects; it cannot list or watch namespace configuration. It validates the selected keys without logging or hashing their contents, incorporates each object's Kubernetes `resourceVersion` into the session Pod hash, and rechecks ready sessions every 30 seconds. Updating either object therefore recreates the session Pod and reloads OMP configuration from the durable workspace state. Deleting a required object or emptying a selected key deletes the owned authority Pod and clears the advertised Pod and Service route before reporting the exact fail-closed condition. In unauthenticated mode the controller has no Secret permission. +The controller uses uncached, exact-name `get` permission for only that administrator-owned ConfigMap; it has no Secret permission and cannot list or watch namespace configuration. It validates the selected keys without logging or hashing their contents, incorporates the ConfigMap's Kubernetes `resourceVersion` into the session Pod hash, and rechecks ready sessions every 30 seconds. Updating the ConfigMap therefore recreates the session Pod and reloads OMP configuration from the durable workspace state. Deleting it, emptying a selected key, or making `models.yml` unsafe deletes the owned authority Pod and clears the advertised Pod and Service route before reporting the exact fail-closed condition. -Credential mode is the default and requires both `credentialSecret` and `credentialKey`. The projected models and settings keys must be distinct. Credential keys cannot overlap the session runtime's `T4_*`, `OMP_*`, `PI_*`, `XDG_*`, loader, shell, display, or path environment; collisions fail chart validation, controller reconciliation, and entrypoint preflight instead of silently replacing runtime authority. An administrator may instead set `allowUnauthenticated: true` only when both credential fields are empty and the private, identity- and NetworkPolicy-isolated model endpoint is intentionally unauthenticated. In that mode the referenced `models.yml` must declare `auth: none` and must not declare `apiKey` or `authHeader`; never use this opt-in for an Internet-reachable or shared endpoint. The chart does not select or hardcode a provider, model, or prompt policy in either mode. +Reusable credential projection is unsupported because OMP and arbitrary session tools share one workload security boundary. `allowUnauthenticated: true` with empty `credentialSecret` and `credentialKey` is therefore mandatory. The two credential fields remain only as cutover sentinels: Helm, the controller, and the image entrypoint all reject an old credential-mode configuration instead of exposing it. Before OMP starts, the image requires the pinned `auth_credentials` table to be empty, rejects the pinned secret settings (`auth.broker.token`, `hindsight.apiToken`, `searxng.token`, and `dev.autoqaPush.token`), rejects broker token and encrypted snapshot files, and fails closed on unknown schemas or linked profile paths. It never deletes or rewrites durable user state. Because Helm rejects legacy credential values before rendering the new Deployment, a failed upgrade leaves the prior release unchanged. Stop development sessions and clear any legacy values and credential state explicitly before adopting this pre-production boundary. Provider authentication must live behind a private model gateway that authorizes the session through infrastructure identity or network policy while presenting an `auth: none` endpoint inside the session's allowed route. Do not expose that endpoint to the Internet or a shared untrusted network. The chart remains provider- and model-neutral. Install or enable with immutable digests: @@ -125,7 +124,9 @@ The chart creates dedicated controller, server, and session ServiceAccounts inst The Kubernetes API audience is configurable because managed clusters can reject the conventional `https://kubernetes.default.svc` audience. The same value is used for the controller API token, server API token, and session-host TokenReview credential. The internal server identity audience remains the fixed `t4-cluster-internal` boundary. DNS selectors default to the conventional `kube-system`/`k8s-app: kube-dns` pair and can be replaced together for clusters using a different DNS deployment. -When chart-managed ingress is enabled, a host, ingress class, and TLS stanza are mandatory. For `ingressClassName: tailscale`, the chart renders the TLS host without a Secret reference so the Tailscale operator can provision and manage the certificate. Other ingress classes must reference an administrator-managed TLS Secret. +The managed gateway has one explicit identity-provider contract: Tailscale. Startup requires `T4_CLUSTER_IDENTITY_PROVIDER=tailscale`, a narrow list of trusted proxy addresses or CIDRs, HTTPS forwarding, and the `Tailscale-User-Login` header supplied by Tailscale Serve or the Tailscale Kubernetes operator. Display-name headers are never principals. A direct deployment that omits the identity-provider setting fails at startup. + +When chart-managed ingress is enabled, the class must be `tailscale`, and a host and TLS stanza are mandatory. The chart renders the TLS host without a Secret reference so the Tailscale operator can provision and manage the certificate. Generic ingress controllers are deliberately unsupported because preserving caller-supplied `Tailscale-User-*` headers would allow identity spoofing even when their source network is trusted. ## API configuration @@ -147,7 +148,7 @@ The optional initial prompt is referenced by Secret name and key `prompt`; the S The session runtime verifies and builds the exact OMP tag `t4code-17.0.5-appserver-10` at commit `8476f4451ed95c5d5401785d279a93d3c659fac4`. It preserves `t4-omp-authority/1`, starts the existing T4 session-host entrypoint, and provides Xvfb, a minimal window manager, and Chromium without privileged mode or host display access. The shared claim is mounted at `/workspace`; authority and browser state live in controller-selected per-session subdirectories. OMP configuration is copied from the read-only administrator ConfigMap projection into the private authority child home before launch. `/dev/shm` is an explicit memory-backed volume. Browser Preview remains the existing GUI stream and control surface. -Session pods do not receive an automatically mounted ServiceAccount token. The explicit projected reviewer token can only create TokenReviews and is not resource-authorized. ConfigMap and credential inputs are namespace-local references; credential mode uses an explicit non-optional `SecretKeyRef`, while explicit unauthenticated mode adds no Secret reference. All containers drop capabilities, disallow privilege escalation, use RuntimeDefault seccomp, and use read-only root filesystems. No per-session NodePort, LoadBalancer, host network, host PID, host display, or hostPath is created. +Session pods do not receive an automatically mounted ServiceAccount token. The explicit projected reviewer token can only create TokenReviews and is not resource-authorized. The namespace-local ConfigMap projection contains credential-free `auth: none` OMP models and credential-free settings; session Pods receive no provider Secret reference or reusable provider credential. All containers drop capabilities, disallow privilege escalation, use RuntimeDefault seccomp, and use read-only root filesystems. No per-session NodePort, LoadBalancer, host network, host PID, host display, or hostPath is created. ## Upgrade and rollback diff --git a/packages/cluster-operator/charttests/chart_contract_test.go b/packages/cluster-operator/charttests/chart_contract_test.go index 9a9aaf7a..611bd493 100644 --- a/packages/cluster-operator/charttests/chart_contract_test.go +++ b/packages/cluster-operator/charttests/chart_contract_test.go @@ -57,6 +57,8 @@ func TestEnabledChartRendersHARestrictedWorkloads(t *testing.T) { server := documentContainingKind(t, output, "Deployment", "name: \"release-name-t4-cluster-server\"") assertContains(t, server, "automountServiceAccountToken: false", + "name: T4_CLUSTER_IDENTITY_PROVIDER", + "value: \"tailscale\"", "name: T4_CLUSTER_TRUSTED_PROXY_CIDRS", "value: \"192.0.2.0/24\"", "name: kubernetes-api-access", @@ -141,37 +143,33 @@ func TestEachDeploymentUsesZeroUnavailableAndConfiguredAPIAudience(t *testing.T) func TestValuesSchemaRejectsUnsafeNamesProfilesCIDRsAndHalfSelectors(t *testing.T) { for name, values := range map[string][]string{ - "cluster host name": {"--set-string", "clusterHost.name=Bad_Name"}, - "storage class name": {"--set-string", "storage.adminRWXStorageClass=Bad_Name"}, - "runtime profile": {"--set-string", "clusterHost.runtimeProfiles[0]=-bad"}, - "Woodpecker Secret name": {"--set-string", "woodpecker.existingSecret=Bad_Name", "--set-string", "woodpecker.configMap=woodpecker-config"}, - "Woodpecker ConfigMap name": {"--set-string", "woodpecker.existingSecret=woodpecker-token", "--set-string", "woodpecker.configMap=Bad_Name"}, - "Woodpecker key": {"--set-string", "woodpecker.existingSecret=woodpecker-token", "--set-string", "woodpecker.configMap=woodpecker-config", "--set-string", "woodpecker.tokenKey=bad/key"}, - "Woodpecker audience": {"--set-string", "woodpecker.serviceAccountAudience=/bad", "--set-string", "woodpecker.configMap=woodpecker-config"}, - "IPv4 default route": {"--set-string", "server.trustedProxyCIDRs[0]=0.0.0.0/0"}, - "IPv6 default route": {"--set-string", "server.trustedProxyCIDRs[0]=::/0"}, - "gateway half selector": {"--set-string", "networkPolicy.gatewayIngress.namespaceSelector.matchLabels.scope=gateway"}, - "observability half selector": {"--set-string", "networkPolicy.observability.podSelector.matchLabels.scope=metrics"}, - "OMP ConfigMap name": {"--set-string", "session.omp.configMap=Bad_Name"}, - "OMP models key": {"--set-string", "session.omp.modelsKey=bad/key"}, - "OMP credential Secret name": {"--set-string", "session.omp.credentialSecret=Bad_Name"}, - "OMP credential environment name": {"--set-string", "session.omp.credentialKey=bad-key"}, - "OMP runtime-owned credential environment": {"--set-string", "session.omp.credentialKey=OMP_PROFILE"}, - "OMP PI runtime-owned credential environment": {"--set-string", "session.omp.credentialKey=PI_PROFILE"}, - "identical OMP projection keys": {"--set-string", "session.omp.settingsKey=provider-models"}, - "model route port zero": {"--set", "networkPolicy.modelRoutePorts[0]=0"}, - "model route port above TCP range": {"--set", "networkPolicy.modelRoutePorts[0]=65536"}, - "duplicate model route port": {"--set", "networkPolicy.modelRoutePorts[0]=19481", "--set", "networkPolicy.modelRoutePorts[1]=19481"}, - "noninteger model route port": {"--set-string", "networkPolicy.modelRoutePorts[0]=https"}, - "model route half selector": {"--set-string", "networkPolicy.modelRoute.namespaceSelector.matchLabels.scope=linkedin-bot"}, - "CI provider port zero": {"--set", "networkPolicy.ciProviderPorts[0]=0"}, - "CI provider port above TCP range": {"--set", "networkPolicy.ciProviderPorts[0]=65536"}, - "duplicate CI provider port": {"--set", "networkPolicy.ciProviderPorts[0]=8080", "--set", "networkPolicy.ciProviderPorts[1]=8080"}, - "noninteger CI provider port": {"--set-string", "networkPolicy.ciProviderPorts[0]=http"}, - "CI provider half selector": {"--set-string", "networkPolicy.ciProvider.namespaceSelector.matchLabels.scope=linkedin-bot"}, - "unauthenticated mode with credential references": {"--set", "session.omp.allowUnauthenticated=true"}, - "credential mode with only Secret": {"--set-string", "session.omp.credentialKey="}, - "credential mode with only key": {"--set-string", "session.omp.credentialSecret="}, + "cluster host name": {"--set-string", "clusterHost.name=Bad_Name"}, + "storage class name": {"--set-string", "storage.adminRWXStorageClass=Bad_Name"}, + "runtime profile": {"--set-string", "clusterHost.runtimeProfiles[0]=-bad"}, + "Woodpecker Secret name": {"--set-string", "woodpecker.existingSecret=Bad_Name", "--set-string", "woodpecker.configMap=woodpecker-config"}, + "Woodpecker ConfigMap name": {"--set-string", "woodpecker.existingSecret=woodpecker-token", "--set-string", "woodpecker.configMap=Bad_Name"}, + "Woodpecker key": {"--set-string", "woodpecker.existingSecret=woodpecker-token", "--set-string", "woodpecker.configMap=woodpecker-config", "--set-string", "woodpecker.tokenKey=bad/key"}, + "Woodpecker audience": {"--set-string", "woodpecker.serviceAccountAudience=/bad", "--set-string", "woodpecker.configMap=woodpecker-config"}, + "IPv4 default route": {"--set-string", "server.trustedProxyCIDRs[0]=0.0.0.0/0"}, + "IPv6 default route": {"--set-string", "server.trustedProxyCIDRs[0]=::/0"}, + "gateway half selector": {"--set-string", "networkPolicy.gatewayIngress.namespaceSelector.matchLabels.scope=gateway"}, + "observability half selector": {"--set-string", "networkPolicy.observability.podSelector.matchLabels.scope=metrics"}, + "OMP ConfigMap name": {"--set-string", "session.omp.configMap=Bad_Name"}, + "OMP models key": {"--set-string", "session.omp.modelsKey=bad/key"}, + "legacy OMP credential Secret": {"--set-string", "session.omp.credentialSecret=omp-runtime-credential"}, + "legacy OMP credential key": {"--set-string", "session.omp.credentialKey=MODEL_API_KEY"}, + "identical OMP projection keys": {"--set-string", "session.omp.settingsKey=provider-models"}, + "model route port zero": {"--set", "networkPolicy.modelRoutePorts[0]=0"}, + "model route port above TCP range": {"--set", "networkPolicy.modelRoutePorts[0]=65536"}, + "duplicate model route port": {"--set", "networkPolicy.modelRoutePorts[0]=19481", "--set", "networkPolicy.modelRoutePorts[1]=19481"}, + "noninteger model route port": {"--set-string", "networkPolicy.modelRoutePorts[0]=https"}, + "model route half selector": {"--set-string", "networkPolicy.modelRoute.namespaceSelector.matchLabels.scope=linkedin-bot"}, + "CI provider port zero": {"--set", "networkPolicy.ciProviderPorts[0]=0"}, + "CI provider port above TCP range": {"--set", "networkPolicy.ciProviderPorts[0]=65536"}, + "duplicate CI provider port": {"--set", "networkPolicy.ciProviderPorts[0]=8080", "--set", "networkPolicy.ciProviderPorts[1]=8080"}, + "noninteger CI provider port": {"--set-string", "networkPolicy.ciProviderPorts[0]=http"}, + "CI provider half selector": {"--set-string", "networkPolicy.ciProvider.namespaceSelector.matchLabels.scope=linkedin-bot"}, + "legacy OMP credential mode": {"--set", "session.omp.allowUnauthenticated=false"}, } { t.Run(name, func(t *testing.T) { helmTemplateMustFail(t, append(enabledValues(), values...)...) @@ -191,26 +189,23 @@ func TestValuesSchemaBoundsRoutePortLists(t *testing.T) { } } -func TestEnabledChartRequiresCommonOMPReferencesAndStrictCredentials(t *testing.T) { - for _, key := range []string{"configMap", "modelsKey", "settingsKey", "credentialSecret", "credentialKey"} { +func TestEnabledChartRequiresCommonOMPReferences(t *testing.T) { + for _, key := range []string{"configMap", "modelsKey", "settingsKey"} { t.Run(key, func(t *testing.T) { helmTemplateMustFail(t, append(enabledValues(), "--set-string", "session.omp."+key+"=")...) }) } } -func TestEnabledChartSupportsExplicitUnauthenticatedOMPMode(t *testing.T) { - output := helmTemplate(t, append(enabledValues(), - "--set", "session.omp.allowUnauthenticated=true", - "--set-string", "session.omp.credentialSecret=", - "--set-string", "session.omp.credentialKey=", - )...) +func TestEnabledChartRequiresIsolatedAuthNoneOMPMode(t *testing.T) { + output := helmTemplate(t, enabledValues()...) controller := documentContainingKind(t, output, "Deployment", "name: \"release-name-t4-cluster-controller\"") assertContains(t, controller, "name: T4_SESSION_OMP_ALLOW_UNAUTHENTICATED\n value: \"true\"", - "name: T4_SESSION_OMP_CREDENTIAL_SECRET\n value: \"\"", - "name: T4_SESSION_OMP_CREDENTIAL_KEY\n value: \"\"", ) + if strings.Contains(controller, "T4_SESSION_OMP_CREDENTIAL_") { + t.Fatal("controller Deployment retained a session credential projection reference") + } assertCount(t, output, "kind: Secret", 0) } @@ -221,9 +216,7 @@ func TestSessionOMPReferencesArePassedWithoutCreatingConfigurationObjects(t *tes "name: T4_SESSION_OMP_CONFIG_MAP\n value: \"omp-runtime-config\"", "name: T4_SESSION_OMP_MODELS_KEY\n value: \"provider-models\"", "name: T4_SESSION_OMP_SETTINGS_KEY\n value: \"agent-settings\"", - "name: T4_SESSION_OMP_CREDENTIAL_SECRET\n value: \"omp-runtime-credential\"", - "name: T4_SESSION_OMP_CREDENTIAL_KEY\n value: \"MODEL_API_KEY\"", - "name: T4_SESSION_OMP_ALLOW_UNAUTHENTICATED\n value: \"false\"", + "name: T4_SESSION_OMP_ALLOW_UNAUTHENTICATED\n value: \"true\"", ) assertCount(t, output, "kind: ConfigMap", 0) assertCount(t, output, "kind: Secret", 0) @@ -298,7 +291,7 @@ func TestDNSAndSourceSelectorsAreConfigurableAndReleaseScoped(t *testing.T) { ) } -func TestIngressRequiresTLSAndSupportsTailscaleManagedCertificates(t *testing.T) { +func TestIngressRequiresTailscaleIdentityAndManagedCertificates(t *testing.T) { output := helmTemplate(t, append(enabledValues(), "--set", "ingress.enabled=true", "--set-string", "ingress.className=tailscale", @@ -317,6 +310,7 @@ func TestIngressRequiresTLSAndSupportsTailscaleManagedCertificates(t *testing.T) "--set", "ingress.enabled=true", "--set-string", "ingress.className=nginx", "--set-string", "ingress.host=operator.example.test", + "--set-string", "ingress.tls.secretName=operator-tls", )...) helmTemplateMustFail(t, append(enabledValues(), "--set", "ingress.enabled=true", @@ -334,21 +328,18 @@ func TestRBACSeparatesControllerMutationFromServerProjection(t *testing.T) { assertContains(t, controllerRole, "resources: [configmaps]", "resourceNames: [\"omp-runtime-config\"]", - "resources: [secrets]", - "resourceNames: [\"omp-runtime-credential\"]", "verbs: [get]", ) + if strings.Contains(controllerRole, "resources: [secrets]") { + t.Fatal("controller role can read provider credential Secrets") + } assertContains(t, serverRole, "t4clusterhosts", "t4workspaces", "t4sessions", "create", "list", "watch") if strings.Contains(serverRole, "secrets") || strings.Contains(serverRole, "persistentvolumeclaims") || strings.Contains(serverRole, "t4sessions/status") { t.Fatal("server role can read secrets or mutate controller-owned infrastructure/status") } } func TestUnauthenticatedOMPControllerCannotReadSecrets(t *testing.T) { - output := helmTemplate(t, append(enabledValues(), - "--set", "session.omp.allowUnauthenticated=true", - "--set-string", "session.omp.credentialSecret=", - "--set-string", "session.omp.credentialKey=", - )...) + output := helmTemplate(t, enabledValues()...) controllerRole := documentContaining(t, output, "name: \"release-name-t4-cluster-controller\"") if strings.Contains(controllerRole, "resources: [secrets]") { t.Fatal("unauthenticated OMP controller can read Secrets") @@ -597,33 +588,33 @@ func TestImageContractsArePinnedAndAuthorityCompatible(t *testing.T) { "/var/run/secrets/kubernetes.io/serviceaccount/namespace", "T4_OMP_CONFIG_SOURCE_DIR", "T4_OMP_ALLOW_UNAUTHENTICATED", - "T4_OMP_CREDENTIAL_KEY", - `if [[ "${T4_OMP_ALLOW_UNAUTHENTICATED}" == "false" ]]`, + "omp_credential_projection_unsupported", + `[[ "${T4_OMP_ALLOW_UNAUTHENTICATED}" == "true" && "$#" -eq 0 ]]`, `export HOME="${T4_SESSION_STATE_ROOT}/home"`, `export PI_CODING_AGENT_DIR="${HOME}/.omp/profiles/${T4_SESSION_NAME}/agent"`, - `T4_*|OMP_*|PI_*|XDG_*|LD_*`, `install -m 0600 "${models_source}"`, `install -m 0600 "${settings_source}"`, `"${PI_CODING_AGENT_DIR}/models.yml"`, `"${PI_CODING_AGENT_DIR}/config.yml"`, + `/usr/local/bin/bun /opt/t4/cluster/images/session-runtime/assert-omp-credentials-absent.ts`, + "omp_credential_state_present", ) } func TestSessionEntrypointFailsClosedBeforeGUIWithoutPrivateOMPInputs(t *testing.T) { entrypoint := filepath.Join(repoRoot(t), "cluster", "images", "session-runtime", "session-entrypoint.sh") - const secretSentinel = "must-not-appear-in-logs" for _, test := range []struct { name string writeModels bool models string writeSettings bool settings string - credential string + unsafeMode bool condition string }{ - {name: "missing models file", writeSettings: true, settings: "settings", credential: secretSentinel, condition: "omp_models"}, - {name: "empty settings file", writeModels: true, models: "models", writeSettings: true, credential: secretSentinel, condition: "omp_settings"}, - {name: "empty credential", writeModels: true, models: "models", writeSettings: true, settings: "settings", condition: "omp_credential"}, + {name: "missing models file", writeSettings: true, settings: "settings", condition: "omp_models"}, + {name: "empty settings file", writeModels: true, models: "models", writeSettings: true, condition: "omp_settings"}, + {name: "credential projection", writeModels: true, models: "models", writeSettings: true, settings: "settings", unsafeMode: true, condition: "omp_credential_projection_unsupported"}, } { t.Run(test.name, func(t *testing.T) { root := t.TempDir() @@ -655,7 +646,13 @@ func TestSessionEntrypointFailsClosedBeforeGUIWithoutPrivateOMPInputs(t *testing if err := os.WriteFile(filepath.Join(bin, "Xvfb"), []byte(fakeXvfb), 0o700); err != nil { t.Fatal(err) } - command := exec.Command("bash", entrypoint, "MODEL_API_KEY") + arguments := []string{entrypoint} + allowUnauthenticated := "true" + if test.unsafeMode { + arguments = append(arguments, "MODEL_API_KEY") + allowUnauthenticated = "false" + } + command := exec.Command("bash", arguments...) command.Env = append(os.Environ(), "PATH="+bin+":"+os.Getenv("PATH"), "T4_SESSION_STATE_ROOT=/workspace/.t4/sessions/session-a", @@ -667,9 +664,8 @@ func TestSessionEntrypointFailsClosedBeforeGUIWithoutPrivateOMPInputs(t *testing "T4_KUBERNETES_CA_PATH="+filepath.Join(projection, "ca.crt"), "T4_KUBERNETES_NAMESPACE_PATH="+filepath.Join(projection, "namespace"), "T4_OMP_CONFIG_SOURCE_DIR="+source, - "T4_OMP_CREDENTIAL_KEY=MODEL_API_KEY", + "T4_OMP_ALLOW_UNAUTHENTICATED="+allowUnauthenticated, "T4_TEST_XVFB_MARKER="+marker, - "MODEL_API_KEY="+test.credential, ) output, err := command.CombinedOutput() exitError, ok := err.(*exec.ExitError) @@ -679,9 +675,6 @@ func TestSessionEntrypointFailsClosedBeforeGUIWithoutPrivateOMPInputs(t *testing if !strings.Contains(string(output), `"condition":"`+test.condition+`"`) { t.Fatalf("entrypoint output lacks bounded failure condition %q: %s", test.condition, output) } - if strings.Contains(string(output), secretSentinel) { - t.Fatalf("entrypoint logged credential value: %s", output) - } if _, err := os.Stat(marker); !os.IsNotExist(err) { t.Fatalf("Xvfb started before OMP configuration passed validation: %v", err) } @@ -726,9 +719,9 @@ func enabledValues() []string { "--set", "session.omp.configMap=omp-runtime-config", "--set", "session.omp.modelsKey=provider-models", "--set", "session.omp.settingsKey=agent-settings", - "--set", "session.omp.credentialSecret=omp-runtime-credential", - "--set", "session.omp.credentialKey=MODEL_API_KEY", - "--set", "session.omp.allowUnauthenticated=false", + "--set-string", "session.omp.credentialSecret=", + "--set-string", "session.omp.credentialKey=", + "--set", "session.omp.allowUnauthenticated=true", } } diff --git a/packages/cluster-operator/controllers/reconciler_test.go b/packages/cluster-operator/controllers/reconciler_test.go index 1f0da613..1a54e2d0 100644 --- a/packages/cluster-operator/controllers/reconciler_test.go +++ b/packages/cluster-operator/controllers/reconciler_test.go @@ -24,6 +24,10 @@ import ( const ( testRuntimeImage = "registry.example/session@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" otherTestRuntimeImage = "registry.example/session@sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + testOMPModels = "providers:\n isolated:\n baseUrl: http://model-route.example/v1\n api: openai-completions\n auth: none\n models:\n - id: test\n" + otherTestOMPModels = "providers:\n isolated-v2:\n baseUrl: http://model-route.example/v2\n api: openai-completions\n auth: none\n models:\n - id: test-v2\n" + testOMPSettings = "theme: dark\n" + otherTestOMPSettings = "theme: light\n" ) func TestWorkspaceReconcileIsIdempotentAcrossDuplicateEvents(t *testing.T) { @@ -219,12 +223,19 @@ func TestSessionFailsClosedWhenAnyOMPReferenceIsMissing(t *testing.T) { {name: "ConfigMap", remove: func(config *controllers.SessionOMPConfig) { config.ConfigMapName = "" }, reason: "OMPReferencesMissing"}, {name: "models key", remove: func(config *controllers.SessionOMPConfig) { config.ModelsKey = "" }, reason: "OMPReferencesMissing"}, {name: "settings key", remove: func(config *controllers.SessionOMPConfig) { config.SettingsKey = "" }, reason: "OMPReferencesMissing"}, - {name: "strict credentials", remove: func(config *controllers.SessionOMPConfig) { config.CredentialSecretName, config.CredentialKey = "", "" }, reason: "OMPReferencesMissing"}, + {name: "unsafe credential mode", remove: func(config *controllers.SessionOMPConfig) { config.AllowUnauthenticated = false }, reason: "OMPCredentialProjectionUnsupported"}, } { t.Run(test.name, func(t *testing.T) { scheme := testScheme(t) + workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) + workspace.Status.PVCName = "workspace-a-data" + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, + Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, + } session := testSession() - c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&clusterv1alpha1.T4Session{}).WithObjects(session).Build() + c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&clusterv1alpha1.T4Session{}, &corev1.PersistentVolumeClaim{}).WithObjects(testHost(), workspace, pvc, session).Build() r := configuredSessionReconciler(c, scheme) test.remove(&r.OMPConfig) if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}); err != nil { @@ -247,14 +258,12 @@ func TestSessionRejectsInvalidOMPAuthenticationAndProjectionReferences(t *testin for _, test := range []struct { name string mutate func(*controllers.SessionOMPConfig) + reason string }{ - {name: "credential Secret without key", mutate: func(config *controllers.SessionOMPConfig) { config.CredentialKey = "" }}, - {name: "credential key without Secret", mutate: func(config *controllers.SessionOMPConfig) { config.CredentialSecretName = "" }}, - {name: "unauthenticated plus credentials", mutate: func(config *controllers.SessionOMPConfig) { config.AllowUnauthenticated = true }}, - {name: "invalid credential environment name", mutate: func(config *controllers.SessionOMPConfig) { config.CredentialKey = "bad-key" }}, - {name: "runtime-owned credential environment", mutate: func(config *controllers.SessionOMPConfig) { config.CredentialKey = "OMP_PROFILE" }}, - {name: "PI runtime-owned credential environment", mutate: func(config *controllers.SessionOMPConfig) { config.CredentialKey = "PI_PROFILE" }}, - {name: "identical projected keys", mutate: func(config *controllers.SessionOMPConfig) { config.SettingsKey = config.ModelsKey }}, + {name: "legacy credential Secret", mutate: func(config *controllers.SessionOMPConfig) { config.CredentialSecretName = "omp-runtime-credential" }, reason: "OMPCredentialProjectionUnsupported"}, + {name: "legacy credential key", mutate: func(config *controllers.SessionOMPConfig) { config.CredentialKey = "MODEL_API_KEY" }, reason: "OMPCredentialProjectionUnsupported"}, + {name: "credential mode", mutate: func(config *controllers.SessionOMPConfig) { config.AllowUnauthenticated = false }, reason: "OMPCredentialProjectionUnsupported"}, + {name: "identical projected keys", mutate: func(config *controllers.SessionOMPConfig) { config.SettingsKey = config.ModelsKey }, reason: "OMPReferencesInvalid"}, } { t.Run(test.name, func(t *testing.T) { scheme := testScheme(t) @@ -271,8 +280,158 @@ func TestSessionRejectsInvalidOMPAuthenticationAndProjectionReferences(t *testin t.Fatal(err) } condition := findCondition(got.Status.Conditions, "RuntimeConfigured") - if condition == nil || condition.Status != metav1.ConditionFalse || condition.Reason != "OMPReferencesInvalid" { - t.Fatalf("RuntimeConfigured = %#v, want False/OMPReferencesInvalid", condition) + if condition == nil || condition.Status != metav1.ConditionFalse || condition.Reason != test.reason { + t.Fatalf("RuntimeConfigured = %#v, want False/%s", condition, test.reason) + } + }) + } +} + +func TestSessionCredentialModeUpgradeStopsExistingAuthorityAndClearsRoute(t *testing.T) { + scheme := testScheme(t) + workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) + workspace.Status.PVCName = "workspace-a-data" + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, + Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, + } + session := testSession() + session.UID = "session-uid" + c := fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&clusterv1alpha1.T4Session{}, &corev1.PersistentVolumeClaim{}, &corev1.Pod{}). + WithObjects(testHost(), workspace, pvc, session).Build() + r := configuredSessionReconciler(c, scheme) + reconcileMany(t, 2, func() error { + _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}) + return err + }) + var running clusterv1alpha1.T4Session + if err := c.Get(context.Background(), client.ObjectKeyFromObject(session), &running); err != nil { + t.Fatal(err) + } + if running.Status.PodName == "" || running.Status.ServiceName == "" { + t.Fatalf("session did not publish its initial route: %#v", running.Status) + } + + r.OMPConfig.AllowUnauthenticated = false + r.OMPConfig.CredentialSecretName = "omp-runtime-credential" + r.OMPConfig.CredentialKey = "MODEL_API_KEY" + if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}); err != nil { + t.Fatal(err) + } + assertObjectCounts(t, c, 0, 0) + var failed clusterv1alpha1.T4Session + if err := c.Get(context.Background(), client.ObjectKeyFromObject(session), &failed); err != nil { + t.Fatal(err) + } + condition := findCondition(failed.Status.Conditions, "RuntimeConfigured") + if failed.Status.PodName != "" || failed.Status.ServiceName != "" || failed.Status.Phase != clusterv1alpha1.InfrastructureFailed || condition == nil || condition.Reason != "OMPCredentialProjectionUnsupported" { + t.Fatalf("unsafe upgrade retained authority route: status=%#v condition=%#v", failed.Status, condition) + } +} + +func TestSessionRejectsCredentialBearingModelsConfiguration(t *testing.T) { + for _, test := range []struct { + name string + models string + }{ + {name: "malformed YAML", models: "providers: ["}, + {name: "missing providers", models: "models: []\n"}, + {name: "missing auth", models: "providers:\n public:\n baseUrl: https://model.example/v1\n"}, + {name: "authenticated provider", models: "providers:\n public:\n baseUrl: https://model.example/v1\n auth: api-key\n"}, + {name: "API key", models: "providers:\n public:\n baseUrl: https://model.example/v1\n auth: none\n apiKey: reusable\n"}, + {name: "authorization header", models: "providers:\n public:\n baseUrl: https://model.example/v1\n auth: none\n headers:\n Authorization: Bearer reusable\n"}, + {name: "x-api-key header", models: "providers:\n public:\n baseUrl: https://model.example/v1\n auth: none\n headers:\n X-API-Key: reusable\n"}, + {name: "custom header", models: "providers:\n public:\n baseUrl: https://model.example/v1\n auth: none\n headers:\n X-Custom-Auth: reusable\n"}, + {name: "URL userinfo", models: "providers:\n public:\n baseUrl: https://user:reusable@model.example/v1\n auth: none\n"}, + {name: "URL query credential", models: "providers:\n public:\n baseUrl: https://model.example/v1?access=reusable\n auth: none\n"}, + {name: "duplicate auth key", models: "providers:\n public:\n baseUrl: https://model.example/v1\n auth: none\n auth: api-key\n"}, + {name: "YAML merge alias", models: "shared: &shared\n auth: api-key\nproviders:\n public:\n <<: *shared\n baseUrl: https://model.example/v1\n auth: none\n"}, + } { + t.Run(test.name, func(t *testing.T) { + scheme := testScheme(t) + workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) + workspace.Status.PVCName = "workspace-a-data" + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, + Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, + } + session := testSession() + c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&clusterv1alpha1.T4Session{}, &corev1.PersistentVolumeClaim{}).WithObjects(testHost(), workspace, pvc, session).Build() + r := configuredSessionReconciler(c, scheme) + var configMap corev1.ConfigMap + key := types.NamespacedName{Namespace: "team", Name: r.OMPConfig.ConfigMapName} + if err := c.Get(context.Background(), key, &configMap); err != nil { + t.Fatal(err) + } + configMap.Data[r.OMPConfig.ModelsKey] = test.models + if err := c.Update(context.Background(), &configMap); err != nil { + t.Fatal(err) + } + reconcileMany(t, 2, func() error { + _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}) + return err + }) + var got clusterv1alpha1.T4Session + if err := c.Get(context.Background(), client.ObjectKeyFromObject(session), &got); err != nil { + t.Fatal(err) + } + condition := findCondition(got.Status.Conditions, "RuntimeConfigured") + if condition == nil || condition.Status != metav1.ConditionFalse || condition.Reason != "OMPModelsAuthenticationUnsafe" { + t.Fatalf("RuntimeConfigured = %#v, want False/OMPModelsAuthenticationUnsafe", condition) + } + }) + } +} + +func TestSessionRejectsCredentialBearingSettingsConfiguration(t *testing.T) { + for _, test := range []struct { + name string + settings string + }{ + {name: "broker token", settings: "auth.broker.token: reusable\n"}, + {name: "nested broker token", settings: "auth:\n broker:\n token: reusable\n"}, + {name: "hindsight token", settings: "hindsight.apiToken: reusable\n"}, + {name: "searxng token", settings: "searxng.token: reusable\n"}, + {name: "auto QA token", settings: "dev.autoqaPush.token: reusable\n"}, + {name: "custom headers", settings: "provider:\n headers:\n X-Custom-Auth: reusable\n"}, + {name: "duplicate key", settings: "theme: dark\ntheme: light\n"}, + {name: "YAML alias", settings: "theme: &theme dark\ncopy: *theme\n"}, + } { + t.Run(test.name, func(t *testing.T) { + scheme := testScheme(t) + workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) + workspace.Status.PVCName = "workspace-a-data" + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: workspace.Status.PVCName, Namespace: "team"}, + Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}}, + Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, + } + session := testSession() + c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&clusterv1alpha1.T4Session{}, &corev1.PersistentVolumeClaim{}).WithObjects(testHost(), workspace, pvc, session).Build() + r := configuredSessionReconciler(c, scheme) + var configMap corev1.ConfigMap + key := types.NamespacedName{Namespace: "team", Name: r.OMPConfig.ConfigMapName} + if err := c.Get(context.Background(), key, &configMap); err != nil { + t.Fatal(err) + } + configMap.Data[r.OMPConfig.SettingsKey] = test.settings + if err := c.Update(context.Background(), &configMap); err != nil { + t.Fatal(err) + } + reconcileMany(t, 2, func() error { + _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}) + return err + }) + var got clusterv1alpha1.T4Session + if err := c.Get(context.Background(), client.ObjectKeyFromObject(session), &got); err != nil { + t.Fatal(err) + } + condition := findCondition(got.Status.Conditions, "RuntimeConfigured") + if condition == nil || condition.Status != metav1.ConditionFalse || condition.Reason != "OMPSettingsAuthenticationUnsafe" { + t.Fatalf("RuntimeConfigured = %#v, want False/OMPSettingsAuthenticationUnsafe", condition) } }) } @@ -512,7 +671,6 @@ func TestSessionWaitsForBoundRWXThenCreatesExactlyOnePodAndService(t *testing.T) t.Fatalf("session ServiceAccount = %q", pod.Spec.ServiceAccountName) } serverIdentity := "" - var credential *corev1.EnvVar configSource := "" allowUnauthenticated := "" for i := range pod.Spec.Containers[0].Env { @@ -526,25 +684,18 @@ func TestSessionWaitsForBoundRWXThenCreatesExactlyOnePodAndService(t *testing.T) if env.Name == "T4_OMP_ALLOW_UNAUTHENTICATED" { allowUnauthenticated = env.Value } - if env.Name == r.OMPConfig.CredentialKey { - credential = env + if env.ValueFrom != nil && env.ValueFrom.SecretKeyRef != nil { + t.Fatalf("session runtime retained a Secret environment reference: %#v", env) } } if serverIdentity != controllers.DefaultServerServiceAccount { t.Fatalf("expected server ServiceAccount = %q", serverIdentity) } - if configSource != "/run/t4-omp-config-source" || allowUnauthenticated != "false" { + if configSource != "/run/t4-omp-config-source" || allowUnauthenticated != "true" { t.Fatalf("OMP preflight environment = source %q allowUnauthenticated %q", configSource, allowUnauthenticated) } - if len(pod.Spec.Containers[0].Args) != 1 || pod.Spec.Containers[0].Args[0] != r.OMPConfig.CredentialKey { - t.Fatalf("credential key argument = %#v, want %q", pod.Spec.Containers[0].Args, r.OMPConfig.CredentialKey) - } - if credential == nil || credential.Value != "" || credential.ValueFrom == nil || credential.ValueFrom.SecretKeyRef == nil { - t.Fatalf("credential environment reference = %#v", credential) - } - secretRef := credential.ValueFrom.SecretKeyRef - if credential.Name != r.OMPConfig.CredentialKey || secretRef.Name != r.OMPConfig.CredentialSecretName || secretRef.Key != r.OMPConfig.CredentialKey || secretRef.Optional == nil || *secretRef.Optional { - t.Fatalf("credential SecretKeyRef is not exact and non-optional: %#v", credential) + if len(pod.Spec.Containers[0].Args) != 0 { + t.Fatalf("session runtime received credential arguments: %#v", pod.Spec.Containers[0].Args) } apiAudience := "" for _, env := range pod.Spec.Containers[0].Env { @@ -596,7 +747,7 @@ func TestSessionWaitsForBoundRWXThenCreatesExactlyOnePodAndService(t *testing.T) } } -func TestSessionUnauthenticatedOMPModeOmitsCredentialSecretReference(t *testing.T) { +func TestSessionOMPModeOmitsCredentialSecretReference(t *testing.T) { scheme := testScheme(t) workspace := testWorkspace(clusterv1alpha1.RetentionPolicyDelete) workspace.Status.PVCName = "workspace-a-data" @@ -610,9 +761,6 @@ func TestSessionUnauthenticatedOMPModeOmitsCredentialSecretReference(t *testing. WithStatusSubresource(&clusterv1alpha1.T4Session{}, &corev1.PersistentVolumeClaim{}, &corev1.Pod{}). WithObjects(testHost(), workspace, pvc, session).Build() r := configuredSessionReconciler(c, scheme) - r.OMPConfig.AllowUnauthenticated = true - r.OMPConfig.CredentialSecretName = "" - r.OMPConfig.CredentialKey = "" reconcileMany(t, 2, func() error { _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}) return err @@ -724,13 +872,6 @@ func TestSessionPodHashIncludesEveryOMPReference(t *testing.T) { {name: "ConfigMap", mutate: func(config *controllers.SessionOMPConfig) { config.ConfigMapName = "other-omp-config" }}, {name: "models key", mutate: func(config *controllers.SessionOMPConfig) { config.ModelsKey = "other-models" }}, {name: "settings key", mutate: func(config *controllers.SessionOMPConfig) { config.SettingsKey = "other-settings" }}, - {name: "credential Secret", mutate: func(config *controllers.SessionOMPConfig) { config.CredentialSecretName = "other-credential" }}, - {name: "credential key", mutate: func(config *controllers.SessionOMPConfig) { config.CredentialKey = "OTHER_MODEL_API_KEY" }}, - {name: "authentication mode", mutate: func(config *controllers.SessionOMPConfig) { - config.AllowUnauthenticated = true - config.CredentialSecretName = "" - config.CredentialKey = "" - }}, } { t.Run(test.name, func(t *testing.T) { scheme := testScheme(t) @@ -773,18 +914,9 @@ func TestSessionRecreatesPodWhenOMPResourceVersionChanges(t *testing.T) { if err := c.Get(ctx, key, &configMap); err != nil { return err } - configMap.Data["provider-models"] = "changed models" + configMap.Data["provider-models"] = otherTestOMPModels return c.Update(ctx, &configMap) }}, - {name: "Secret", mutate: func(ctx context.Context, c client.Client) error { - var secret corev1.Secret - key := types.NamespacedName{Namespace: "team", Name: "omp-runtime-credential"} - if err := c.Get(ctx, key, &secret); err != nil { - return err - } - secret.Data["MODEL_API_KEY"] = []byte("rotated credential") - return c.Update(ctx, &secret) - }}, } { t.Run(test.name, func(t *testing.T) { ctx := context.Background() @@ -828,15 +960,6 @@ func TestSessionRuntimeReferenceRevocationStopsAuthority(t *testing.T) { {name: "ConfigMap deletion", wantReason: "OMPConfigMapNotFound", revoke: func(ctx context.Context, c client.Client) error { return c.Delete(ctx, &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "omp-runtime-config", Namespace: "team"}}) }}, - {name: "credential removal", wantReason: "OMPCredentialSecretInvalid", revoke: func(ctx context.Context, c client.Client) error { - var secret corev1.Secret - key := types.NamespacedName{Namespace: "team", Name: "omp-runtime-credential"} - if err := c.Get(ctx, key, &secret); err != nil { - return err - } - delete(secret.Data, "MODEL_API_KEY") - return c.Update(ctx, &secret) - }}, } { t.Run(test.name, func(t *testing.T) { ctx := context.Background() @@ -887,14 +1010,13 @@ func TestSessionRuntimeReferenceRevocationStopsAuthority(t *testing.T) { } } -func TestSessionFailsClosedWhenOMPObjectsAreMissing(t *testing.T) { +func TestSessionFailsClosedWhenOMPConfigMapIsMissing(t *testing.T) { for _, test := range []struct { name string configMap *corev1.ConfigMap wantReason string }{ {name: "ConfigMap", wantReason: "OMPConfigMapNotFound"}, - {name: "Secret", configMap: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "omp-runtime-config", Namespace: "team"}, Data: map[string]string{"provider-models": "models", "agent-settings": "settings"}}, wantReason: "OMPCredentialSecretNotFound"}, } { t.Run(test.name, func(t *testing.T) { scheme := testScheme(t) @@ -907,7 +1029,7 @@ func TestSessionFailsClosedWhenOMPObjectsAreMissing(t *testing.T) { objects = append(objects, test.configMap) } c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&clusterv1alpha1.T4Session{}, &corev1.PersistentVolumeClaim{}, &corev1.Pod{}).WithObjects(objects...).Build() - r := &controllers.SessionReconciler{Client: c, APIReader: c, Scheme: scheme, RuntimeImage: testRuntimeImage, OMPConfig: controllers.SessionOMPConfig{ConfigMapName: "omp-runtime-config", ModelsKey: "provider-models", SettingsKey: "agent-settings", CredentialSecretName: "omp-runtime-credential", CredentialKey: "MODEL_API_KEY"}} + r := &controllers.SessionReconciler{Client: c, APIReader: c, Scheme: scheme, RuntimeImage: testRuntimeImage, OMPConfig: controllers.SessionOMPConfig{ConfigMapName: "omp-runtime-config", ModelsKey: "provider-models", SettingsKey: "agent-settings", AllowUnauthenticated: true}} if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(session)}); err != nil { t.Fatal(err) } @@ -1135,16 +1257,10 @@ func TestSessionDeletionCleansResourcesBeforeFinalizer(t *testing.T) { func configuredSessionReconciler(c client.Client, scheme *runtime.Scheme) *controllers.SessionReconciler { for _, object := range []client.Object{ &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "omp-runtime-config", Namespace: "team"}, Data: map[string]string{ - "provider-models": "models", "agent-settings": "settings", "other-models": "other models", "other-settings": "other settings", + "provider-models": testOMPModels, "agent-settings": testOMPSettings, "other-models": otherTestOMPModels, "other-settings": otherTestOMPSettings, }}, &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "other-omp-config", Namespace: "team"}, Data: map[string]string{ - "provider-models": "models", "agent-settings": "settings", "other-models": "other models", "other-settings": "other settings", - }}, - &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "omp-runtime-credential", Namespace: "team"}, Data: map[string][]byte{ - "MODEL_API_KEY": []byte("credential"), "OTHER_MODEL_API_KEY": []byte("other credential"), - }}, - &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "other-credential", Namespace: "team"}, Data: map[string][]byte{ - "MODEL_API_KEY": []byte("credential"), "OTHER_MODEL_API_KEY": []byte("other credential"), + "provider-models": testOMPModels, "agent-settings": testOMPSettings, "other-models": otherTestOMPModels, "other-settings": otherTestOMPSettings, }}, rwxStorageClass(), } { @@ -1161,8 +1277,7 @@ func configuredSessionReconciler(c client.Client, scheme *runtime.Scheme) *contr ConfigMapName: "omp-runtime-config", ModelsKey: "provider-models", SettingsKey: "agent-settings", - CredentialSecretName: "omp-runtime-credential", - CredentialKey: "MODEL_API_KEY", + AllowUnauthenticated: true, }, } } diff --git a/packages/cluster-operator/controllers/session_controller.go b/packages/cluster-operator/controllers/session_controller.go index c2353a12..9336a3f4 100644 --- a/packages/cluster-operator/controllers/session_controller.go +++ b/packages/cluster-operator/controllers/session_controller.go @@ -5,11 +5,13 @@ import ( "crypto/sha256" "encoding/json" "fmt" + "net/url" "reflect" "regexp" "strings" "time" + "gopkg.in/yaml.v3" corev1 "k8s.io/api/core/v1" storagev1 "k8s.io/api/storage/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -36,22 +38,9 @@ const ( var ( configMapKeyPattern = regexp.MustCompile(`^[-._A-Za-z0-9]+$`) - envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) runtimeImagePattern = regexp.MustCompile(`^(?:(?:[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?|\[[A-Fa-f0-9:]+\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*@sha256:[a-f0-9]{64}$`) ) -func reservedCredentialEnvironment(name string) bool { - if strings.HasPrefix(name, "T4_") || strings.HasPrefix(name, "OMP_") || strings.HasPrefix(name, "PI_") || strings.HasPrefix(name, "XDG_") || strings.HasPrefix(name, "LD_") { - return true - } - switch name { - case "HOME", "DISPLAY", "PATH", "BASH_ENV", "ENV", "SHELLOPTS", "NODE_OPTIONS", "BUN_OPTIONS": - return true - default: - return false - } -} - type SessionOMPConfig struct { ConfigMapName string ModelsKey string @@ -61,8 +50,7 @@ type SessionOMPConfig struct { AllowUnauthenticated bool } type ompResourceVersions struct { - ConfigMap string `json:"configMap"` - CredentialSecret string `json:"credentialSecret,omitempty"` + ConfigMap string `json:"configMap"` } func (r *SessionReconciler) loadOMPResourceVersions(ctx context.Context, namespace string) (ompResourceVersions, string, string, error) { @@ -80,46 +68,186 @@ func (r *SessionReconciler) loadOMPResourceVersions(ctx context.Context, namespa if configMap.Data[r.OMPConfig.ModelsKey] == "" || configMap.Data[r.OMPConfig.SettingsKey] == "" { return ompResourceVersions{}, "OMPConfigMapInvalid", "administrator-owned OMP ConfigMap must contain nonempty models and settings keys", nil } - versions := ompResourceVersions{ConfigMap: configMap.ResourceVersion} - if r.OMPConfig.AllowUnauthenticated { - return versions, "", "", nil + if err := validateAuthNoneModelsYAML(configMap.Data[r.OMPConfig.ModelsKey]); err != nil { + return ompResourceVersions{}, "OMPModelsAuthenticationUnsafe", "OMP models configuration must contain only auth-none providers and no embedded credential fields", nil } - var secret corev1.Secret - if err := reader.Get(ctx, types.NamespacedName{Namespace: namespace, Name: r.OMPConfig.CredentialSecretName}, &secret); err != nil { - if apierrors.IsNotFound(err) { - return ompResourceVersions{}, "OMPCredentialSecretNotFound", "administrator-owned OMP credential Secret does not exist", nil + if err := validateCredentialFreeSettingsYAML(configMap.Data[r.OMPConfig.SettingsKey]); err != nil { + return ompResourceVersions{}, "OMPSettingsAuthenticationUnsafe", "OMP settings configuration must not contain embedded credential fields", nil + } + return ompResourceVersions{ConfigMap: configMap.ResourceVersion}, "", "", nil +} + +func mappingValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + for index := 0; index+1 < len(node.Content); index += 2 { + if node.Content[index].Value == key { + return node.Content[index+1] } - return ompResourceVersions{}, "", "", err } - if len(secret.Data[r.OMPConfig.CredentialKey]) == 0 { - return ompResourceVersions{}, "OMPCredentialSecretInvalid", "administrator-owned OMP credential Secret must contain the configured nonempty key", nil + return nil +} + +func canonicalYAMLKey(value string) string { + return strings.Map(func(character rune) rune { + if character >= 'A' && character <= 'Z' { + return character + ('a' - 'A') + } + if character >= 'a' && character <= 'z' || character >= '0' && character <= '9' { + return character + } + return -1 + }, value) +} + +func validateYAMLStructure(node *yaml.Node) error { + if node == nil { + return nil + } + if node.Kind == yaml.AliasNode || node.Alias != nil || node.Anchor != "" { + return fmt.Errorf("YAML aliases and anchors are unsupported") + } + if node.Kind == yaml.MappingNode { + if len(node.Content)%2 != 0 { + return fmt.Errorf("YAML mapping is malformed") + } + seen := make(map[string]struct{}, len(node.Content)/2) + for index := 0; index < len(node.Content); index += 2 { + key, value := node.Content[index], node.Content[index+1] + if key.Kind != yaml.ScalarNode || key.Tag != "!!str" || key.Value == "<<" { + return fmt.Errorf("YAML mapping keys must be plain strings") + } + if _, duplicate := seen[key.Value]; duplicate { + return fmt.Errorf("YAML mapping contains duplicate key %q", key.Value) + } + seen[key.Value] = struct{}{} + if err := validateYAMLStructure(value); err != nil { + return err + } + } + return nil } - versions.CredentialSecret = secret.ResourceVersion - return versions, "", "", nil + for _, child := range node.Content { + if err := validateYAMLStructure(child); err != nil { + return err + } + } + return nil } -func (config SessionOMPConfig) validationFailure() (string, string) { - if config.ConfigMapName == "" || config.ModelsKey == "" || config.SettingsKey == "" { - return "OMPReferencesMissing", "administrator-owned OMP ConfigMap and configuration keys are not configured" +func credentialBearingYAMLKey(value string) bool { + canonical := canonicalYAMLKey(value) + switch canonical { + case "apikey", "authheader", "authorization", "credential", "password", "secret", "token", "accesstoken", "xapikey", "headers": + return true } - if config.AllowUnauthenticated { - if config.CredentialSecretName != "" || config.CredentialKey != "" { - return "OMPReferencesInvalid", "unauthenticated OMP mode cannot include credential Secret references" + for _, suffix := range []string{"apikey", "authheader", "authorization", "credential", "password", "secret", "token"} { + if strings.HasSuffix(canonical, suffix) { + return true } - } else { - if config.CredentialSecretName == "" && config.CredentialKey == "" { - return "OMPReferencesMissing", "administrator-owned OMP credential Secret and key are not configured" + } + return false +} + +func forbiddenCredentialField(node *yaml.Node) bool { + if node == nil { + return false + } + if node.Kind == yaml.MappingNode { + for index := 0; index+1 < len(node.Content); index += 2 { + key, value := node.Content[index], node.Content[index+1] + if credentialBearingYAMLKey(key.Value) { + return true + } + switch canonicalYAMLKey(key.Value) { + case "headers": + return true + case "baseurl", "url", "endpoint": + if value.Kind == yaml.ScalarNode { + if parsed, err := url.Parse(value.Value); err == nil && (parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "") { + return true + } + } + } + if forbiddenCredentialField(value) { + return true + } + } + return false + } + for _, child := range node.Content { + if forbiddenCredentialField(child) { + return true } - if config.CredentialSecretName == "" || config.CredentialKey == "" { - return "OMPReferencesInvalid", "OMP credential Secret and key must be configured together" + } + return false +} + +func validateCredentialFreeSettingsYAML(content string) error { + var document yaml.Node + if err := yaml.Unmarshal([]byte(content), &document); err != nil { + return err + } + if len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode { + return fmt.Errorf("settings document must be a mapping") + } + root := document.Content[0] + if err := validateYAMLStructure(root); err != nil { + return err + } + if forbiddenCredentialField(root) { + return fmt.Errorf("settings document contains a credential-bearing field") + } + return nil +} + +func validateAuthNoneModelsYAML(content string) error { + var document yaml.Node + if err := yaml.Unmarshal([]byte(content), &document); err != nil { + return err + } + if len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode { + return fmt.Errorf("models document must be a mapping") + } + root := document.Content[0] + if err := validateYAMLStructure(root); err != nil { + return err + } + providers := mappingValue(root, "providers") + if providers == nil || providers.Kind != yaml.MappingNode || len(providers.Content) == 0 { + return fmt.Errorf("models document must contain providers") + } + for index := 0; index+1 < len(providers.Content); index += 2 { + provider := providers.Content[index+1] + auth := mappingValue(provider, "auth") + if provider.Kind != yaml.MappingNode || auth == nil || auth.Kind != yaml.ScalarNode || auth.Value != "none" { + return fmt.Errorf("provider authentication must be none") } } + if forbiddenCredentialField(root) { + return fmt.Errorf("models document contains a credential-bearing field") + } + return nil +} + +func (config SessionOMPConfig) validationFailure() (string, string) { + // A session runtime intentionally exposes arbitrary-code tools. A reusable + // provider credential therefore cannot be projected into the Pod in any + // form: the authority process, its tool children, and the user's shell share + // the same workload security boundary. Keep the legacy fields only so an old + // deployment fails with an explicit migration condition instead of silently + // accepting unsafe configuration. + if config.CredentialSecretName != "" || config.CredentialKey != "" || !config.AllowUnauthenticated { + return "OMPCredentialProjectionUnsupported", "reusable provider credentials cannot be projected into arbitrary-code session Pods; configure an identity- and NetworkPolicy-isolated auth-none model route" + } + if config.ConfigMapName == "" || config.ModelsKey == "" || config.SettingsKey == "" { + return "OMPReferencesMissing", "administrator-owned OMP ConfigMap and configuration keys are not configured" + } if len(utilvalidation.IsDNS1123Subdomain(config.ConfigMapName)) != 0 || len(config.ModelsKey) > 253 || !configMapKeyPattern.MatchString(config.ModelsKey) || len(config.SettingsKey) > 253 || !configMapKeyPattern.MatchString(config.SettingsKey) || - config.ModelsKey == config.SettingsKey || - (config.CredentialSecretName != "" && len(utilvalidation.IsDNS1123Subdomain(config.CredentialSecretName)) != 0) || - (config.CredentialKey != "" && (len(config.CredentialKey) > 253 || !envVarNamePattern.MatchString(config.CredentialKey) || reservedCredentialEnvironment(config.CredentialKey))) { + config.ModelsKey == config.SettingsKey { return "OMPReferencesInvalid", "administrator-owned OMP configuration references are invalid" } return "", "" @@ -176,6 +304,9 @@ func (r *SessionReconciler) Reconcile(ctx context.Context, request ctrl.Request) return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, "RuntimeConfigured", reason, message) } if reason, message := r.OMPConfig.validationFailure(); reason != "" { + if err := r.deleteOwnedSessionResources(ctx, &session); err != nil { + return ctrl.Result{}, err + } return ctrl.Result{RequeueAfter: 30 * time.Second}, r.updateSessionFailure(ctx, &session, "RuntimeConfigured", reason, message) } @@ -433,17 +564,6 @@ func (r *SessionReconciler) desiredPod(session *clusterv1alpha1.T4Session, pvcNa ReadinessProbe: &corev1.Probe{ProbeHandler: corev1.ProbeHandler{TCPSocket: &corev1.TCPSocketAction{Port: intstr.FromString("host")}}, FailureThreshold: 3, PeriodSeconds: 5, TimeoutSeconds: 2}, LivenessProbe: &corev1.Probe{ProbeHandler: corev1.ProbeHandler{TCPSocket: &corev1.TCPSocketAction{Port: intstr.FromString("host")}}, FailureThreshold: 3, PeriodSeconds: 10, TimeoutSeconds: 2}, } - if !r.OMPConfig.AllowUnauthenticated { - container.Args = []string{r.OMPConfig.CredentialKey} - container.Env = append(container.Env, corev1.EnvVar{ - Name: r.OMPConfig.CredentialKey, - ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: r.OMPConfig.CredentialSecretName}, - Key: r.OMPConfig.CredentialKey, - Optional: &falseValue, - }}, - }) - } volumes := []corev1.Volume{ {Name: "workspace", VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: pvcName}}}, {Name: "runtime", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{Medium: corev1.StorageMediumMemory, SizeLimit: ptrQuantity(apiresource.MustParse("128Mi"))}}}, diff --git a/packages/cluster-operator/go.mod b/packages/cluster-operator/go.mod index 83e424b3..c85639c9 100644 --- a/packages/cluster-operator/go.mod +++ b/packages/cluster-operator/go.mod @@ -4,6 +4,7 @@ go 1.24.0 require ( github.com/prometheus/client_golang v1.22.0 + gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.33.2 k8s.io/apiextensions-apiserver v0.33.2 k8s.io/apimachinery v0.33.2 @@ -55,7 +56,6 @@ require ( google.golang.org/protobuf v1.36.5 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect diff --git a/packages/cluster-server/src/config.ts b/packages/cluster-server/src/config.ts index addb6e9e..06a8d863 100644 --- a/packages/cluster-server/src/config.ts +++ b/packages/cluster-server/src/config.ts @@ -18,6 +18,7 @@ export interface ClusterServerConfig { readonly kubernetesApiAudience: string; readonly identityTokenPath: string; readonly serverServiceAccountName: string; + readonly identityProvider: "tailscale"; readonly trustedProxyAddresses: readonly string[]; readonly trustedProxyCidrs: readonly string[]; readonly woodpecker?: { @@ -28,6 +29,10 @@ export interface ClusterServerConfig { readonly tokenFile?: string; }; } +function identityProvider(value: string): "tailscale" { + if (value !== "tailscale") throw new Error("T4_CLUSTER_IDENTITY_PROVIDER must be tailscale"); + return value; +} function required(env: Readonly>, name: string): string { const value = env[name]; if (!value) throw new Error(`${name} is required`); @@ -138,6 +143,7 @@ export function clusterServerConfigFromEnv(env: Readonly boolean): string | undefined { +export function gatewayPrincipal( + request: Request, + remoteAddress: string, + trustedSource: (address: string) => boolean, + identityProvider: "tailscale", +): string | undefined { + if (identityProvider !== "tailscale") return undefined; if (!trustedSource(remoteAddress)) return undefined; if (request.headers.get("x-forwarded-proto") !== "https") return undefined; - const principal = request.headers.get("tailscale-user-login") ?? request.headers.get("tailscale-user-name"); + const principal = request.headers.get("tailscale-user-login"); if (!principal || principal !== principal.trim() || new TextEncoder().encode(principal).byteLength > 256 || /\p{Cc}/u.test(principal)) return undefined; return principal; } @@ -66,7 +73,7 @@ export function startClusterHttpServers(options: ClusterHttpServersOptions): Clu if (draining) return new Response("draining", { status: 503 }); const remoteAddress = server.requestIP(request)?.address; if (!remoteAddress) return new Response("authenticated proxy required", { status: 401 }); - const principal = gatewayPrincipal(request, remoteAddress, trustedSource); + const principal = gatewayPrincipal(request, remoteAddress, trustedSource, options.identityProvider); if (!principal) return new Response("authenticated Tailscale proxy identity required", { status: 401 }); const origin = request.headers.get("origin"); if (origin && !options.projection.allowedOrigins().includes(origin)) return new Response("forbidden", { status: 403 }); diff --git a/packages/cluster-server/test/config.test.ts b/packages/cluster-server/test/config.test.ts index 11ab50c4..611ff078 100644 --- a/packages/cluster-server/test/config.test.ts +++ b/packages/cluster-server/test/config.test.ts @@ -13,6 +13,7 @@ const BASE_ENV = { T4_CLUSTER_HOST_NAME: "default", T4_CLUSTER_IDENTITY_TOKEN_FILE: "/var/run/secrets/t4-cluster-identity/token", T4_CLUSTER_SERVER_SERVICE_ACCOUNT: "release-t4-cluster-server", + T4_CLUSTER_IDENTITY_PROVIDER: "tailscale", } as const; describe("cluster server configuration", () => { @@ -112,6 +113,12 @@ describe("cluster server configuration", () => { }); describe("trusted cluster gateway proxy sources", () => { + it("requires the explicit Tailscale identity provider", () => { + expect(clusterServerConfigFromEnv(BASE_ENV).identityProvider).toBe("tailscale"); + expect(() => clusterServerConfigFromEnv({ ...BASE_ENV, T4_CLUSTER_IDENTITY_PROVIDER: undefined })).toThrow("T4_CLUSTER_IDENTITY_PROVIDER is required"); + expect(() => clusterServerConfigFromEnv({ ...BASE_ENV, T4_CLUSTER_IDENTITY_PROVIDER: "generic" })).toThrow("must be tailscale"); + }); + it("accepts bounded canonical IPv4 and IPv6 networks", () => { const config = clusterServerConfigFromEnv({ ...BASE_ENV, diff --git a/packages/cluster-server/test/server.test.ts b/packages/cluster-server/test/server.test.ts new file mode 100644 index 00000000..f00dee31 --- /dev/null +++ b/packages/cluster-server/test/server.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vite-plus/test"; +import { gatewayPrincipal } from "../src/server.ts"; + +function request(headers: Readonly>): Request { + return new Request("https://cluster.example/v1/ws", { headers }); +} + +function requestWithRawHeaders(headers: Readonly>): Request { + return { + headers: { get: (name: string): string | null => headers[name] ?? null }, + } as unknown as Request; +} + +describe("cluster gateway Tailscale identity", () => { + const trustedSource = (address: string): boolean => address === "100.64.0.7"; + + it("accepts the Tailscale login only across the explicit trusted HTTPS proxy boundary", () => { + const input = request({ + "x-forwarded-proto": "https", + "tailscale-user-login": "operator@example.com", + "tailscale-user-name": "Operator", + }); + expect(gatewayPrincipal(input, "100.64.0.7", trustedSource, "tailscale")).toBe("operator@example.com"); + }); + + it("rejects identity headers from untrusted sources or non-HTTPS forwarding", () => { + const identityHeaders = { "x-forwarded-proto": "https", "tailscale-user-login": "attacker@example.com" }; + expect(gatewayPrincipal(request(identityHeaders), "198.51.100.4", trustedSource, "tailscale")).toBeUndefined(); + expect(gatewayPrincipal(request({ ...identityHeaders, "x-forwarded-proto": "http" }), "100.64.0.7", trustedSource, "tailscale")).toBeUndefined(); + expect(gatewayPrincipal(request({ "tailscale-user-login": "attacker@example.com" }), "100.64.0.7", trustedSource, "tailscale")).toBeUndefined(); + }); + + it("does not treat the display-name header as an authenticated principal", () => { + const input = request({ "x-forwarded-proto": "https", "tailscale-user-name": "Spoofed User" }); + expect(gatewayPrincipal(input, "100.64.0.7", trustedSource, "tailscale")).toBeUndefined(); + }); + + it("rejects malformed or oversized login identities", () => { + for (const principal of [" padded@example.com", "line\nbreak", "x".repeat(257)]) { + const input = requestWithRawHeaders({ "x-forwarded-proto": "https", "tailscale-user-login": principal }); + expect(gatewayPrincipal(input, "100.64.0.7", trustedSource, "tailscale")).toBeUndefined(); + } + }); + + it("fails closed if untyped JavaScript supplies another provider", () => { + const input = request({ "x-forwarded-proto": "https", "tailscale-user-login": "attacker@example.com" }); + expect(gatewayPrincipal(input, "100.64.0.7", trustedSource, "generic" as "tailscale")).toBeUndefined(); + }); +}); diff --git a/scripts/cluster-ci/cluster-ci-contract.test.mjs b/scripts/cluster-ci/cluster-ci-contract.test.mjs index 184cad23..4c240a34 100644 --- a/scripts/cluster-ci/cluster-ci-contract.test.mjs +++ b/scripts/cluster-ci/cluster-ci-contract.test.mjs @@ -333,6 +333,11 @@ test("Woodpecker keeps upstream gates and serializes bounded cluster publication assert.ok( steps["cluster-server-tests"].commands.includes("(cd packages/cluster-server && bun --bun run test)"), ); + assert.ok( + steps["cluster-server-tests"].commands.includes( + "bun test cluster/images/session-runtime/assert-omp-credentials-absent.test.ts", + ), + ); assert.match(steps["cluster-wire-tests"].image, /library\/node:[^@]+@sha256:[0-9a-f]{64}$/u); assert.deepEqual(steps["cluster-wire-tests"].depends_on, ["cluster-server-tests", "bun-runtime"]); assert.ok(steps["cluster-wire-tests"].commands.includes('export PATH="$PWD/.ci:$PATH"'));