mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 07:59:42 +02:00
feat(sessions): offer to rebuild the sessions a host reboot destroyed
A host reboot takes the tmux server down with it, so every pane dies, reconciliation finds nothing to attach to, and the board comes up empty. Picking yesterday's work back up meant finding each conversation in history and resuming it by hand, one at a time. The boot pass now works out what the reboot killed and leaves it on offer. It runs inside restoreMuxSessions(), in the window where reconciliation has reported the dead sessions and cleanupStaleSessions() has not pruned their records yet, which is the only place the records can still be read. The board shows a banner, and nothing is created until the user clicks it. A click rather than an automatic restore is what makes the reboot heuristic acceptable. The heuristic cannot tell a reboot from a crash that took tmux down inside the same window, so it decides whether to ASK, never whether to act: a wrong yes costs a line of text the user dismisses instead of N CLI processes nobody asked for. Four things are re-checked when the click arrives rather than trusted from boot, because hours can pass and the board moves on. The owner's privilege grant re-resolves through the env clamp. The workspace must still be on disk. A conversation the user already resumed by hand from the Resume list is skipped, since two panes running --resume on one conversation would fight over the same transcript. Entries leave the plan synchronously before the first await, and the route is single-flighted, so a double-click or two devices cannot both reach the same entry. A restored session comes back attached, idle and disarmed. Respawn controllers and Ralph loops are deliberately not re-armed: a machine that just came up is the worst moment to turn an autonomous run loose. Its workspace hooks are installed by the restore route itself, because the boot-time sweep sits behind a gate that is false after a reboot and has finished long before the click; without them a session goes silently blind, with no stop or idle events for respawn, no Approvals Inbox item and no red tab on a blocking dialog. Stats collection starts the same way. The pane is new, so the conversation continues and the terminal scrollback does not. The banner says so rather than letting an empty pane read as a broken restore. The plan lives in memory only. A server restart drops it, which costs the convenience this adds and never the conversation: the conversation is the transcript under ~/.claude/projects, which the Welcome screen's Resume list and the Session Manager already read, so a dropped plan returns the user to resuming by hand. clampEnvOverridesForOwner moves to src/session-env-clamp.ts, since the question it answers is about session privilege rather than about HTTP and it now has a caller outside the route layer. Its test hook stays re-exported from session-routes.ts. Claude sessions only for this pass. The other CLIs name their thread in their own config object, which this does not thread through yet. Remote and docker sessions are skipped on purpose, because both need another host or a container to be up and a freshly booted machine cannot promise either. Refs #411 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bd286bf502
commit
da933d70be
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @fileoverview The env-var half of the multi-user privilege clamp.
|
||||
*
|
||||
* A session's `envOverrides` can hand back privilege that the per-CLI config
|
||||
* clamp removed, so a non-granted owner's overrides get the privileged keys
|
||||
* stripped before the session is built. Two callers need that today. The create
|
||||
* and resume routes clamp what a request asked for, and the reboot-restore route
|
||||
* clamps what a persisted record carried, because a record written while its
|
||||
* owner held a grant must not replay that grant after the grant is gone.
|
||||
*
|
||||
* This lives outside `web/routes` on purpose. The question it answers is about
|
||||
* session privilege rather than about HTTP, and `cron/cron-service.ts` sets the
|
||||
* precedent by importing `canUsernameRunPrivilegedCommands` from `user-store.ts`
|
||||
* directly and re-resolving the owner's grant when a job fires. Every caller here
|
||||
* re-resolves the grant at the moment it builds a session, for the same reason.
|
||||
*
|
||||
* @dependencies user-store (canUsernameRunPrivilegedCommands), config/cli-registry
|
||||
* @consumedby web/routes/session-routes, web/routes/reboot-restore-routes
|
||||
*
|
||||
* @module session-env-clamp
|
||||
*/
|
||||
|
||||
import { canUsernameRunPrivilegedCommands } from './user-store.js';
|
||||
import { enabledClis } from './config/cli-registry/registry.js';
|
||||
|
||||
/**
|
||||
* Env-var keys a non-granted owner must not be able to set, because each one
|
||||
* hands back privilege `clampExternalCliBypassForOwner()` just removed, or redirects a
|
||||
* credential-resolution endpoint.
|
||||
*
|
||||
* The DeepSeek three are reachable because `DSH_*` and `DEEPSEEK_*` are
|
||||
* allowlisted `envOverrides` prefixes (schemas.ts) — which they have to be, since
|
||||
* that is also how a user configures the harness's non-privileged knobs.
|
||||
*
|
||||
* - `DSH_PERMISSION_MODE` IS the harness's permission switch. Every other CLI's
|
||||
* bypass is a command-line FLAG, reachable only through the per-CLI config the
|
||||
* clamp already owns; this one is an env var, so the config clamp alone is
|
||||
* half a gate.
|
||||
* - `DSH_HOME` points the launcher at a profile tree, and a profile's plugin code
|
||||
* executes at BOOT, before any approval row can apply. A user who can write a
|
||||
* workspace can put a profile in it, so this is the wider of the two.
|
||||
* - `DEEPSEEK_BASE_URL` aims the provider endpoint, and `_configureCliEnv()`
|
||||
* forwards the SERVER's own `DEEPSEEK_API_KEY` into every dsh pane before
|
||||
* `applyEnvOverrides()` runs — so a non-granted owner who could set the base
|
||||
* URL would have the operator's API key sent as a bearer credential to a host
|
||||
* of their choosing. (`DEEPSEEK_API_KEY` itself stays overridable: supplying
|
||||
* your OWN key removes privilege rather than granting it.)
|
||||
* - `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` are where omp resolves
|
||||
* credentials from — the same shape as `DEEPSEEK_BASE_URL` above, reachable
|
||||
* because `OMP_*` is an allowlisted prefix. Unlike DeepSeek, Codeman does not
|
||||
* forward any operator-held key into an omp pane today (omp's provider
|
||||
* credentials live in `~/.omp` config files, not env vars), so there is no
|
||||
* known concrete exfiltration path yet — clamped defensively anyway, since a
|
||||
* non-granted owner redirecting where a shared multi-tenant deployment
|
||||
* resolves auth from is not something to allow silently (found in
|
||||
* Ark0N/Codeman#353 review; omp's own knobs are otherwise mostly `PI_*`,
|
||||
* already allowlisted for pi and not addressed here — see resolveOmpHome()).
|
||||
*/
|
||||
export function ownerClampedEnvKeys(): string[] {
|
||||
return enabledClis().flatMap((entry) => entry.capabilities.privilegedEnvKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Env-var half of the multi-user bypass clamp.
|
||||
*
|
||||
* `clampExternalCliBypassForOwner()` in `web/routes/session-routes.ts` clamps the
|
||||
* per-CLI CONFIG, and for every CLI
|
||||
* but DeepSeek that is the whole story. Here it is not: `applyEnvOverrides()` runs
|
||||
* AFTER `_configureCliEnv()` in tmux-manager, so an override sent on the SAME
|
||||
* request lands last and wins, and a non-granted owner could restore
|
||||
* `danger-full-access` on the very request the config clamp downgraded.
|
||||
*
|
||||
* Keys are DROPPED rather than rewritten: dropping falls through to what
|
||||
* `_configureCliEnv()` exports, which is the clamped config and the server's own
|
||||
* `DSH_HOME`, i.e. exactly the intended state. No-op in single-user mode and for a
|
||||
* granted owner, like every other clamp here
|
||||
* (`canUsernameRunPrivilegedCommands()` returns true when `!isMultiUserMode()`),
|
||||
* and it returns the caller's own object untouched when there is nothing to strip.
|
||||
*/
|
||||
export async function clampEnvOverridesForOwner(
|
||||
owner: string | undefined,
|
||||
envOverrides: Record<string, string> | undefined
|
||||
): Promise<Record<string, string> | undefined> {
|
||||
if (!envOverrides) return envOverrides;
|
||||
const keys = ownerClampedEnvKeys();
|
||||
if (!keys.some((key) => key in envOverrides)) return envOverrides;
|
||||
if (await canUsernameRunPrivilegedCommands(owner)) return envOverrides;
|
||||
const clamped = { ...envOverrides };
|
||||
for (const key of keys) delete clamped[key];
|
||||
return clamped;
|
||||
}
|
||||
Reference in New Issue
Block a user