Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions .woodpecker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
122 changes: 122 additions & 0 deletions cluster/images/session-runtime/assert-omp-credentials-absent.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
152 changes: 152 additions & 0 deletions cluster/images/session-runtime/assert-omp-credentials-absent.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Check the broker token in the selected OMP profile

The durable credential preflight checks $HOME/.omp/auth-broker.token, but OMP's profile-local broker token is stored at $HOME/.omp/profiles/<profile>/auth-broker.token (as the repository's maintainer installer does). Thus a session whose selected profile contains that token will pass this check and start OMP with a reusable broker credential accessible to the arbitrary-code session workload. Derive and check the profile root from agentDir (the parent of agent) instead.

Useful? React with 👍 / 👎.

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 <agent-dir> <home>");
await assertOMPProfileCredentialsAbsent({ agentDir, home });
process.stdout.write(JSON.stringify({ component: "session-runtime", result: "credential_state_absent" }) + "\n");
}
28 changes: 8 additions & 20 deletions cluster/images/session-runtime/session-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]}"
Expand Down
9 changes: 5 additions & 4 deletions deploy/charts/t4-cluster/templates/deployments.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 0 additions & 6 deletions deploy/charts/t4-cluster/templates/rbac.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading