mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
fix(deepseek): close the env-var clamp hole, bound the profile install, make the hook gate per-session
Three review findings on the DeepSeek Harness mode, plus one the third exposed. 1. The multi-user clamp was bypassable by a sibling field on the same request. clampExternalCliBypassForOwner() clamps deepSeekConfig.permissionMode, but DSH_* is an allowlisted envOverrides prefix and applyEnvOverrides() runs AFTER _configureDeepSeek(), so a non-granted owner sending envOverrides.DSH_PERMISSION_MODE landed last and won. Measured on an isolated instance: a session created with permissionMode "read-only" and that override ran with DSH_PERMISSION_MODE=danger-full-access in its pane. Every other CLI's bypass is a command-line flag reachable only through the per-CLI config, which is why the config clamp alone is the whole gate for them. clampEnvOverridesForOwner() adds the env-var half: for a non-granted owner it DROPS DSH_PERMISSION_MODE and DSH_HOME (dropping falls through to what _configureDeepSeek() exports, i.e. the clamped value). DSH_HOME is on that list because it aims the launcher at a profile tree whose plugin code runs at boot, before any approval row can apply. Verified end to end in real multi-user mode: a non-granted user sending both now gets workspace-write and no DSH_HOME, while an unrelated DSH_TELEMETRY_MODE passes through untouched. 2. POST /api/deepseek/install-profile could hang forever. spawn's own `timeout` signals only the direct child, and a plugin install fans out into package-manager children that keep the inherited stdio pipes open, so `close` never fires and the held-open request leaks with no route-level deadline. Reproduced: with a 1.5s built-in timeout the promise was still unsettled after 6s and both fan-out children were alive. Now detached: true plus negative-pid SIGTERM/SIGKILL, the same escalation runGit() uses for the same reason, with a last-resort reap for a grandchild that escaped the group. Same probe after the change: close fires, direct child and both grandchildren dead. 3. hooksAvailableForMode() promised more than a dsh session can deliver. deepSeekConfig.statusReporting: false disarms the HERDR_* export, and that triple is the only reason a dsh session posts hook events, so `until=stop` was accepted and then blocked for the caller's whole timeout: the exact infinite-wait-dressed-as-a-timeout the predicate exists to prevent. It now takes HookCapabilityOptions and every call site passes sessionHookOptions(), with the deepseek arm reading `!== false` so a forgotten one degrades to the old behaviour. The refusal names the setting rather than saying "no Claude Code hooks", which would send the caller hunting a bug that is really a setting they chose. Profile conformance stays unknowable at request time and is documented as such. The stale "True for `claude` and nothing else" docblock is corrected. 4. Exposed by (3): hooksAvailableForMode() was doing double duty as "is this a claude session". Read My Mind (POST /api/sessions/:id/readmymind) and intent capture read Claude's own transcript, and adding deepseek silently widened both to a mode that has none. They compare mode === 'claude' directly now, and a static check pins them there. Verified: full CI gate green (6132 passed), typecheck/lint/format clean, and the wait-signal gating exercised against a live server with a real dsh 0.1.1-rc.2 -- bridge off plus explicit until=stop is a 400 naming the setting, bridge off with no `until` still 200s on idle/exit, bridge on accepts stop.
This commit is contained in:
@@ -893,6 +893,20 @@ export class Session extends EventEmitter {
|
||||
return this._remote;
|
||||
}
|
||||
|
||||
/**
|
||||
* `deepSeekConfig.statusReporting` verbatim: `undefined` when the caller sent
|
||||
* none (i.e. ON), `false` when the user disarmed the status bridge for this
|
||||
* session.
|
||||
*
|
||||
* Exposed because whether a dsh session can deliver `stop`/`blocked` is a
|
||||
* per-SESSION fact, not a per-mode one, and `hooksAvailableForMode()` is pure
|
||||
* and holds no `Session` reference by design. Undefined for every other mode,
|
||||
* where the flag is meaningless.
|
||||
*/
|
||||
get deepSeekStatusReporting(): boolean | undefined {
|
||||
return this._deepSeekConfig?.statusReporting;
|
||||
}
|
||||
|
||||
/** Owning username in multi-user mode, else undefined. */
|
||||
get owner(): string | undefined {
|
||||
return this._owner;
|
||||
|
||||
@@ -23,7 +23,7 @@ import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { ApprovalAnswerSchema } from '../schemas.js';
|
||||
import { parseBody, getAuthUser, canAccessOwned, findSessionOrFail } from '../route-helpers.js';
|
||||
import { approvalInbox, type ApprovalItem } from '../approval-inbox.js';
|
||||
import { hooksAvailableForMode } from '../session-wait-registry.js';
|
||||
import { hooksAvailableForMode, sessionHookOptions } from '../session-wait-registry.js';
|
||||
import type { SessionPort } from '../ports/index.js';
|
||||
|
||||
/**
|
||||
@@ -99,7 +99,7 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort):
|
||||
// Throws 404 (not 403) for sessions the caller does not own, same
|
||||
// no-existence-leak rule as every other session route.
|
||||
const session = findSessionOrFail(ctx, item.sessionId, req);
|
||||
if (!hooksAvailableForMode(session.mode)) {
|
||||
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { HookEventSchema, isValidWorkingDir } from '../schemas.js';
|
||||
import { sanitizeHookData, parseBody } from '../route-helpers.js';
|
||||
import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import { sessionWaits, hooksAvailableForMode } from '../session-wait-registry.js';
|
||||
import { sessionWaits, hooksAvailableForMode, sessionHookOptions } from '../session-wait-registry.js';
|
||||
import { approvalInbox, type ApprovalKind } from '../approval-inbox.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
@@ -59,7 +59,7 @@ export function registerHookEventRoutes(
|
||||
// could never legitimately emit one is now dropped instead of steering another
|
||||
// agent's control flow.
|
||||
const waitSession = ctx.sessions.get(sessionId);
|
||||
if (waitSession && hooksAvailableForMode(waitSession.mode)) {
|
||||
if (waitSession && hooksAvailableForMode(waitSession.mode, sessionHookOptions(waitSession))) {
|
||||
if (event === 'stop') {
|
||||
sessionWaits.notifySignal(sessionId, 'stop');
|
||||
} else if (event === 'permission_prompt' || event === 'elicitation_dialog') {
|
||||
@@ -120,7 +120,7 @@ export function registerHookEventRoutes(
|
||||
// session that can never show one must not create an answerable item).
|
||||
let approvalId: string | undefined;
|
||||
const approvalKind = APPROVAL_KIND_BY_EVENT[event];
|
||||
if (session && hooksAvailableForMode(session.mode)) {
|
||||
if (session && hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
|
||||
if (approvalKind) {
|
||||
const toolInput =
|
||||
safeData.tool_input && typeof safeData.tool_input === 'object'
|
||||
|
||||
@@ -38,7 +38,6 @@ import { IntentGoalsSchema, ReadMyMindPredictSchema } from '../schemas.js';
|
||||
import { parseBody, findSessionOrFail } from '../route-helpers.js';
|
||||
import { intentStore } from '../../intent-store.js';
|
||||
import { approvalInbox } from '../approval-inbox.js';
|
||||
import { hooksAvailableForMode } from '../session-wait-registry.js';
|
||||
import { buildPredictionContext, type PredictionContextInputs } from '../../readmymind-context.js';
|
||||
import { collectWorkspaceSignals, readTranscriptSignals } from '../../readmymind-collectors.js';
|
||||
import { readMyMindPredictor } from '../../readmymind-predictor.js';
|
||||
@@ -72,7 +71,11 @@ export function registerReadMyMindRoutes(app: FastifyInstance, ctx: SessionPort
|
||||
const body = parseBody(ReadMyMindPredictSchema, req.body ?? {});
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
if (!hooksAvailableForMode(session.mode)) {
|
||||
// `mode === 'claude'` directly, NOT hooksAvailableForMode(): that predicate
|
||||
// answers "can this session deliver stop/blocked", and once `deepseek` earned
|
||||
// a yes it silently widened this gate to a mode whose sessions have no Claude
|
||||
// transcript for readTranscriptSignals() to read.
|
||||
if (session.mode !== 'claude') {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Read My Mind predicts claude-mode sessions only');
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import { TabLayoutValidationError } from '../../tab-layout.js';
|
||||
import {
|
||||
sessionWaits,
|
||||
resolveWaitSignals,
|
||||
sessionHookOptions,
|
||||
signalForStatus,
|
||||
WaitCapacityError,
|
||||
type WaitSignal,
|
||||
@@ -391,6 +392,55 @@ async function clampExternalCliBypassForOwner(
|
||||
/** Test hook: the clamp is the multi-user safety gate for the external CLIs' privileged flags. */
|
||||
export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
|
||||
|
||||
/**
|
||||
* Env-var keys a non-granted owner must not be able to set, because each one
|
||||
* hands back privilege the config clamp above just removed.
|
||||
*
|
||||
* Both are DeepSeek's, and both are reachable because `DSH_*` is an allowlisted
|
||||
* `envOverrides` prefix (schemas.ts) — which it has 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.
|
||||
*/
|
||||
const OWNER_CLAMPED_ENV_KEYS = ['DSH_PERMISSION_MODE', 'DSH_HOME'] as const;
|
||||
|
||||
/**
|
||||
* Env-var half of the multi-user bypass clamp.
|
||||
*
|
||||
* `clampExternalCliBypassForOwner()` clamps the per-CLI CONFIG, and for every CLI
|
||||
* but DeepSeek that is the whole story. Here it is not: `applyEnvOverrides()` runs
|
||||
* AFTER `_configureDeepSeek()` 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
|
||||
* `_configureDeepSeek()` 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.
|
||||
*/
|
||||
async function clampEnvOverridesForOwner(
|
||||
owner: string | undefined,
|
||||
envOverrides: Record<string, string> | undefined
|
||||
): Promise<Record<string, string> | undefined> {
|
||||
if (!envOverrides) return envOverrides;
|
||||
if (!OWNER_CLAMPED_ENV_KEYS.some((key) => key in envOverrides)) return envOverrides;
|
||||
if (await canUsernameRunPrivilegedCommands(owner)) return envOverrides;
|
||||
const clamped = { ...envOverrides };
|
||||
for (const key of OWNER_CLAMPED_ENV_KEYS) delete clamped[key];
|
||||
return clamped;
|
||||
}
|
||||
|
||||
/** Test hook: the env-var half of the same multi-user safety gate. */
|
||||
export const _clampEnvOverridesForOwner = clampEnvOverridesForOwner;
|
||||
|
||||
/**
|
||||
* Why a DeepSeek session cannot start, or null when it can.
|
||||
*
|
||||
@@ -1018,7 +1068,7 @@ export function registerSessionRoutes(
|
||||
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
|
||||
deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
|
||||
resumeSessionId: validatedResumeId,
|
||||
envOverrides: body.envOverrides,
|
||||
envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides),
|
||||
effort: body.effort,
|
||||
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
|
||||
remote,
|
||||
@@ -1344,7 +1394,10 @@ export function registerSessionRoutes(
|
||||
wait === true || (typeof wait === 'string' && wait.trim().length > 0) || (Array.isArray(wait) && wait.length > 0);
|
||||
let until: readonly WaitSignal[] = [];
|
||||
if (wantsWait) {
|
||||
const resolved = resolveWaitSignals(wait === true ? undefined : wait, { mode: session.mode });
|
||||
const resolved = resolveWaitSignals(wait === true ? undefined : wait, {
|
||||
mode: session.mode,
|
||||
...sessionHookOptions(session),
|
||||
});
|
||||
if (resolved.error) return createErrorResponse(ApiErrorCode.INVALID_INPUT, resolved.error);
|
||||
until = resolved.until;
|
||||
}
|
||||
@@ -1521,7 +1574,7 @@ export function registerSessionRoutes(
|
||||
|
||||
// Shared with the `wait` field on POST .../input: unknown token is a 400,
|
||||
// hook-only signals are rejected explicitly but dropped from the default.
|
||||
const { until, error } = resolveWaitSignals(query.until, { mode: session.mode });
|
||||
const { until, error } = resolveWaitSignals(query.until, { mode: session.mode, ...sessionHookOptions(session) });
|
||||
if (error) return createErrorResponse(ApiErrorCode.INVALID_INPUT, error);
|
||||
|
||||
// The value actually applied after clamping, echoed below: a caller that asked
|
||||
@@ -3160,6 +3213,7 @@ export function registerSessionRoutes(
|
||||
deepSeekConfig
|
||||
);
|
||||
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides);
|
||||
const session = new Session({
|
||||
workingDir: resolvedCasePath,
|
||||
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
|
||||
@@ -3178,7 +3232,7 @@ export function registerSessionRoutes(
|
||||
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
|
||||
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
|
||||
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
|
||||
envOverrides,
|
||||
envOverrides: qsGatedEnvOverrides,
|
||||
effort,
|
||||
remote,
|
||||
docker,
|
||||
|
||||
@@ -530,7 +530,10 @@ export function registerSystemRoutes(
|
||||
// second command;
|
||||
// - the request is held open with a bounded timeout, mirroring the
|
||||
// synchronous-clone precedent in `POST /api/cases/clone` rather than
|
||||
// introducing a job store for a once-per-install action.
|
||||
// introducing a job store for a once-per-install action — and the bound is
|
||||
// real, because the install runs in its own process GROUP and the timeout
|
||||
// kills the whole tree (see the spawn below for why the built-in one is
|
||||
// not enough).
|
||||
app.post('/api/deepseek/install-profile', async (req) => {
|
||||
const body = parseBody(DeepSeekInstallProfileSchema, req.body);
|
||||
if (isMultiUserMode() && !(await canUsernameRunPrivilegedCommands(getAuthUser(req).username))) {
|
||||
@@ -546,29 +549,87 @@ export function registerSystemRoutes(
|
||||
|
||||
const profile = body.profile || DEEPSEEK_DEFAULT_PROFILE;
|
||||
const pkg = body.package || DEEPSEEK_DEFAULT_TUI_PACKAGE;
|
||||
const result = await new Promise<{ code: number | null; output: string }>((resolve) => {
|
||||
const child = spawn(join(dir, 'dsh'), ['plugin', '--profile', profile, 'add', pkg], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: DEEPSEEK_INSTALL_TIMEOUT_MS,
|
||||
// dsh bundles its own package manager, so no system pnpm is required —
|
||||
// but it still needs a HOME to resolve $DSH_HOME against.
|
||||
env: process.env,
|
||||
});
|
||||
const result = await new Promise<{ code: number | null; output: string; timedOut: boolean }>((resolve) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(join(dir, 'dsh'), ['plugin', '--profile', profile, 'add', pkg], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
// Own process group, and the timeout enforced by hand rather than by
|
||||
// spawn's `timeout` option. A plugin install fans out into
|
||||
// package-manager resolver/build children, and spawn's own timeout
|
||||
// signals ONLY the direct child: the survivors keep the inherited stdio
|
||||
// pipes open, `close` never fires, and this request hangs forever with
|
||||
// no route-level deadline behind it. Same fan-out, same escalation and
|
||||
// same negative-pid signal as runGit() in git-clone.ts, which is the
|
||||
// synchronous-spawn precedent this endpoint is modelled on.
|
||||
detached: true,
|
||||
// dsh bundles its own package manager, so no system pnpm is required —
|
||||
// but it still needs a HOME to resolve $DSH_HOME against.
|
||||
env: process.env,
|
||||
});
|
||||
} catch (err) {
|
||||
resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false });
|
||||
return;
|
||||
}
|
||||
|
||||
let output = '';
|
||||
let timedOut = false;
|
||||
let settled = false;
|
||||
let killTimer: NodeJS.Timeout | undefined;
|
||||
let reapTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
const capture = (chunk: Buffer) => {
|
||||
// Bounded: a package manager can emit megabytes of progress.
|
||||
if (output.length < 16_384) output += chunk.toString('utf-8');
|
||||
};
|
||||
child.stdout?.on('data', capture);
|
||||
child.stderr?.on('data', capture);
|
||||
child.on('error', (err) => resolve({ code: null, output: `${output}\n${err.message}` }));
|
||||
child.on('close', (code) => resolve({ code, output }));
|
||||
|
||||
const killTree = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
if (child.pid) process.kill(-child.pid, signal);
|
||||
} catch {
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const finish = (code: number | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (killTimer) clearTimeout(killTimer);
|
||||
if (reapTimer) clearTimeout(reapTimer);
|
||||
resolve({ code, output, timedOut });
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
killTree('SIGTERM');
|
||||
killTimer = setTimeout(() => killTree('SIGKILL'), 3_000);
|
||||
// Last resort: a grandchild that escaped the group (double-fork/setsid)
|
||||
// can hold the pipes open past SIGKILL, and `close` would still never
|
||||
// arrive. Answer the caller anyway rather than leaking the request.
|
||||
reapTimer = setTimeout(() => finish(null), 8_000);
|
||||
}, DEEPSEEK_INSTALL_TIMEOUT_MS);
|
||||
|
||||
child.on('error', (err) => {
|
||||
output = `${output}\n${err.message}`;
|
||||
finish(null);
|
||||
});
|
||||
child.on('close', (code) => finish(code));
|
||||
});
|
||||
|
||||
if (result.code !== 0) {
|
||||
const detail = result.timedOut
|
||||
? `timed out after ${Math.round(DEEPSEEK_INSTALL_TIMEOUT_MS / 1000)}s`
|
||||
: result.output.slice(-1000).trim() || 'no output';
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`Installing ${pkg} into profile "${profile}" failed: ${result.output.slice(-1000).trim() || 'no output'}`
|
||||
`Installing ${pkg} into profile "${profile}" failed: ${detail}`
|
||||
);
|
||||
}
|
||||
const { listDeepSeekProfiles, resolveDefaultDeepSeekProfile, isDeepSeekRunnable } =
|
||||
|
||||
+5
-2
@@ -91,7 +91,7 @@ import {
|
||||
attachSessionListeners,
|
||||
detachSessionListeners,
|
||||
} from './session-listener-wiring.js';
|
||||
import { sessionWaits, hooksAvailableForMode } from './session-wait-registry.js';
|
||||
import { sessionWaits } from './session-wait-registry.js';
|
||||
import { intentStore } from '../intent-store.js';
|
||||
import { AI_CHECK_MODEL } from '../config/ai-defaults.js';
|
||||
import { approvalInbox } from './approval-inbox.js';
|
||||
@@ -1093,7 +1093,10 @@ export class WebServer extends EventEmitter {
|
||||
*/
|
||||
private async captureIntentPrompt(sessionId: string, text: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || !hooksAvailableForMode(session.mode)) return;
|
||||
// `mode === 'claude'` directly: the intent profile is fed from Claude's own
|
||||
// transcript, so this is a claude question, not a hooks-available one (which
|
||||
// `deepseek` now answers yes to).
|
||||
if (!session || session.mode !== 'claude') return;
|
||||
try {
|
||||
const settings = await this.readSettings();
|
||||
if (settings.readMyMindEnabled !== true) return;
|
||||
|
||||
@@ -170,25 +170,72 @@ export function signalForStatus(status: SessionStatus): WaitSignal | null {
|
||||
/** Signals that arrive only via Claude Code hooks, so only `claude` mode can emit them. */
|
||||
const HOOK_ONLY_SIGNALS: readonly WaitSignal[] = ['stop', 'blocked'];
|
||||
|
||||
/** Per-session facts that can turn a mode's hook capability OFF for one session. */
|
||||
export interface HookCapabilityOptions {
|
||||
/**
|
||||
* `deepSeekConfig.statusReporting`, verbatim (so `undefined` means "not sent",
|
||||
* i.e. ON). `false` is the per-session opt-out that stops `_configureDeepSeek()`
|
||||
* exporting the `HERDR_*` triple, which is the ONLY thing that makes a dsh
|
||||
* session emit hook events at all.
|
||||
*/
|
||||
deepSeekStatusReporting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a session in this mode ever POSTs Codeman hook events, and therefore
|
||||
* whether `stop` / `blocked` can ever fire for it.
|
||||
* Whether this session ever POSTs Codeman hook events, and therefore whether
|
||||
* `stop` / `blocked` can ever fire for it.
|
||||
*
|
||||
* True for `claude` and nothing else. The tempting predicate is
|
||||
* `claude` always (Claude Code fires the hooks itself), `deepseek` when its
|
||||
* status bridge is armed, nothing else. The tempting predicate is
|
||||
* `!isExternalCliMode(mode)`, and it is WRONG: that helper covers only
|
||||
* opencode/codex/gemini/antigravity, so `shell` falls through it — and a shell session
|
||||
* is a plain bash PTY with no Claude Code and no hooks installed. `until=stop` on one
|
||||
* was accepted and then blocked for the caller's whole timeout, which is precisely the
|
||||
* infinite-wait-dressed-as-a-timeout this guard exists to prevent.
|
||||
*
|
||||
* ⚠️ `deepseek` is a per-SESSION answer, not a per-mode one, which is why the
|
||||
* options argument exists: `deepSeekConfig.statusReporting: false` disarms the
|
||||
* bridge for one session, and answering from the mode alone re-creates the exact
|
||||
* infinite-wait this guard is for. Every call site therefore passes the session's
|
||||
* own flag; the default stays permissive so a forgotten one degrades to the old
|
||||
* behavior rather than 400ing a session that works.
|
||||
*
|
||||
* ⚠️ It is also the LIMIT of what can be known at request time. Whether the
|
||||
* installed profile actually implements the supervisor contract is only
|
||||
* observable once it reports, and `resolveDefaultDeepSeekProfile()` deliberately
|
||||
* treats an unrecognized profile as launchable, so a dsh session running a
|
||||
* non-conforming TUI still answers true here and still times out on an explicit
|
||||
* `until=stop`. The default signal set keeps `idle`/`exit` for exactly that case.
|
||||
*
|
||||
* ⚠️ NOT a stand-in for "is this a claude session". It reads like one and it was
|
||||
* used as one (Read My Mind, intent capture) until `deepseek` joined and silently
|
||||
* widened both. Those sites compare `mode === 'claude'` directly now; ask this
|
||||
* function only about hook SIGNALS.
|
||||
*/
|
||||
export function hooksAvailableForMode(mode: SessionMode): boolean {
|
||||
export function hooksAvailableForMode(mode: SessionMode, options: HookCapabilityOptions = {}): boolean {
|
||||
if (mode === 'claude') return true;
|
||||
// `deepseek` earns this the same way `claude` does — by emitting DEFINITIVE
|
||||
// signals rather than having them inferred. The DeepSeek Harness terminal
|
||||
// front door reports idle/working/blocked to its supervisor, and Codeman is
|
||||
// that supervisor (see deepseek-status-shim.ts), so a dsh session really can
|
||||
// deliver `stop` and `blocked`. Every other mode is output-stabilization
|
||||
// guesswork and must keep failing the ask.
|
||||
return mode === 'claude' || mode === 'deepseek';
|
||||
// deliver `stop` and `blocked` — unless the user turned the bridge off, in
|
||||
// which case nothing on the box will ever post one. Every other mode is
|
||||
// output-stabilization guesswork and must keep failing the ask.
|
||||
if (mode === 'deepseek') return options.deepSeekStatusReporting !== false;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift the per-session hook facts off a live session.
|
||||
*
|
||||
* Structurally typed on purpose: this module is pure and deliberately imports no
|
||||
* `Session` (importing it would drag node-pty and the session layer into every
|
||||
* consumer). One helper rather than an inline object literal at each of the four
|
||||
* call sites, so a future per-session fact is added in one place instead of
|
||||
* being forgotten at three of them.
|
||||
*/
|
||||
export function sessionHookOptions(session: { deepSeekStatusReporting?: boolean }): HookCapabilityOptions {
|
||||
return { deepSeekStatusReporting: session.deepSeekStatusReporting };
|
||||
}
|
||||
|
||||
/** Outcome of resolving a caller-supplied wait target against a session's mode. */
|
||||
@@ -212,10 +259,15 @@ export interface ResolvedWaitSignals {
|
||||
* not drift; the second-guessing that produces is worse than the duplication.
|
||||
*
|
||||
* @param raw - the caller's value (comma string, array, `true` for "the default")
|
||||
* @param options - `mode` decides whether the hook-only signals are available, and
|
||||
* names the mode in the error message so the caller can see why
|
||||
* @param options - `mode` plus the per-session facts `hooksAvailableForMode()` needs
|
||||
* (a dsh session with its status bridge disarmed emits no hooks even
|
||||
* though the mode can). The mode also names itself in the error
|
||||
* message so the caller can see why.
|
||||
*/
|
||||
export function resolveWaitSignals(raw: unknown, options: { mode: SessionMode }): ResolvedWaitSignals {
|
||||
export function resolveWaitSignals(
|
||||
raw: unknown,
|
||||
options: { mode: SessionMode } & HookCapabilityOptions
|
||||
): ResolvedWaitSignals {
|
||||
const parsed = parseWaitSignals(raw);
|
||||
if (parsed.invalid.length > 0) {
|
||||
return {
|
||||
@@ -224,7 +276,7 @@ export function resolveWaitSignals(raw: unknown, options: { mode: SessionMode })
|
||||
};
|
||||
}
|
||||
|
||||
const unsupported = new Set<WaitSignal>(hooksAvailableForMode(options.mode) ? [] : HOOK_ONLY_SIGNALS);
|
||||
const unsupported = new Set<WaitSignal>(hooksAvailableForMode(options.mode, options) ? [] : HOOK_ONLY_SIGNALS);
|
||||
|
||||
if (parsed.signals.length === 0) {
|
||||
return { until: DEFAULT_WAIT_SIGNALS.filter((signal) => !unsupported.has(signal)), error: null };
|
||||
@@ -234,7 +286,14 @@ export function resolveWaitSignals(raw: unknown, options: { mode: SessionMode })
|
||||
if (rejected.length > 0) {
|
||||
return {
|
||||
until: [],
|
||||
error: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). Use idle or exit.`,
|
||||
// A dsh session is the one case where the mode is capable and THIS session
|
||||
// is not, so saying "never fire for deepseek sessions" would send the
|
||||
// caller looking for a bug that is really a setting they chose.
|
||||
error:
|
||||
options.mode === 'deepseek'
|
||||
? `Signal(s) ${rejected.join(', ')} never fire for this deepseek session: its status bridge is off ` +
|
||||
`(deepSeekConfig.statusReporting: false), so nothing posts hook events. Use idle or exit.`
|
||||
: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). Use idle or exit.`,
|
||||
};
|
||||
}
|
||||
return { until: parsed.signals, error: null };
|
||||
|
||||
Reference in New Issue
Block a user