mirror of
https://github.com/sredevopsorg/deepseek-kubernetes-sandbox.git
synced 2026-11-03 09:23:25 +00:00
Compare commits
1 Commits
main
...
chore/enti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a585e6be13 |
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"agent": "dsh",
|
|
||||||
"bridge": "@sredevopsorg/dsh-entire-bridge",
|
|
||||||
"hook_names": [
|
|
||||||
"session-start",
|
|
||||||
"turn-start",
|
|
||||||
"turn-end",
|
|
||||||
"compaction",
|
|
||||||
"session-end",
|
|
||||||
"subagent-start",
|
|
||||||
"subagent-end"
|
|
||||||
],
|
|
||||||
"installed_at": "2026-11-02T06:02:27.816Z",
|
|
||||||
"local_dev": false
|
|
||||||
}
|
|
||||||
585
.opencode/plugins/entire.ts
Normal file
585
.opencode/plugins/entire.ts
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
// Entire CLI plugin for OpenCode
|
||||||
|
// Auto-generated by `entire enable --agent opencode`
|
||||||
|
// Do not edit manually — changes will be overwritten on next install.
|
||||||
|
//
|
||||||
|
// Uses node:child_process so hooks work under both Bun (OpenCode CLI/TUI)
|
||||||
|
// and Node (OpenCode Desktop's Electron sidecar). See #2014.
|
||||||
|
import { spawn, spawnSync } from "node:child_process"
|
||||||
|
import type { Plugin } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
|
export const EntirePlugin: Plugin = async ({ directory }) => {
|
||||||
|
// Track seen user messages to fire turn-start only once per message
|
||||||
|
const seenUserMessages = new Set<string>()
|
||||||
|
// Track current session ID for message events (which don't include sessionID)
|
||||||
|
let currentSessionID: string | null = null
|
||||||
|
// Track the model used by the most recent assistant message
|
||||||
|
let currentModel: string | null = null
|
||||||
|
// In-memory store for message metadata (role, tokens, etc.)
|
||||||
|
const messageStore = new Map<string, any>()
|
||||||
|
// One-time model-context injection captured from the turn-start hook's stdout,
|
||||||
|
// applied on the next LLM call via experimental.chat.system.transform.
|
||||||
|
let pendingInjection: string | null = null
|
||||||
|
// Child (subagent) sessions. OpenCode's task tool runs each subagent as a
|
||||||
|
// real session, so without this set a child would register as the user's
|
||||||
|
// session: its write would take the checkpoint and the parent would log
|
||||||
|
// "no files modified". Learned from session.* events' `parentID`, and from the child
|
||||||
|
// ID surfaced in the parent's task part / tool.execute.after — the latter
|
||||||
|
// two also cover a child session resumed via `task_id` from an earlier
|
||||||
|
// process, whose own session.created predates this plugin instance. These
|
||||||
|
// sets live for the process and are cleared only on server.instance.disposed.
|
||||||
|
const childSessions = new Set<string>()
|
||||||
|
// child session ID -> the top-level (user's) session it descends from. With
|
||||||
|
// subagent_depth > 1 a child can itself call the task tool; that
|
||||||
|
// grandchild's task is recorded on the top-level session, the only one
|
||||||
|
// Entire tracks, keyed by its own callID like any other task.
|
||||||
|
const rootOf = new Map<string, string>()
|
||||||
|
// child session ID -> the session that launched it, so aborting a session
|
||||||
|
// can end the background calls it launched.
|
||||||
|
const parentOf = new Map<string, string>()
|
||||||
|
// The per-call maps and sets below are keyed by callKey: the calling
|
||||||
|
// session plus the hook key. A callID is unique only within one session
|
||||||
|
// (some providers number calls per conversation), so a child's own task
|
||||||
|
// call can share one with its parent's running call.
|
||||||
|
// Call keys already announced via subagent-start (the running part update
|
||||||
|
// repeats), so a late running update cannot announce a task again after its
|
||||||
|
// stop. Cleared for a key when a new call starts under it (a reused callID).
|
||||||
|
const announcedTasks = new Set<string>()
|
||||||
|
// Call key -> Date.now() at tool.execute.before, sent as started_at. A
|
||||||
|
// child resumed via `task_id` holds every earlier call's messages too; this
|
||||||
|
// is what lets the CLI keep only the ones this call produced. The hook key
|
||||||
|
// is the part's callID, except for a command subtask (`subtask: true`),
|
||||||
|
// whose before/after hooks carry the task part's own id instead.
|
||||||
|
const taskStartedAt = new Map<string, number>()
|
||||||
|
// Announced tasks not yet stopped: call key -> its subagent-stop payload.
|
||||||
|
// Every stop path takes the entry out, so a task stops at most once.
|
||||||
|
const liveTasks = new Map<string, Record<string, unknown>>()
|
||||||
|
// Call key of a task part id -> call key of its callID, to map a command
|
||||||
|
// subtask's hook key to the callID its subagent-start used.
|
||||||
|
const partCallID = new Map<string, string>()
|
||||||
|
// Call keys whose stop fired before their running part was announced. The
|
||||||
|
// late announcement is then skipped rather than opening a task that would
|
||||||
|
// never stop.
|
||||||
|
const stoppedBeforeStart = new Set<string>()
|
||||||
|
// Call keys of tasks stopped from their error part, so a
|
||||||
|
// tool.execute.after that still arrives does not stop them again.
|
||||||
|
const stoppedOnError = new Set<string>()
|
||||||
|
// Background children (OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS, or a
|
||||||
|
// foreground task promoted to the background): child ID -> subagent-stop
|
||||||
|
// payloads held back until the child's own session goes idle. A queue: a
|
||||||
|
// resume via `task_id` that joins a running child is queued behind the
|
||||||
|
// current run and runs as its own busy -> idle cycle, so each idle ends the
|
||||||
|
// oldest held call.
|
||||||
|
const backgroundTasks = new Map<string, Record<string, unknown>[]>()
|
||||||
|
// Sessions whose last session.status was not idle. An idle only ends a
|
||||||
|
// held call when it follows a busy period: an errored run reports idle
|
||||||
|
// twice.
|
||||||
|
const busySessions = new Set<string>()
|
||||||
|
// Children whose held stops all end at their next idle: an abort reached
|
||||||
|
// them mid-run. Cancelling a job does not cancel a joined run already
|
||||||
|
// executing, so the child's own idle, not the abort, is when it ends.
|
||||||
|
const drainAtIdle = new Set<string>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the shell command for a hook invocation.
|
||||||
|
* Uses sh -c so the command is guarded by a `command -v` probe: when the
|
||||||
|
* entire binary is not on PATH the hook exits 0 rather than failing the
|
||||||
|
* surrounding OpenCode operation.
|
||||||
|
*/
|
||||||
|
function hookCmd(hookName: string): string[] {
|
||||||
|
return ["sh", "-c", `if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks opencode ${hookName}`]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipe JSON payload to an entire hooks command (async).
|
||||||
|
* Errors are logged but never thrown — plugin failures must not crash OpenCode.
|
||||||
|
*/
|
||||||
|
async function callHook(hookName: string, payload: Record<string, unknown>) {
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(payload)
|
||||||
|
const [cmd, ...args] = hookCmd(hookName)
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const proc = spawn(cmd, args, {
|
||||||
|
cwd: directory,
|
||||||
|
stdio: ["pipe", "ignore", "ignore"],
|
||||||
|
})
|
||||||
|
proc.on("error", () => resolve())
|
||||||
|
proc.on("close", () => resolve())
|
||||||
|
proc.stdin?.end(json + "\n")
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — plugin failures must not crash OpenCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous variant for hooks that must complete before subsequent agent work
|
||||||
|
* or process exit. `turn-start` must finish initializing session state before a
|
||||||
|
* fast mid-turn commit can hit git hooks, and `turn-end` / `session-end` must
|
||||||
|
* finish before `opencode run` tears down its event loop.
|
||||||
|
*/
|
||||||
|
function callHookSync(hookName: string, payload: Record<string, unknown>) {
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(payload)
|
||||||
|
const [cmd, ...args] = hookCmd(hookName)
|
||||||
|
spawnSync(cmd, args, {
|
||||||
|
cwd: directory,
|
||||||
|
input: json + "\n",
|
||||||
|
stdio: ["pipe", "ignore", "ignore"],
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — plugin failures must not crash OpenCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseInjectedContext scans a hook's stdout for Entire's injection envelope
|
||||||
|
// ({"inject_context":"..."}) and returns the text to inject, or null.
|
||||||
|
function parseInjectedContext(stdout: string): string | null {
|
||||||
|
if (!stdout) return null
|
||||||
|
for (const line of stdout.split("\n")) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed.startsWith("{")) continue
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as { inject_context?: unknown }
|
||||||
|
if (typeof parsed.inject_context === "string" && parsed.inject_context.length > 0) {
|
||||||
|
return parsed.inject_context
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// not our envelope — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireTurnStart fires turn-start synchronously (state must be ready before
|
||||||
|
// mid-turn commits) and stashes any model-context injection emitted on stdout
|
||||||
|
// for experimental.chat.system.transform to apply. Entire emits the injection
|
||||||
|
// at most once per session, so pendingInjection is set on the first turn only.
|
||||||
|
function fireTurnStart(payload: Record<string, unknown>) {
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(payload)
|
||||||
|
const [cmd, ...args] = hookCmd("turn-start")
|
||||||
|
const proc = spawnSync(cmd, args, {
|
||||||
|
cwd: directory,
|
||||||
|
input: json + "\n",
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["pipe", "pipe", "ignore"],
|
||||||
|
})
|
||||||
|
const out = typeof proc.stdout === "string" ? proc.stdout : ""
|
||||||
|
const injected = parseInjectedContext(out)
|
||||||
|
if (injected) pendingInjection = injected
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — plugin failures must not crash OpenCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackChild records a child and the top-level session it descends from.
|
||||||
|
function trackChild(childID: string, parentID: string) {
|
||||||
|
childSessions.add(childID)
|
||||||
|
if (!rootOf.has(childID)) rootOf.set(childID, rootOf.get(parentID) ?? parentID)
|
||||||
|
if (!parentOf.has(childID)) parentOf.set(childID, parentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// callKey scopes a hook key (a callID or a task part id) to the session
|
||||||
|
// that made the call.
|
||||||
|
function callKey(sessionID: string, hookKey: string): string {
|
||||||
|
return sessionID + "\0" + hookKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// topLevelSession maps a session to the top-level session it belongs to.
|
||||||
|
function topLevelSession(sessionID: string): string {
|
||||||
|
return rootOf.get(sessionID) ?? sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
// announceTask fires subagent-start the first time a task part is running
|
||||||
|
// with its child bound (state.metadata.sessionId). tool.execute.before fires
|
||||||
|
// earlier but has no child ID yet, and session.created for the child can
|
||||||
|
// interleave with a sibling's, so neither is a safe join. The running update
|
||||||
|
// repeats, hence once per call key. Called for task parts in child sessions
|
||||||
|
// too, so a nested subagent is announced on the top-level session.
|
||||||
|
function announceTask(part: any) {
|
||||||
|
if (!(part?.type === "tool" && part.tool === "task" && part.callID &&
|
||||||
|
part.state?.status === "running" && part.state?.metadata?.sessionId)) return
|
||||||
|
const sessionID = part.sessionID ?? currentSessionID
|
||||||
|
if (!sessionID) return
|
||||||
|
const key = callKey(sessionID, part.callID)
|
||||||
|
const partKey = callKey(sessionID, part.id)
|
||||||
|
if (announcedTasks.has(key)) return
|
||||||
|
announcedTasks.add(key)
|
||||||
|
trackChild(part.state.metadata.sessionId, sessionID)
|
||||||
|
// Without the before hook (the plugin loaded mid-call), the part's own
|
||||||
|
// start, OpenCode's clock before the child's prompt, still bounds it.
|
||||||
|
const startedAt = taskStartedAt.get(key) ?? taskStartedAt.get(partKey) ?? part.state?.time?.start ?? 0
|
||||||
|
if (stoppedBeforeStart.delete(key) || stoppedBeforeStart.delete(partKey)) {
|
||||||
|
forgetTaskKeys(part, sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
session_id: topLevelSession(sessionID),
|
||||||
|
tool_use_id: part.callID,
|
||||||
|
subagent_id: part.state.metadata.sessionId,
|
||||||
|
subagent_type: part.state?.input?.subagent_type ?? "",
|
||||||
|
task_description: part.state?.input?.description ?? "",
|
||||||
|
started_at: startedAt,
|
||||||
|
}
|
||||||
|
liveTasks.set(key, payload)
|
||||||
|
if (part.id) partCallID.set(partKey, key)
|
||||||
|
callHookSync("subagent-start", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// announcedKey maps a call key to the call key the task was announced under.
|
||||||
|
function announcedKey(key: string): string {
|
||||||
|
return partCallID.get(key) ?? key
|
||||||
|
}
|
||||||
|
|
||||||
|
// takeLiveTask removes and returns the announced, not yet stopped task a
|
||||||
|
// call key belongs to.
|
||||||
|
function takeLiveTask(key: string): Record<string, unknown> | undefined {
|
||||||
|
const announced = announcedKey(key)
|
||||||
|
partCallID.delete(key)
|
||||||
|
const payload = liveTasks.get(announced)
|
||||||
|
liveTasks.delete(announced)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgetTaskKeys(part: any, sessionID: string) {
|
||||||
|
taskStartedAt.delete(callKey(sessionID, part.callID))
|
||||||
|
if (part.id) taskStartedAt.delete(callKey(sessionID, part.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopFailedTask fires subagent-stop for an announced task whose part
|
||||||
|
// ended in error without tool.execute.after: OpenCode skips that hook when
|
||||||
|
// the call is aborted (Esc, `opencode run` teardown) or its execute throws.
|
||||||
|
// A task that failed before its child was bound was never announced, so
|
||||||
|
// nothing fires for it.
|
||||||
|
function stopFailedTask(part: any) {
|
||||||
|
if (!(part?.type === "tool" && part.tool === "task" && part.callID &&
|
||||||
|
part.state?.status === "error")) return
|
||||||
|
const sessionID = part.sessionID ?? currentSessionID ?? ""
|
||||||
|
const key = callKey(sessionID, part.callID)
|
||||||
|
const partKey = callKey(sessionID, part.id)
|
||||||
|
const payload = takeLiveTask(key)
|
||||||
|
forgetTaskKeys(part, sessionID)
|
||||||
|
stoppedBeforeStart.delete(key)
|
||||||
|
if (part.id) {
|
||||||
|
partCallID.delete(partKey)
|
||||||
|
stoppedBeforeStart.delete(partKey)
|
||||||
|
}
|
||||||
|
if (!payload) return
|
||||||
|
stoppedOnError.add(key)
|
||||||
|
if (part.id) stoppedOnError.add(partKey)
|
||||||
|
callHookSync("subagent-stop", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishBackgroundTask fires the oldest held subagent-stop for a background
|
||||||
|
// child whose run went idle: its work finished, failed, or was aborted
|
||||||
|
// (`opencode run` exiting with the child still running). Each case ends the
|
||||||
|
// call; a later call queued on the same child ends at its own run's idle.
|
||||||
|
function finishBackgroundTask(childID: string) {
|
||||||
|
const payloads = backgroundTasks.get(childID)
|
||||||
|
const payload = payloads?.shift()
|
||||||
|
if (!payload) return
|
||||||
|
if (payloads.length === 0) backgroundTasks.delete(childID)
|
||||||
|
callHookSync("subagent-stop", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainBackgroundTasks fires every held subagent-stop for a child whose
|
||||||
|
// background job is over. A job that fails or is cancelled drops the runs
|
||||||
|
// still queued on it, so their calls get no idle of their own; left held,
|
||||||
|
// the next resume of the child would end them at its idle instead.
|
||||||
|
function drainBackgroundTasks(childID: string) {
|
||||||
|
const payloads = backgroundTasks.get(childID)
|
||||||
|
if (!payloads) return
|
||||||
|
backgroundTasks.delete(childID)
|
||||||
|
for (const payload of payloads) callHookSync("subagent-stop", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A background job's end reaches the session that launched it as a
|
||||||
|
// synthetic text part: <task id="<child>" state="completed|error">.
|
||||||
|
const backgroundResult = /^<task id="([^"]+)" state="(completed|error)">/
|
||||||
|
|
||||||
|
// backgroundSessionAborted ends the held calls of an aborted session and of
|
||||||
|
// the children it launched (Esc cancels its background jobs and reports no
|
||||||
|
// result): at once for an idle child, at its next idle for a busy one.
|
||||||
|
function backgroundSessionAborted(sessionID: string) {
|
||||||
|
const targets = [sessionID]
|
||||||
|
for (const [childID, parentID] of parentOf) if (parentID === sessionID) targets.push(childID)
|
||||||
|
for (const target of targets) {
|
||||||
|
if (!backgroundTasks.has(target)) continue
|
||||||
|
if (busySessions.has(target)) drainAtIdle.add(target)
|
||||||
|
else drainBackgroundTasks(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSessionTracking(sessionID: string) {
|
||||||
|
if (currentSessionID === sessionID) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seenUserMessages.clear()
|
||||||
|
messageStore.clear()
|
||||||
|
currentModel = null
|
||||||
|
currentSessionID = sessionID
|
||||||
|
// Drop any turn-start injection captured for the prior session so it
|
||||||
|
// can't leak into the new session's system prompt.
|
||||||
|
pendingInjection = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Apply the one-time Entire context injection captured at turn-start by
|
||||||
|
// appending it to the system prompt for this LLM call.
|
||||||
|
"experimental.chat.system.transform": async (input: { sessionID?: string }, output: { system: string[] }) => {
|
||||||
|
if (input?.sessionID && childSessions.has(input.sessionID)) return
|
||||||
|
if (pendingInjection && Array.isArray(output.system)) {
|
||||||
|
output.system.push(pendingInjection)
|
||||||
|
pendingInjection = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Subagent call start. No child ID exists yet, so this only records the
|
||||||
|
// time; subagent-start fires from the running task part below.
|
||||||
|
"tool.execute.before": async (input) => {
|
||||||
|
try {
|
||||||
|
if (input.tool !== "task") return
|
||||||
|
const key = callKey(input.sessionID, input.callID)
|
||||||
|
// A new call under this key: some providers number calls per
|
||||||
|
// conversation, so an earlier call's markers must not dedupe or
|
||||||
|
// swallow this one's start and stop.
|
||||||
|
announcedTasks.delete(key)
|
||||||
|
stoppedBeforeStart.delete(key)
|
||||||
|
stoppedOnError.delete(key)
|
||||||
|
taskStartedAt.set(key, Date.now())
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — plugin failures must not crash OpenCode
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Subagent completion. tool.execute.after for the task tool fires once, with
|
||||||
|
// the child session ID in output.metadata: at true completion for a
|
||||||
|
// foreground task, immediately for a background one (metadata.background),
|
||||||
|
// whose stop is held until the child goes idle. Synchronous: `opencode run`
|
||||||
|
// exits on the parent's idle right after this, and an async hook would be
|
||||||
|
// killed before it finished.
|
||||||
|
"tool.execute.after": async (input, output) => {
|
||||||
|
try {
|
||||||
|
if (input.tool !== "task") return
|
||||||
|
const key = callKey(input.sessionID, input.callID)
|
||||||
|
const announced = takeLiveTask(key)
|
||||||
|
if (stoppedOnError.delete(key) && !announced) {
|
||||||
|
// Already stopped: its part ended in error first.
|
||||||
|
taskStartedAt.delete(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A failed command subtask fires this hook with no output; its
|
||||||
|
// announced child still ends here.
|
||||||
|
const childID = output?.metadata?.sessionId ?? announced?.subagent_id
|
||||||
|
if (!childID) {
|
||||||
|
taskStartedAt.delete(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trackChild(childID, input.sessionID)
|
||||||
|
const startedAt = taskStartedAt.get(key) ?? 0
|
||||||
|
taskStartedAt.delete(key)
|
||||||
|
if (!announced) stoppedBeforeStart.add(key)
|
||||||
|
// A child's own task call (subagent_depth > 1) is recorded on the
|
||||||
|
// top-level session: the child has no Entire session of its own.
|
||||||
|
const payload = announced ?? {
|
||||||
|
session_id: topLevelSession(input.sessionID),
|
||||||
|
tool_use_id: input.callID,
|
||||||
|
subagent_id: childID,
|
||||||
|
subagent_type: input.args?.subagent_type ?? "",
|
||||||
|
task_description: input.args?.description ?? "",
|
||||||
|
started_at: startedAt,
|
||||||
|
}
|
||||||
|
if (output?.metadata?.background === true) {
|
||||||
|
backgroundTasks.set(childID, [...(backgroundTasks.get(childID) ?? []), payload])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callHookSync("subagent-stop", payload)
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — plugin failures must not crash OpenCode
|
||||||
|
}
|
||||||
|
},
|
||||||
|
event: async ({ event }) => {
|
||||||
|
try {
|
||||||
|
const props = (event as any).properties
|
||||||
|
const info = props?.info
|
||||||
|
if (event.type.startsWith("session.") && info?.parentID && info?.id) trackChild(info.id, info.parentID)
|
||||||
|
if (event.type === "message.part.updated") {
|
||||||
|
announceTask(props?.part)
|
||||||
|
stopFailedTask(props?.part)
|
||||||
|
const part = props?.part
|
||||||
|
// Only a part in a tracked session: synthetic text also carries
|
||||||
|
// attachment contents, which could spell the same envelope.
|
||||||
|
const sid = part?.sessionID
|
||||||
|
const tracked = sid === currentSessionID || childSessions.has(sid) || [...parentOf.values()].includes(sid)
|
||||||
|
if (tracked && part?.type === "text" && part.synthetic === true && typeof part.text === "string") {
|
||||||
|
const result = backgroundResult.exec(part.text)
|
||||||
|
if (result) drainBackgroundTasks(result[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An aborted message, reported on session.error, or on the message
|
||||||
|
// itself when the run was interrupted outside the processor.
|
||||||
|
if (event.type === "session.error" && props?.error?.name === "MessageAbortedError" && props?.sessionID) {
|
||||||
|
backgroundSessionAborted(props.sessionID)
|
||||||
|
}
|
||||||
|
if (event.type === "message.updated" && info?.error?.name === "MessageAbortedError" && info?.sessionID) {
|
||||||
|
backgroundSessionAborted(info.sessionID)
|
||||||
|
}
|
||||||
|
if (event.type === "session.status" && props?.sessionID) {
|
||||||
|
if (props?.status?.type !== "idle") busySessions.add(props.sessionID)
|
||||||
|
else if (busySessions.delete(props.sessionID)) {
|
||||||
|
if (drainAtIdle.delete(props.sessionID)) drainBackgroundTasks(props.sessionID)
|
||||||
|
else finishBackgroundTask(props.sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const eventSessionID: string | undefined =
|
||||||
|
props?.sessionID ?? info?.sessionID ?? info?.id ?? props?.part?.sessionID
|
||||||
|
if (eventSessionID && childSessions.has(eventSessionID)) return
|
||||||
|
switch (event.type) {
|
||||||
|
case "session.created": {
|
||||||
|
const session = info
|
||||||
|
if (!session?.id) break
|
||||||
|
// Reset per-session tracking state when switching sessions.
|
||||||
|
if (resetSessionTracking(session.id)) {
|
||||||
|
await callHook("session-start", {
|
||||||
|
session_id: session.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "message.updated": {
|
||||||
|
const msg = info
|
||||||
|
if (!msg) break
|
||||||
|
|
||||||
|
if (msg.sessionID && resetSessionTracking(msg.sessionID)) {
|
||||||
|
callHookSync("session-start", {
|
||||||
|
session_id: msg.sessionID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store message metadata (role, time, tokens, etc.)
|
||||||
|
messageStore.set(msg.id, msg)
|
||||||
|
// Track model from assistant messages
|
||||||
|
if (msg.role === "assistant" && msg.modelID) {
|
||||||
|
currentModel = msg.modelID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: some opencode run flows commit before any message.part.updated
|
||||||
|
// event is delivered for the user's prompt. Start the turn from the
|
||||||
|
// user message itself so git hooks see an ACTIVE session in time.
|
||||||
|
if (msg.role === "user" && !seenUserMessages.has(msg.id)) {
|
||||||
|
seenUserMessages.add(msg.id)
|
||||||
|
const sessionID = msg.sessionID ?? currentSessionID
|
||||||
|
if (sessionID) {
|
||||||
|
fireTurnStart({
|
||||||
|
session_id: sessionID,
|
||||||
|
prompt: "",
|
||||||
|
model: currentModel ?? "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "message.part.updated": {
|
||||||
|
const part = props?.part
|
||||||
|
if (!part?.messageID) break
|
||||||
|
|
||||||
|
// Fire turn-start on the first text part of a new user message
|
||||||
|
const msg = messageStore.get(part.messageID)
|
||||||
|
if (msg?.role === "user" && part.type === "text" && !seenUserMessages.has(msg.id)) {
|
||||||
|
seenUserMessages.add(msg.id)
|
||||||
|
const sessionID = msg.sessionID ?? currentSessionID
|
||||||
|
if (sessionID) {
|
||||||
|
// A synthetic part is OpenCode writing, not the user: a
|
||||||
|
// background task's result arrives this way. The parent does
|
||||||
|
// take a turn on it, so the turn still starts, with no prompt.
|
||||||
|
fireTurnStart({
|
||||||
|
session_id: sessionID,
|
||||||
|
prompt: part.synthetic === true ? "" : (part.text ?? ""),
|
||||||
|
model: currentModel ?? "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "session.status": {
|
||||||
|
// session.status fires in both TUI and non-interactive (run) mode.
|
||||||
|
// session.idle is deprecated and not reliably emitted in run mode.
|
||||||
|
if (props?.status?.type !== "idle") break
|
||||||
|
const sessionID = props?.sessionID ?? currentSessionID
|
||||||
|
if (!sessionID) break
|
||||||
|
// Use sync variant: `opencode run` exits on the same idle event,
|
||||||
|
// so an async hook would be killed before completing.
|
||||||
|
callHookSync("turn-end", {
|
||||||
|
session_id: sessionID,
|
||||||
|
model: currentModel ?? "",
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "session.compacted": {
|
||||||
|
const sessionID = props?.sessionID
|
||||||
|
if (!sessionID) break
|
||||||
|
await callHook("compaction", {
|
||||||
|
session_id: sessionID,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "session.deleted": {
|
||||||
|
const session = info
|
||||||
|
if (!session?.id) break
|
||||||
|
seenUserMessages.clear()
|
||||||
|
messageStore.clear()
|
||||||
|
currentSessionID = null
|
||||||
|
pendingInjection = null
|
||||||
|
// childSessions/announcedTasks are NOT cleared here: a child's own
|
||||||
|
// deletion never reaches this case (the guard above returns first),
|
||||||
|
// so this only ever fires for an unrelated top-level session, and
|
||||||
|
// clearing here would drop a live child back to top-level mid-task.
|
||||||
|
// Use sync variant: session-end may fire during shutdown.
|
||||||
|
callHookSync("session-end", {
|
||||||
|
session_id: session.id,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "server.instance.disposed": {
|
||||||
|
// Fires when OpenCode shuts down (TUI close or `opencode run` exit).
|
||||||
|
// session.deleted only fires on explicit user deletion, not on quit,
|
||||||
|
// so this is the only reliable way to end sessions on exit.
|
||||||
|
if (!currentSessionID) break
|
||||||
|
const sessionID = currentSessionID
|
||||||
|
seenUserMessages.clear()
|
||||||
|
messageStore.clear()
|
||||||
|
currentSessionID = null
|
||||||
|
pendingInjection = null
|
||||||
|
childSessions.clear()
|
||||||
|
rootOf.clear()
|
||||||
|
parentOf.clear()
|
||||||
|
announcedTasks.clear()
|
||||||
|
taskStartedAt.clear()
|
||||||
|
liveTasks.clear()
|
||||||
|
partCallID.clear()
|
||||||
|
stoppedBeforeStart.clear()
|
||||||
|
stoppedOnError.clear()
|
||||||
|
// A background child still running here is ended by the Go side's
|
||||||
|
// SessionEnd sweep, which completes every live task record.
|
||||||
|
backgroundTasks.clear()
|
||||||
|
busySessions.clear()
|
||||||
|
drainAtIdle.clear()
|
||||||
|
// Use sync variant: this is the last event before process exit.
|
||||||
|
callHookSync("session-end", {
|
||||||
|
session_id: sessionID,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — plugin failures must not crash OpenCode
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,8 +35,7 @@ disclosure once a fix is available.
|
|||||||
exposure variant's policy is required, not optional: an exposure without it
|
exposure variant's policy is required, not optional: an exposure without it
|
||||||
is demo-only. Run `examples/dsh-agents-as-a-service/scripts/preflight-sso.sh`
|
is demo-only. Run `examples/dsh-agents-as-a-service/scripts/preflight-sso.sh`
|
||||||
after applying the variant; it checks the Secrets, gateway env, TLS
|
after applying the variant; it checks the Secrets, gateway env, TLS
|
||||||
reference, leftover placeholders, accidental second paths, the gateway
|
reference, leftover placeholders and accidental second paths.
|
||||||
Role's `patch` verb on `sandboxclaims` and the variant NetworkPolicies.
|
|
||||||
- **Insecure-cookie guard.** TLS terminates outside the gateway. Set
|
- **Insecure-cookie guard.** TLS terminates outside the gateway. Set
|
||||||
COOKIE_SECURE=true for HTTPS and remove ALLOW_INSECURE_COOKIES; the gateway
|
COOKIE_SECURE=true for HTTPS and remove ALLOW_INSECURE_COOKIES; the gateway
|
||||||
refuses to start with COOKIE_SECURE=false unless ALLOW_INSECURE_COOKIES=1 is
|
refuses to start with COOKIE_SECURE=false unless ALLOW_INSECURE_COOKIES=1 is
|
||||||
|
|||||||
@@ -518,10 +518,8 @@ front of **oauth2-proxy**, which authenticates the browser against GitHub
|
|||||||
reports **before** pointing real traffic at the Gateway. It verifies the
|
reports **before** pointing real traffic at the Gateway. It verifies the
|
||||||
Secrets (including a valid oauth2-proxy cookie secret length), the gateway's
|
Secrets (including a valid oauth2-proxy cookie secret length), the gateway's
|
||||||
`AUTH_PROXY_HEADER`/`COOKIE_SECURE`, that the Gateway's TLS certificate
|
`AUTH_PROXY_HEADER`/`COOKIE_SECURE`, that the Gateway's TLS certificate
|
||||||
reference resolves, that the gateway ServiceAccount may `patch`
|
reference resolves, and that no `your-github-org`/`dsh-demo.example.com`
|
||||||
`sandboxclaims` (SSO token rotation), that the variant's NetworkPolicies are
|
placeholder was left applied. Full verification steps are in the
|
||||||
applied, and that no `your-github-org`/`dsh-demo.example.com` placeholder was
|
|
||||||
left applied. Full verification steps are in the
|
|
||||||
[exposure README](manifests/exposure/README.md).
|
[exposure README](manifests/exposure/README.md).
|
||||||
|
|
||||||
> **Exposure without the variant NetworkPolicy is demo-only.** The SSO
|
> **Exposure without the variant NetworkPolicy is demo-only.** The SSO
|
||||||
@@ -601,7 +599,7 @@ applied by the base step.
|
|||||||
| Every browser is logged out after a gateway rollout/restart | Expected: sessions are opaque ids in gateway memory and a restart drops them. Sign in again; a production deployment needs shared session state or per-request re-authentication. The DSH sandboxes and their data are unaffected |
|
| Every browser is logged out after a gateway rollout/restart | Expected: sessions are opaque ids in gateway memory and a restart drops them. Sign in again; a production deployment needs shared session state or per-request re-authentication. The DSH sandboxes and their data are unaffected |
|
||||||
| WebSocket closes with `1011` for a busy client | `MAX_IN_FLIGHT_PER_USER` (per-user connection cap) or an upstream/handshake failure closed the socket; check the gateway logs to tell them apart |
|
| WebSocket closes with `1011` for a busy client | `MAX_IN_FLIGHT_PER_USER` (per-user connection cap) or an upstream/handshake failure closed the socket; check the gateway logs to tell them apart |
|
||||||
| WebSocket closes after a very large message from DSH | A message received from DSH exceeded `WS_MAX_MESSAGE_BYTES` (16 MiB), so the relay closed it instead of buffering; raise the limit only if DSH legitimately sends larger frames. Browser-to-DSH frames are bounded by uvicorn's own limit |
|
| WebSocket closes after a very large message from DSH | A message received from DSH exceeded `WS_MAX_MESSAGE_BYTES` (16 MiB), so the relay closed it instead of buffering; raise the limit only if DSH legitimately sends larger frames. Browser-to-DSH frames are bounded by uvicorn's own limit |
|
||||||
| `scripts/preflight-sso.sh` exits non-zero | It names the failing item: a missing Secret/key, a bad oauth2-proxy cookie-secret length, missing `AUTH_PROXY_HEADER`/`COOKIE_SECURE`, a dangling Gateway TLS reference, a leftover `your-github-org`/`dsh-demo.example.com` placeholder, a second path to the gateway (an Ingress), a gateway Role without `patch` on `sandboxclaims`, or a missing SSO NetworkPolicy. Fix all items before exposing the SSO variant |
|
| `scripts/preflight-sso.sh` exits non-zero | It names the failing item: a missing Secret/key, a bad oauth2-proxy cookie-secret length, missing `AUTH_PROXY_HEADER`/`COOKIE_SECURE`, a dangling Gateway TLS reference, a leftover `your-github-org`/`dsh-demo.example.com` placeholder, or a second path to the gateway (an Ingress). Fix all items before exposing the SSO variant |
|
||||||
| `409` on signup | User already has a claim; use `POST /login` with the existing token or delete the user first |
|
| `409` on signup | User already has a claim; use `POST /login` with the existing token or delete the user first |
|
||||||
| Cookie set but browser keeps getting `401` | `COOKIE_SECURE=true` while serving over plain HTTP (secure cookies are not sent); use HTTPS or set `false` **and** `ALLOW_INSECURE_COOKIES=1` for local demos |
|
| Cookie set but browser keeps getting `401` | `COOKIE_SECURE=true` while serving over plain HTTP (secure cookies are not sent); use HTTPS or set `false` **and** `ALLOW_INSECURE_COOKIES=1` for local demos |
|
||||||
| WebSocket closes immediately (`4401`) | Missing/expired gateway cookie, or the upgrade's `Origin` does not match `Host` (CSWSH check); DSH rejecting `Host` instead surfaces as close `1011` — add the host to `DSH_TRUSTED_HOSTS` |
|
| WebSocket closes immediately (`4401`) | Missing/expired gateway cookie, or the upgrade's `Origin` does not match `Host` (CSWSH check); DSH rejecting `Host` instead surfaces as close `1011` — add the host to `DSH_TRUSTED_HOSTS` |
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ The gateway runs with a namespace-scoped ServiceAccount (`manifests/50-gateway.y
|
|||||||
|
|
||||||
| API group | Resource | Verbs | Why |
|
| API group | Resource | Verbs | Why |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `extensions.agents.x-k8s.io` | `sandboxclaims` | `get`, `list`, `create`, `patch`, `delete` | signup, state, account deletion, token rotation |
|
| `extensions.agents.x-k8s.io` | `sandboxclaims` | `get`, `list`, `create`, `delete` | signup, state, account deletion |
|
||||||
| `agents.x-k8s.io` | `sandboxes` | `get`, `list`, `patch` | resolve DNS/pod, wake/suspend |
|
| `agents.x-k8s.io` | `sandboxes` | `get`, `list`, `patch` | resolve DNS/pod, wake/suspend |
|
||||||
| `""` (core) | `pods` | `get`, `list` | resolve the pod behind a sandbox |
|
| `""` (core) | `pods` | `get`, `list` | resolve the pod behind a sandbox |
|
||||||
| `""` (core) | `pods/log` | `get` | launch-token handshake |
|
| `""` (core) | `pods/log` | `get` | launch-token handshake |
|
||||||
@@ -395,8 +395,7 @@ Notes and limits:
|
|||||||
- **SSO is trust-on-hop.** `AUTH_PROXY_HEADER` makes the gateway trust the proxy-asserted
|
- **SSO is trust-on-hop.** `AUTH_PROXY_HEADER` makes the gateway trust the proxy-asserted
|
||||||
identity; the SSO variant must remain the only path to the Service, and its NetworkPolicy is
|
identity; the SSO variant must remain the only path to the Service, and its NetworkPolicy is
|
||||||
required, not optional. Run [`scripts/preflight-sso.sh`](../scripts/preflight-sso.sh) after
|
required, not optional. Run [`scripts/preflight-sso.sh`](../scripts/preflight-sso.sh) after
|
||||||
applying it: it fails when the Role does not grant `patch` on `sandboxclaims` (SSO token
|
applying it.
|
||||||
rotation) or when the variant's NetworkPolicies are missing.
|
|
||||||
|
|
||||||
## Failure modes
|
## Failure modes
|
||||||
|
|
||||||
@@ -405,7 +404,6 @@ Notes and limits:
|
|||||||
| DSH log line changed or rotated away | Proxy returns `503` (missing launch token) | Fix the parser or restart the pod so the token line is re-emitted |
|
| DSH log line changed or rotated away | Proxy returns `503` (missing launch token) | Fix the parser or restart the pod so the token line is re-emitted |
|
||||||
| Probe on `/` | Pod `CrashLoopBackOff`, `401` in probe logs | Use `/favicon.svg` for readiness/liveness (shipped templates do) |
|
| Probe on `/` | Pod `CrashLoopBackOff`, `401` in probe logs | Use `/favicon.svg` for readiness/liveness (shipped templates do) |
|
||||||
| Warm pool exhausted | JSON signup returns `202` (state `Waking`); form signup is slow | Increase `replicas` in the warm pool |
|
| Warm pool exhausted | JSON signup returns `202` (state `Waking`); form signup is slow | Increase `replicas` in the warm pool |
|
||||||
| Kubernetes API error (API down or throttled, missing RBAC verb) | HTTP `503` + `Retry-After`; WebSocket close `1011` | One credential-free log line with the API `status`/`reason`, plus a fixed body; the Kubernetes `Status` payload is never proxied. Check the ServiceAccount Role and the API server — RBAC is evaluated per request, so a Role fix needs no restart |
|
|
||||||
| Gateway restart | Idle sandboxes stop suspending; handshake cache and browser sessions lost | Idle clocks and handshake cache rebuild lazily; sessions do not, so browsers must sign in again after a rollout. Use a shared session store for production |
|
| Gateway restart | Idle sandboxes stop suspending; handshake cache and browser sessions lost | Idle clocks and handshake cache rebuild lazily; sessions do not, so browsers must sign in again after a rollout. Use a shared session store for production |
|
||||||
| Gateway configured `COOKIE_SECURE=false` without `ALLOW_INSECURE_COOKIES=1` | Gateway pod exits at startup / `CrashLoopBackOff` | Intentional guard against an insecure cookie on an HTTPS deployment; set `ALLOW_INSECURE_COOKIES=1` only for the local plain-HTTP demo, or set `COOKIE_SECURE=true` |
|
| Gateway configured `COOKIE_SECURE=false` without `ALLOW_INSECURE_COOKIES=1` | Gateway pod exits at startup / `CrashLoopBackOff` | Intentional guard against an insecure cookie on an HTTPS deployment; set `ALLOW_INSECURE_COOKIES=1` only for the local plain-HTTP demo, or set `COOKIE_SECURE=true` |
|
||||||
| Auth rate limit hit | `429` + `Retry-After` on signup/login | Per-client in-memory bucket (`AUTH_RATE_LIMIT`/`AUTH_RATE_WINDOW`); wait for the window or tune it. Behind a proxy, keying uses the rightmost `X-Forwarded-For` entry |
|
| Auth rate limit hit | `429` + `Retry-After` on signup/login | Per-client in-memory bucket (`AUTH_RATE_LIMIT`/`AUTH_RATE_WINDOW`); wait for the window or tune it. Behind a proxy, keying uses the rightmost `X-Forwarded-For` entry |
|
||||||
@@ -413,7 +411,7 @@ Notes and limits:
|
|||||||
| Per-user concurrency cap hit | HTTP `503` + `Retry-After`, or WebSocket close `1011` | `MAX_IN_FLIGHT_PER_USER` protects the single gateway from one busy tenant; reduce concurrency or raise the cap deliberately. These are per replica |
|
| Per-user concurrency cap hit | HTTP `503` + `Retry-After`, or WebSocket close `1011` | `MAX_IN_FLIGHT_PER_USER` protects the single gateway from one busy tenant; reduce concurrency or raise the cap deliberately. These are per replica |
|
||||||
| Oversized WebSocket message from DSH | Connection closes | `WS_MAX_MESSAGE_BYTES` (16 MiB) is enforced on upstream messages; raise it only if DSH legitimately needs larger frames |
|
| Oversized WebSocket message from DSH | Connection closes | `WS_MAX_MESSAGE_BYTES` (16 MiB) is enforced on upstream messages; raise it only if DSH legitimately needs larger frames |
|
||||||
| Session expiry / revocation | `401` (HTTP) or close `4401` (WebSocket) | Expected after logout, user delete, `/recreate`, `COOKIE_MAX_AGE` expiry or a gateway restart; sign in again |
|
| Session expiry / revocation | `401` (HTTP) or close `4401` (WebSocket) | Expected after logout, user delete, `/recreate`, `COOKIE_MAX_AGE` expiry or a gateway restart; sign in again |
|
||||||
| SSO variant applied without its NetworkPolicy (or with an Ingress) | Gateway accepts spoofed `X-Forwarded-User` | Demo-only configuration; keep `75-networkpolicies.yaml`, remove the second path, and run `scripts/preflight-sso.sh`, which fails when either policy is missing |
|
| SSO variant applied without its NetworkPolicy (or with an Ingress) | Gateway accepts spoofed `X-Forwarded-User` | Demo-only configuration; keep `75-networkpolicies.yaml`, remove the second path, and run `scripts/preflight-sso.sh` |
|
||||||
| Browser aborts a request (tab reload, back navigation) during a slow wake or handshake | No response reaches the client; nothing appears in the access log | If the abort lands before the response starts, the body read raises `ClientDisconnect` and the app answers `499` (`CLIENT_CLOSED_REQUEST`), which cannot reach the departed peer. Nothing reached DSH, no ERROR traceback is logged, and the in-flight slot is released. A request already streaming ends with its upstream status. |
|
| Browser aborts a request (tab reload, back navigation) during a slow wake or handshake | No response reaches the client; nothing appears in the access log | If the abort lands before the response starts, the body read raises `ClientDisconnect` and the app answers `499` (`CLIENT_CLOSED_REQUEST`), which cannot reach the departed peer. Nothing reached DSH, no ERROR traceback is logged, and the in-flight slot is released. A request already streaming ends with its upstream status. |
|
||||||
| DSH auth cookie expired mid-session | Proxy `401` once, then recovers | Gateway refreshes the handshake once |
|
| DSH auth cookie expired mid-session | Proxy `401` once, then recovers | Gateway refreshes the handshake once |
|
||||||
| Upstream pod name changed after resume | Cached cookie keyed by old pod UID is useless | Cache is invalidated on suspend; next request re-handshakes |
|
| Upstream pod name changed after resume | Cached cookie keyed by old pod UID is useless | Cache is invalidated on suspend; next request re-handshakes |
|
||||||
|
|||||||
@@ -338,10 +338,8 @@ The SSO policy is what turns the trust boundary in the
|
|||||||
The `dsh-gateway` Service must still stay ClusterIP; do not apply both exposure
|
The `dsh-gateway` Service must still stay ClusterIP; do not apply both exposure
|
||||||
variants. After applying the SSO variant, run
|
variants. After applying the SSO variant, run
|
||||||
[`scripts/preflight-sso.sh`](../scripts/preflight-sso.sh): it checks the
|
[`scripts/preflight-sso.sh`](../scripts/preflight-sso.sh): it checks the
|
||||||
gateway's SSO env, the Secrets, the TLS reference, leftover placeholders,
|
gateway's SSO env, the Secrets, the TLS reference, leftover placeholders and
|
||||||
accidental second paths (such as an Ingress), the gateway Role's `patch` verb on
|
accidental second paths (such as an Ingress).
|
||||||
`sandboxclaims` (SSO token rotation) and that both SSO NetworkPolicies are
|
|
||||||
applied.
|
|
||||||
|
|
||||||
Verify the negative case from a sandbox pod (it is not the proxy):
|
Verify the negative case from a sandbox pod (it is not the proxy):
|
||||||
|
|
||||||
|
|||||||
@@ -175,11 +175,9 @@ oauth2-proxy's egress to the gateway, DNS and public HTTPS.
|
|||||||
| Sandbox not provisioned, wake timeout, handshake failure | `503` + `Retry-After: 10`. The handshake error body is a fixed message: it never contains the launch token, the handshake URL or pod/credential details. |
|
| Sandbox not provisioned, wake timeout, handshake failure | `503` + `Retry-After: 10`. The handshake error body is a fixed message: it never contains the launch token, the handshake URL or pod/credential details. |
|
||||||
| User over `MAX_IN_FLIGHT_PER_USER` | `503` + `Retry-After` (HTTP); the existing in-flight requests are untouched |
|
| User over `MAX_IN_FLIGHT_PER_USER` | `503` + `Retry-After` (HTTP); the existing in-flight requests are untouched |
|
||||||
| Upstream connection/protocol failure | `502` |
|
| Upstream connection/protocol failure | `502` |
|
||||||
| Kubernetes API error (API unreachable/throttled, RBAC `403`, `5xx`) | `503` + `Retry-After: 10`. The body is a fixed message; the Kubernetes `Status` payload (which can name cluster objects) is never proxied, and the API `status`/`reason` is logged once |
|
|
||||||
| WS auth failure (unknown user, bad token, cross-origin) | accept, then close `4401` |
|
| WS auth failure (unknown user, bad token, cross-origin) | accept, then close `4401` |
|
||||||
| WS per-user concurrency cap | accept, then close `1011` |
|
| WS per-user concurrency cap | accept, then close `1011` |
|
||||||
| WS upstream/handshake failure | accept, then close `1011` |
|
| WS upstream/handshake failure | accept, then close `1011` |
|
||||||
| WS Kubernetes API error | accept, then close `1011` |
|
|
||||||
| WS message from DSH over `WS_MAX_MESSAGE_BYTES` | the upstream/relay closes the connection (websockets' message-too-big path) |
|
| WS message from DSH over `WS_MAX_MESSAGE_BYTES` | the upstream/relay closes the connection (websockets' message-too-big path) |
|
||||||
|
|
||||||
A close code can only be delivered after the WebSocket upgrade is accepted;
|
A close code can only be delivered after the WebSocket upgrade is accepted;
|
||||||
@@ -232,8 +230,7 @@ would never be sent). HTTPS deployments must set `COOKIE_SECURE=true`.
|
|||||||
|
|
||||||
The gateway's ServiceAccount needs, in its namespace:
|
The gateway's ServiceAccount needs, in its namespace:
|
||||||
|
|
||||||
* `sandboxclaims` (`extensions.agents.x-k8s.io`): get, list, create, patch, delete
|
* `sandboxclaims` (`extensions.agents.x-k8s.io`): get, list, create, delete
|
||||||
(patch rotates the token-hash annotation when a session is minted)
|
|
||||||
* `sandboxes` (`agents.x-k8s.io`): get, list, patch
|
* `sandboxes` (`agents.x-k8s.io`): get, list, patch
|
||||||
* `pods`: get, list
|
* `pods`: get, list
|
||||||
* `pods/log`: get
|
* `pods/log`: get
|
||||||
|
|||||||
@@ -1510,13 +1510,7 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
|
|||||||
user = websocket.path_params["user"]
|
user = websocket.path_params["user"]
|
||||||
if not valid_user(user):
|
if not valid_user(user):
|
||||||
return await self._ws_reject(websocket, 4401)
|
return await self._ws_reject(websocket, 4401)
|
||||||
try:
|
claim = await self.k8s.get_claim(user)
|
||||||
claim = await self.k8s.get_claim(user)
|
|
||||||
except ApiException:
|
|
||||||
# Starlette has no exception handler for WebSocket scopes, so an
|
|
||||||
# unhandled API error would escape as a traceback and an unclean
|
|
||||||
# close. 1011 keeps the HTTP 503 meaning on the WS channel.
|
|
||||||
return await self._ws_reject(websocket, 1011)
|
|
||||||
if claim is None:
|
if claim is None:
|
||||||
return await self._ws_reject(websocket, 4401)
|
return await self._ws_reject(websocket, 4401)
|
||||||
token, cookie_user = self.credentials(websocket.headers, websocket.cookies)
|
token, cookie_user = self.credentials(websocket.headers, websocket.cookies)
|
||||||
@@ -1752,28 +1746,6 @@ async def client_gone(request: Request, exc: ClientDisconnect) -> Response:
|
|||||||
return Response(status_code=CLIENT_CLOSED_REQUEST)
|
return Response(status_code=CLIENT_CLOSED_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
async def k8s_api_error(request: Request, exc: ApiException) -> Response:
|
|
||||||
"""Answer a Kubernetes API failure with 503 instead of a bare 500.
|
|
||||||
|
|
||||||
The gateway is the only path between the browser and the cluster, so an
|
|
||||||
API outage, a throttle or a missing RBAC verb is a server-side
|
|
||||||
availability problem, not a client error. A 503 keeps ingress and
|
|
||||||
browser retry semantics honest, and the body is a fixed message: the
|
|
||||||
Kubernetes ``Status`` payload can name cluster objects and is never
|
|
||||||
proxied. The API ``status``/``reason`` is logged once, credential-free.
|
|
||||||
"""
|
|
||||||
print(
|
|
||||||
"gateway: kubernetes API error: "
|
|
||||||
f"status={getattr(exc, 'status', None)} reason={getattr(exc, 'reason', None)}",
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
return JSONResponse(
|
|
||||||
{"error": "cluster API unavailable, retry shortly"},
|
|
||||||
status_code=503,
|
|
||||||
headers={"Retry-After": "10"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_app(
|
def create_app(
|
||||||
settings: Settings | None = None,
|
settings: Settings | None = None,
|
||||||
k8s=None,
|
k8s=None,
|
||||||
@@ -1806,7 +1778,7 @@ def create_app(
|
|||||||
]
|
]
|
||||||
app = Starlette(
|
app = Starlette(
|
||||||
routes=routes,
|
routes=routes,
|
||||||
exception_handlers={ClientDisconnect: client_gone, ApiException: k8s_api_error},
|
exception_handlers={ClientDisconnect: client_gone},
|
||||||
lifespan=_lifespan(gateway, http_client if owns_http_client else None),
|
lifespan=_lifespan(gateway, http_client if owns_http_client else None),
|
||||||
)
|
)
|
||||||
app.state.gateway = gateway
|
app.state.gateway = gateway
|
||||||
|
|||||||
@@ -1176,14 +1176,12 @@ async def test_delete_missing_claim_is_404(env):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_unexpected_api_error_returns_503(env):
|
async def test_unexpected_api_error_propagates(env):
|
||||||
add_user(env.k8s)
|
add_user(env.k8s)
|
||||||
env.k8s.get_claim_error = ApiException(status=500)
|
env.k8s.get_claim_error = ApiException(status=500)
|
||||||
async with asgi_client(env) as client:
|
async with asgi_client(env) as client:
|
||||||
response = await client.get("/users/alice")
|
with pytest.raises(ApiException):
|
||||||
assert response.status_code == 503
|
await client.get("/users/alice")
|
||||||
assert response.headers["retry-after"] == "10"
|
|
||||||
assert response.json() == {"error": "cluster API unavailable, retry shortly"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -1833,16 +1831,6 @@ def test_ws_unauthenticated_closes_4401(env):
|
|||||||
assert exc.value.code == 4401
|
assert exc.value.code == 4401
|
||||||
|
|
||||||
|
|
||||||
def test_ws_kubernetes_api_error_closes_1011(env):
|
|
||||||
add_user(env.k8s)
|
|
||||||
env.k8s.get_claim_error = ApiException(status=503)
|
|
||||||
with TestClient(env.app) as client:
|
|
||||||
with client.websocket_connect("/u/alice/api/remote.mux") as ws:
|
|
||||||
with pytest.raises(WebSocketDisconnect) as exc:
|
|
||||||
ws.receive_text()
|
|
||||||
assert exc.value.code == 1011
|
|
||||||
|
|
||||||
|
|
||||||
def test_ws_cookie_cross_origin_closes_4401(env):
|
def test_ws_cookie_cross_origin_closes_4401(env):
|
||||||
add_user(env.k8s)
|
add_user(env.k8s)
|
||||||
with TestClient(env.app) as client:
|
with TestClient(env.app) as client:
|
||||||
@@ -2161,19 +2149,6 @@ def test_remote_user_returns_valid_and_rejects_invalid(env):
|
|||||||
assert g.remote_user({}) is None
|
assert g.remote_user({}) is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sso_landing_returns_503_when_claim_patch_is_forbidden(env):
|
|
||||||
"""A missing RBAC verb on ``patch sandboxclaims`` used to escape as a 500."""
|
|
||||||
enable_sso(env)
|
|
||||||
env.k8s.claims["alice"] = {"metadata": {"name": claim_name("alice")}}
|
|
||||||
env.k8s.patch_error = ApiException(status=403)
|
|
||||||
async with asgi_client(env) as client:
|
|
||||||
response = await client.get("/", headers={"X-Forwarded-User": "alice"})
|
|
||||||
assert response.status_code == 503
|
|
||||||
assert response.headers["retry-after"] == "10"
|
|
||||||
assert response.json() == {"error": "cluster API unavailable, retry shortly"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sso_landing_new_user_shows_provisioning(env):
|
async def test_sso_landing_new_user_shows_provisioning(env):
|
||||||
enable_sso(env)
|
enable_sso(env)
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ metadata:
|
|||||||
namespace: dsh-demo
|
namespace: dsh-demo
|
||||||
---
|
---
|
||||||
# Least privilege:
|
# Least privilege:
|
||||||
# - claims are created/deleted per user (signup/delete) and patched only to
|
# - claims are created/deleted per user (signup/delete);
|
||||||
# rotate the token-hash annotation when a session is minted;
|
|
||||||
# - sandboxes are read, and patched only to flip operatingMode (suspend/resume);
|
# - sandboxes are read, and patched only to flip operatingMode (suspend/resume);
|
||||||
# - pods are read, and pods/log is read to capture DSH's launch token from
|
# - pods are read, and pods/log is read to capture DSH's launch token from
|
||||||
# the startup log line (`dsh web: ...?token=...`).
|
# the startup log line (`dsh web: ...?token=...`).
|
||||||
@@ -40,7 +39,7 @@ metadata:
|
|||||||
rules:
|
rules:
|
||||||
- apiGroups: ["extensions.agents.x-k8s.io"]
|
- apiGroups: ["extensions.agents.x-k8s.io"]
|
||||||
resources: ["sandboxclaims"]
|
resources: ["sandboxclaims"]
|
||||||
verbs: ["get", "list", "create", "patch", "delete"]
|
verbs: ["get", "list", "create", "delete"]
|
||||||
- apiGroups: ["agents.x-k8s.io"]
|
- apiGroups: ["agents.x-k8s.io"]
|
||||||
resources: ["sandboxes"]
|
resources: ["sandboxes"]
|
||||||
verbs: ["get", "list", "patch"]
|
verbs: ["get", "list", "patch"]
|
||||||
|
|||||||
@@ -139,15 +139,13 @@ kubectl apply -f manifests/exposure/gateway-api-sso/
|
|||||||
|
|
||||||
# 5. Preflight: fails loudly on missing Secrets, a bad oauth2-proxy cookie
|
# 5. Preflight: fails loudly on missing Secrets, a bad oauth2-proxy cookie
|
||||||
# secret, missing gateway SSO env, a dangling TLS reference, leftover
|
# secret, missing gateway SSO env, a dangling TLS reference, leftover
|
||||||
# placeholders, a second path to the gateway, a Role without `patch` on
|
# placeholders, or a second path to the gateway.
|
||||||
# sandboxclaims, or missing SSO NetworkPolicies.
|
|
||||||
./scripts/preflight-sso.sh
|
./scripts/preflight-sso.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The preflight is read-only and exits non-zero on any failure, naming the exact
|
The preflight is read-only and exits non-zero on any failure, naming the exact
|
||||||
fix. Run it again after rotating a Secret or changing the public host; it also
|
fix. Run it again after rotating a Secret or changing the public host; it also
|
||||||
verifies that the oauth2-proxy cookie secret is a valid AES key length (16, 24
|
verifies that the oauth2-proxy cookie secret decodes to 16, 24 or 32 bytes.
|
||||||
or 32 raw bytes, or URL-safe unpadded base64 that decodes to one of those).
|
|
||||||
|
|
||||||
### SSO trust boundary
|
### SSO trust boundary
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# REQUIRED with the SSO exposure variant: enforce the trust boundary at the
|
# REQUIRED with the SSO exposure variant: enforce the trust boundary at the
|
||||||
# network layer. Applying the SSO variant without this file is demo-only.
|
# network layer. Applying the SSO variant without this file is demo-only.
|
||||||
# `scripts/preflight-sso.sh` fails while either policy below is missing.
|
|
||||||
#
|
#
|
||||||
# The chain is Traefik (Gateway API) -> oauth2-proxy -> dsh-gateway. These
|
# The chain is Traefik (Gateway API) -> oauth2-proxy -> dsh-gateway. These
|
||||||
# policies make it the ONLY chain:
|
# policies make it the ONLY chain:
|
||||||
|
|||||||
@@ -20,14 +20,7 @@
|
|||||||
# applied oauth2-proxy args, Gateway listeners or HTTPRoute hostnames;
|
# applied oauth2-proxy args, Gateway listeners or HTTPRoute hostnames;
|
||||||
# 6. (warning) no Ingress in the namespace creates a second path to
|
# 6. (warning) no Ingress in the namespace creates a second path to
|
||||||
# dsh-gateway. With SSO enabled the Service must be reachable only through
|
# dsh-gateway. With SSO enabled the Service must be reachable only through
|
||||||
# oauth2-proxy;
|
# oauth2-proxy.
|
||||||
# 7. the gateway ServiceAccount may `patch` sandboxclaims. The SSO landing
|
|
||||||
# path rotates the token-hash annotation whenever the browser has no valid
|
|
||||||
# gateway session (first visit, expired/foreign cookie, gateway restart),
|
|
||||||
# so without that verb every such sign-in answers 503;
|
|
||||||
# 8. the SSO variant's required NetworkPolicies are applied. Without them any
|
|
||||||
# Pod in the cluster can reach dsh-gateway:8080 and spoof the identity
|
|
||||||
# header, so the exposure is demo-only.
|
|
||||||
#
|
#
|
||||||
# Exit status: 0 when every check passes (warnings allowed), 1 on any failure.
|
# Exit status: 0 when every check passes (warnings allowed), 1 on any failure.
|
||||||
# Every failure prints the fix. This script only reads cluster state.
|
# Every failure prints the fix. This script only reads cluster state.
|
||||||
@@ -42,9 +35,6 @@
|
|||||||
# OAUTH_SECRET oauth2-proxy Secret name (default: oauth2-proxy)
|
# OAUTH_SECRET oauth2-proxy Secret name (default: oauth2-proxy)
|
||||||
# TLS_SECRET TLS Secret name the Gateway should reference (default:
|
# TLS_SECRET TLS Secret name the Gateway should reference (default:
|
||||||
# dsh-demo-tls)
|
# dsh-demo-tls)
|
||||||
# GATEWAY_ROLE Role/RoleBinding name that grants the gateway access
|
|
||||||
# (default: dsh-gateway; only used when impersonation is
|
|
||||||
# unavailable and the permission must be read statically)
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
@@ -53,7 +43,6 @@ GATEWAY_DEPLOY="${GATEWAY_DEPLOY:-dsh-gateway}"
|
|||||||
OAUTH_DEPLOY="${OAUTH_DEPLOY:-oauth2-proxy}"
|
OAUTH_DEPLOY="${OAUTH_DEPLOY:-oauth2-proxy}"
|
||||||
OAUTH_SECRET="${OAUTH_SECRET:-oauth2-proxy}"
|
OAUTH_SECRET="${OAUTH_SECRET:-oauth2-proxy}"
|
||||||
TLS_SECRET="${TLS_SECRET:-dsh-demo-tls}"
|
TLS_SECRET="${TLS_SECRET:-dsh-demo-tls}"
|
||||||
GATEWAY_ROLE="${GATEWAY_ROLE:-dsh-gateway}"
|
|
||||||
|
|
||||||
FAILED=0
|
FAILED=0
|
||||||
pass() { printf 'PASS %s\n' "$*"; }
|
pass() { printf 'PASS %s\n' "$*"; }
|
||||||
@@ -94,38 +83,21 @@ check_secret_key "$OAUTH_SECRET" client-secret \
|
|||||||
check_secret_key "$OAUTH_SECRET" cookie-secret \
|
check_secret_key "$OAUTH_SECRET" cookie-secret \
|
||||||
"Create it from manifests/exposure/gateway-api-sso/73-oauth2-proxy-secret.yaml.example."
|
"Create it from manifests/exposure/gateway-api-sso/73-oauth2-proxy-secret.yaml.example."
|
||||||
|
|
||||||
# oauth2-proxy accepts the cookie secret either as raw bytes of length 16, 24
|
# The cookie secret must be URL-safe unpadded base64 of 16, 24 or 32 bytes;
|
||||||
# or 32, or as URL-safe unpadded base64 that decodes to one of those lengths
|
# oauth2-proxy refuses anything else (the classic error is "44 bytes").
|
||||||
# (pkg/encryption.SecretBytes). The documented generator,
|
|
||||||
# `python3 -c 'import secrets; print(secrets.token_urlsafe(32))'`, produces the
|
|
||||||
# second form: 43 base64 characters that decode to 32 bytes.
|
|
||||||
if COOKIE_B64=$(kubectl -n "$NS" get secret "$OAUTH_SECRET" \
|
if COOKIE_B64=$(kubectl -n "$NS" get secret "$OAUTH_SECRET" \
|
||||||
-o jsonpath='{.data.cookie-secret}' 2>/dev/null); then
|
-o jsonpath='{.data.cookie-secret}' 2>/dev/null); then
|
||||||
if [ -n "$COOKIE_B64" ]; then
|
if [ -n "$COOKIE_B64" ]; then
|
||||||
COOKIE_RAW=$(printf '%s' "$COOKIE_B64" | base64 -d 2>/dev/null || true)
|
if ! COOKIE_LEN=$(printf '%s' "$COOKIE_B64" | base64 -d 2>/dev/null | wc -c); then
|
||||||
COOKIE_LEN=${#COOKIE_RAW}
|
fail "Secret $OAUTH_SECRET key 'cookie-secret' is not valid base64. Generate it with: python3 -c 'import secrets; print(secrets.token_urlsafe(32))'"
|
||||||
COOKIE_FORM=raw
|
else
|
||||||
case "$COOKIE_LEN" in
|
case "$COOKIE_LEN" in
|
||||||
16 | 24 | 32) ;;
|
16 | 24 | 32) pass "oauth2-proxy cookie-secret decodes to $COOKIE_LEN bytes" ;;
|
||||||
*)
|
*)
|
||||||
# Mirror SecretBytes: only a strictly URL-safe base64 string is decoded;
|
fail "oauth2-proxy cookie-secret decodes to $COOKIE_LEN bytes; it must be 16, 24 or 32. Generate a URL-safe unpadded value with: python3 -c 'import secrets; print(secrets.token_urlsafe(32))'"
|
||||||
# anything else is used as raw bytes (and is then the wrong length).
|
;;
|
||||||
COOKIE_FORM=base64
|
esac
|
||||||
case "$COOKIE_RAW" in
|
fi
|
||||||
"" | *[!A-Za-z0-9_=-]*) COOKIE_LEN=0 ;;
|
|
||||||
*)
|
|
||||||
COOKIE_LEN=$(printf '%s' "$COOKIE_RAW" | tr -- '-_' '+/' | tr -d '=\n' \
|
|
||||||
| base64 -d 2>/dev/null | wc -c || true)
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
case "$COOKIE_LEN" in
|
|
||||||
16 | 24 | 32) pass "oauth2-proxy cookie-secret is $COOKIE_FORM with a $COOKIE_LEN-byte key" ;;
|
|
||||||
*)
|
|
||||||
fail "oauth2-proxy cookie-secret is not a valid length ($COOKIE_LEN bytes). It must be 16, 24 or 32 raw bytes, or URL-safe unpadded base64 that decodes to one of those lengths. Generate it with: python3 -c 'import secrets; print(secrets.token_urlsafe(32))'"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -167,51 +139,6 @@ if env_is_set ALLOW_INSECURE_COOKIES; then
|
|||||||
warn "ALLOW_INSECURE_COOKIES is still set on deploy/$GATEWAY_DEPLOY. It is ignored while COOKIE_SECURE=true but is a footgun: remove it with 'kubectl -n $NS set env deploy/$GATEWAY_DEPLOY ALLOW_INSECURE_COOKIES-'."
|
warn "ALLOW_INSECURE_COOKIES is still set on deploy/$GATEWAY_DEPLOY. It is ignored while COOKIE_SECURE=true but is a footgun: remove it with 'kubectl -n $NS set env deploy/$GATEWAY_DEPLOY ALLOW_INSECURE_COOKIES-'."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
|
||||||
echo "== Gateway RBAC (SSO token rotation)"
|
|
||||||
# The SSO landing path patches the token-hash annotation on the user's claim
|
|
||||||
# whenever the browser has no valid gateway session: first visit, an expired or
|
|
||||||
# foreign cookie, or a gateway restart (sessions live in the gateway's memory).
|
|
||||||
# Without `patch` every such GET / answers 503 and the user cannot sign in.
|
|
||||||
GATEWAY_SA=$(kubectl -n "$NS" get deployment "$GATEWAY_DEPLOY" \
|
|
||||||
-o jsonpath='{.spec.template.spec.serviceAccountName}' 2>/dev/null || true)
|
|
||||||
GATEWAY_SA=${GATEWAY_SA:-default}
|
|
||||||
CAN_I=$(kubectl auth can-i patch sandboxclaims.extensions.agents.x-k8s.io \
|
|
||||||
-n "$NS" --as="system:serviceaccount:$NS:$GATEWAY_SA" 2>&1 || true)
|
|
||||||
|
|
||||||
# read_gateway_rules: one "apiGroups|resources|verbs" line per rule of the
|
|
||||||
# Role (or ClusterRole) the gateway RoleBinding points at. Used only when
|
|
||||||
# `kubectl auth can-i --as` is unavailable (a read-only kubeconfig cannot
|
|
||||||
# impersonate); it is a static check, not an authoritative one.
|
|
||||||
read_gateway_rules() {
|
|
||||||
local rules_jsonpath kind name
|
|
||||||
rules_jsonpath='{range .rules[*]}{range .apiGroups[*]}{@}{","}{end}{"|"}{range .resources[*]}{@}{","}{end}{"|"}{range .verbs[*]}{@}{","}{end}{"\n"}{end}'
|
|
||||||
kind=$(kubectl -n "$NS" get rolebinding "$GATEWAY_ROLE" \
|
|
||||||
-o jsonpath='{.roleRef.kind}' 2>/dev/null || true)
|
|
||||||
name=$(kubectl -n "$NS" get rolebinding "$GATEWAY_ROLE" \
|
|
||||||
-o jsonpath='{.roleRef.name}' 2>/dev/null || true)
|
|
||||||
case "$kind" in
|
|
||||||
Role) kubectl -n "$NS" get role "$name" -o jsonpath="$rules_jsonpath" 2>/dev/null || true ;;
|
|
||||||
ClusterRole) kubectl get clusterrole "$name" -o jsonpath="$rules_jsonpath" 2>/dev/null || true ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ "$CAN_I" = "yes" ]; then
|
|
||||||
pass "ServiceAccount $GATEWAY_SA can patch sandboxclaims (token rotation)"
|
|
||||||
elif [ "$CAN_I" = "no" ]; then
|
|
||||||
fail "ServiceAccount $GATEWAY_SA cannot patch sandboxclaims: SSO sign-in answers 503 whenever the browser has no valid gateway session (after a gateway restart, cookie expiry or in a new browser). Fix: grant 'patch' on sandboxclaims to Role $GATEWAY_ROLE (the shipped Role in manifests/50-gateway.yaml has it; re-applying just the Role/RoleBinding is enough). RBAC is evaluated per request; no gateway restart is needed."
|
|
||||||
else
|
|
||||||
RULES=$(read_gateway_rules)
|
|
||||||
if printf '%s\n' "$RULES" | awk -F'|' \
|
|
||||||
'$1 ~ /(^|,)extensions\.agents\.x-k8s\.io(,|$)/ && $2 ~ /(^|,)sandboxclaims(,|$)/ && $3 ~ /(^|,)patch(,|$)/ { found = 1 } END { exit found ? 0 : 1 }'; then
|
|
||||||
pass "the gateway Role grants patch on sandboxclaims (static check; impersonation unavailable)"
|
|
||||||
elif [ -n "$RULES" ]; then
|
|
||||||
fail "Role $GATEWAY_ROLE does not grant 'patch' on sandboxclaims: SSO sign-in answers 503 whenever the browser has no valid gateway session. Fix: grant that verb to the Role (the shipped Role in manifests/50-gateway.yaml has it; re-applying just the Role/RoleBinding is enough). RBAC is evaluated per request; no gateway restart is needed."
|
|
||||||
else
|
|
||||||
warn "could not verify that ServiceAccount $GATEWAY_SA may patch sandboxclaims ($CAN_I). Check manually that Role $GATEWAY_ROLE grants 'patch' on extensions.agents.x-k8s.io/sandboxclaims."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "== SSO objects and placeholders"
|
echo "== SSO objects and placeholders"
|
||||||
if kubectl -n "$NS" get deployment "$OAUTH_DEPLOY" >/dev/null 2>&1; then
|
if kubectl -n "$NS" get deployment "$OAUTH_DEPLOY" >/dev/null 2>&1; then
|
||||||
@@ -299,18 +226,6 @@ if kubectl -n "$NS" get ingress >/dev/null 2>&1; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The SSO variant's policies are part of the variant: without them the
|
|
||||||
# gateway Service is reachable by any Pod, which can spoof the identity header.
|
|
||||||
echo
|
|
||||||
echo "== SSO NetworkPolicies (required with this variant)"
|
|
||||||
for POLICY in dsh-gateway-ingress oauth2-proxy-ingress; do
|
|
||||||
if kubectl -n "$NS" get networkpolicy "$POLICY" >/dev/null 2>&1; then
|
|
||||||
pass "NetworkPolicy $POLICY exists"
|
|
||||||
else
|
|
||||||
fail "NetworkPolicy $POLICY is missing: any Pod in the cluster can reach dsh-gateway:8080 and spoof X-Forwarded-User, so the SSO identity is not trustworthy. Fix: kubectl apply -f manifests/exposure/gateway-api-sso/75-networkpolicies.yaml"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
if [ "$FAILED" -ne 0 ]; then
|
if [ "$FAILED" -ne 0 ]; then
|
||||||
echo "preflight-sso: FAILED - fix the items above before exposing the gateway." >&2
|
echo "preflight-sso: FAILED - fix the items above before exposing the gateway." >&2
|
||||||
|
|||||||
Reference in New Issue
Block a user