1
0
mirror of https://github.com/sredevopsorg/deepseek-kubernetes-sandbox.git synced 2026-11-03 01:13:24 +00:00

1 Commits

Author SHA1 Message Date
SREDevOpsAgents
a585e6be13 Enable Entire 2026-10-31 12:25:45 -03:00
7 changed files with 594 additions and 83 deletions

View File

@@ -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
View 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
}
},
}
}

View File

@@ -339,7 +339,7 @@ The gateway runs with a namespace-scoped ServiceAccount (`manifests/50-gateway.y
| 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 |
| `""` (core) | `pods` | `get`, `list` | resolve the pod behind a sandbox |
| `""` (core) | `pods/log` | `get` | launch-token handshake |
@@ -404,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 |
| 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 |
| 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 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 |

View File

@@ -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. |
| User over `MAX_IN_FLIGHT_PER_USER` | `503` + `Retry-After` (HTTP); the existing in-flight requests are untouched |
| 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 per-user concurrency cap | 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) |
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:
* `sandboxclaims` (`extensions.agents.x-k8s.io`): get, list, create, patch, delete
(patch rotates the token-hash annotation when a session is minted)
* `sandboxclaims` (`extensions.agents.x-k8s.io`): get, list, create, delete
* `sandboxes` (`agents.x-k8s.io`): get, list, patch
* `pods`: get, list
* `pods/log`: get

View File

@@ -1510,13 +1510,7 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
user = websocket.path_params["user"]
if not valid_user(user):
return await self._ws_reject(websocket, 4401)
try:
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:
return await self._ws_reject(websocket, 4401)
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)
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(
settings: Settings | None = None,
k8s=None,
@@ -1806,7 +1778,7 @@ def create_app(
]
app = Starlette(
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),
)
app.state.gateway = gateway

View File

@@ -1176,14 +1176,12 @@ async def test_delete_missing_claim_is_404(env):
@pytest.mark.asyncio
async def test_unexpected_api_error_returns_503(env):
async def test_unexpected_api_error_propagates(env):
add_user(env.k8s)
env.k8s.get_claim_error = ApiException(status=500)
async with asgi_client(env) as client:
response = await client.get("/users/alice")
assert response.status_code == 503
assert response.headers["retry-after"] == "10"
assert response.json() == {"error": "cluster API unavailable, retry shortly"}
with pytest.raises(ApiException):
await client.get("/users/alice")
@pytest.mark.asyncio
@@ -1833,16 +1831,6 @@ def test_ws_unauthenticated_closes_4401(env):
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):
add_user(env.k8s)
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
@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
async def test_sso_landing_new_user_shows_provisioning(env):
enable_sso(env)

View File

@@ -26,8 +26,7 @@ metadata:
namespace: dsh-demo
---
# Least privilege:
# - claims are created/deleted per user (signup/delete) and patched only to
# rotate the token-hash annotation when a session is minted;
# - claims are created/deleted per user (signup/delete);
# - 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
# the startup log line (`dsh web: ...?token=...`).
@@ -40,7 +39,7 @@ metadata:
rules:
- apiGroups: ["extensions.agents.x-k8s.io"]
resources: ["sandboxclaims"]
verbs: ["get", "list", "create", "patch", "delete"]
verbs: ["get", "list", "create", "delete"]
- apiGroups: ["agents.x-k8s.io"]
resources: ["sandboxes"]
verbs: ["get", "list", "patch"]