mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
fix(deepseek): review-driven hardening across the harness integration
Fifteen review findings on the dsh mode, the serious ones first: - Multi-user: DEEPSEEK_BASE_URL joins the owner-clamped env keys. _configureDeepSeek() forwards the SERVER's own DEEPSEEK_API_KEY into every dsh pane and applyEnvOverrides() lands after it, so a non-granted owner who could redirect the base URL would have the operator's key sent as a bearer credential to a host of their choosing. - Wait registry: until=stop/blocked is refused on docker and remote-SSH dsh sessions (new deepSeekBridgeUnreachable fact in sessionHookOptions). The HERDR triple is set via LOCAL tmux setenv, which crosses neither docker exec nor ssh, so such a session can never post a hook event and the wait burned its whole timeout on every turn. - Approvals: a dsh item is an ALERT, not an answerable card. The answer route refuses (the '1'/Esc keystrokes are Claude-dialog-shaped and the option parser cannot read a third-party TUI's frames, so an answer was a blind keystroke into a foreign composer), and the push notification carries no Approve/Deny actions for dsh sessions. - Status shim (v3): --seq is forwarded and the server drops stale retried reports inside a 60s window (the TUI retries with backoff, so a retried 'working' could land after 'blocked' and resolve an approval whose dialog was still on screen); 4xx responses exit 0 instead of retrying, so one misconfigured session cannot feed the auth rate-limit bucket until the hook endpoint 429s for the whole instance. - Web-UI server: concurrent starts are serialized through a lock (two racing POSTs used to pick the same port and orphan the winner), and the readiness poll / timeout paths only clear or stop the singleton while it is still theirs. First click actually opens the tab now (refreshWebviews, not the nonexistent loadWebviews). DELETE /api/deepseek/web requires the privileged grant in multi-user mode. - Cron: deepseek jobs run the same two-part launch gate as the HTTP create paths (impl moved into the resolver so all three share it) and no longer stamp a Claude default model on the session. - Parity sweeps: quick-start's docker branch rejects deepSeekConfig like the remote branch; the Ralph auto-enable list gained deepseek; HookEventType gained agent_working; the phone overview run menu filters managed webview records like the desktop menu. - install.sh: the dsh identity probe closes stdin (under curl|bash a child that reads stdin eats the rest of the script), bounds the exec with timeout where available, and is memoized to one scan per install. - Welcome screen: .welcome-btn-deepseek styled in the #4d6bfe brand identity (it rendered as an unstyled UA-grey button); stale markup comment about the web shortcut rewritten; clamp docs updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -395,11 +395,23 @@ export class CronService {
|
||||
let session: Session;
|
||||
try {
|
||||
const mode = job.agentType;
|
||||
// Same two-part availability gate the HTTP create paths run: `dsh` is a
|
||||
// profile LAUNCHER, so without this a job on a box with only the stock
|
||||
// web/headless profiles spawns a bare `dsh` that boots a profile unable
|
||||
// to drive a pane, and the prompt is typed into a logging server or a
|
||||
// dead pane instead of failing the run with the actionable message.
|
||||
if (mode === 'deepseek') {
|
||||
const { resolveDeepSeekLaunchError } = await import('../utils/deepseek-cli-resolver.js');
|
||||
const launchError = resolveDeepSeekLaunchError();
|
||||
if (launchError) return this.failRun(job, run, launchError);
|
||||
}
|
||||
const globalNice = await this.deps.getGlobalNiceConfig();
|
||||
const modelConfig = await this.deps.getModelConfig();
|
||||
const claudeModeConfig = await this.deps.getClaudeModeConfig();
|
||||
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, job.owner);
|
||||
const model = mode !== 'shell' ? modelConfig?.defaultModel || undefined : undefined;
|
||||
// DeepSeek's model is a composition entry in the profile's config tree,
|
||||
// not a session flag — mirror the HTTP routes' exclusion.
|
||||
const model = mode !== 'shell' && mode !== 'deepseek' ? modelConfig?.defaultModel || undefined : undefined;
|
||||
// Section 6.3: materialize the safe default for a non-granted owner (see
|
||||
// clampCronExternalCliConfigs — cron sends no per-CLI config, so the CLI's own
|
||||
// spawn default is what would otherwise apply).
|
||||
|
||||
@@ -54,7 +54,7 @@ import { dataPath } from './config/instance.js';
|
||||
* by an older Codeman and rewrite only when needed (rather than rewriting on
|
||||
* every session create, or — worse — leaving a stale one in place forever).
|
||||
*/
|
||||
const SHIM_VERSION = 2;
|
||||
const SHIM_VERSION = 3;
|
||||
const SHIM_MARKER = `codeman-dsh-status-shim v${SHIM_VERSION}`;
|
||||
|
||||
/**
|
||||
@@ -143,12 +143,19 @@ try {
|
||||
// Missing file: the loopback bypass still applies when no tunnel is running.
|
||||
}
|
||||
|
||||
// The contract's ordering token: the TUI retries failed deliveries with
|
||||
// backoff, so a stale report can land AFTER a newer one. Forwarded so the
|
||||
// server can drop out-of-order arrivals instead of, say, resolving an
|
||||
// approval with a retried 'working' while the harness sits blocked.
|
||||
const seq = Number(flag('--seq'))
|
||||
|
||||
const body = JSON.stringify({
|
||||
event,
|
||||
sessionId,
|
||||
data: {
|
||||
source: 'dsh-status-shim',
|
||||
agent: flag('--agent') || 'dsh',
|
||||
...(Number.isFinite(seq) ? { seq } : {}),
|
||||
...(flag('--message') ? { message: flag('--message') } : {}),
|
||||
},
|
||||
})
|
||||
@@ -179,7 +186,14 @@ const req = transport.request(
|
||||
},
|
||||
(res) => {
|
||||
res.resume()
|
||||
process.exit(res.statusCode && res.statusCode >= 200 && res.statusCode < 300 ? 0 : 1)
|
||||
const status = res.statusCode ?? 0
|
||||
// 2xx: delivered. 4xx: PERMANENT — a 401 (missing/rotated secret) or 429
|
||||
// can never be fixed by retrying, and each retry feeds the auth-failure
|
||||
// rate-limit bucket, so a single misconfigured dsh session could 429 the
|
||||
// hook endpoint for the whole instance (killing every claude session's
|
||||
// real hooks). Exit 0 so the TUI does not retry; only transport errors
|
||||
// and 5xx stay retryable.
|
||||
process.exit(status >= 200 && status < 500 ? 0 : 1)
|
||||
}
|
||||
)
|
||||
req.on('timeout', () => {
|
||||
|
||||
@@ -157,16 +157,37 @@ export async function stopDeepSeekWeb(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
type StartResult = { ok: true; port: number; url: string; reused: boolean } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Serializes concurrent starts. Two POSTs racing (two devices, or a double
|
||||
* click while the first boots) used to both see `current === null`, pick the
|
||||
* SAME free port, and spawn twice: the loser died on EADDRINUSE while its exit
|
||||
* handler nulled the singleton out from under the winner, leaving a live
|
||||
* `dsh web` nothing tracked or killed — the exact orphan this module exists to
|
||||
* prevent. The second caller now simply waits and reuses the first's server.
|
||||
*/
|
||||
let startLock: Promise<unknown> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Start (or reuse) the background `dsh web` for `authority`.
|
||||
*
|
||||
* @param dshDir directory holding the resolved `dsh` binary.
|
||||
* @param authority browser authority to pass as `--trusted-host`.
|
||||
*/
|
||||
export async function startDeepSeekWeb(
|
||||
dshDir: string,
|
||||
authority: string
|
||||
): Promise<{ ok: true; port: number; url: string; reused: boolean } | { ok: false; error: string }> {
|
||||
export function startDeepSeekWeb(dshDir: string, authority: string): Promise<StartResult> {
|
||||
const run = startLock.then(
|
||||
() => startDeepSeekWebLocked(dshDir, authority),
|
||||
() => startDeepSeekWebLocked(dshDir, authority)
|
||||
);
|
||||
startLock = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
async function startDeepSeekWebLocked(dshDir: string, authority: string): Promise<StartResult> {
|
||||
// Reuse only when the running server is BOTH healthy and fenced for the
|
||||
// authority now asking. A server trusting the other origin renders a page
|
||||
// whose every API call 403s, which looks like a broken dashboard rather than
|
||||
@@ -226,7 +247,10 @@ export async function startDeepSeekWeb(
|
||||
const deadline = Date.now() + READY_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
if (exited) {
|
||||
current = null;
|
||||
// Guarded like the exit/error handlers: a concurrent stop (DELETE route,
|
||||
// shutdown) may already have cleared or replaced the singleton, and an
|
||||
// unconditional null here would drop a server this call does not own.
|
||||
if (current === running) current = null;
|
||||
const tail = output.trim().slice(-800);
|
||||
return { ok: false, error: tail ? `dsh web exited during startup: ${tail}` : 'dsh web exited during startup' };
|
||||
}
|
||||
@@ -236,7 +260,14 @@ export async function startDeepSeekWeb(
|
||||
await new Promise((r) => setTimeout(r, READY_POLL_MS));
|
||||
}
|
||||
|
||||
await stopDeepSeekWeb();
|
||||
// Timeout: kill OUR child. Only route through stopDeepSeekWeb() while the
|
||||
// singleton is still ours — signalling `current` unconditionally here could
|
||||
// SIGTERM a healthy server a concurrent actor now owns.
|
||||
if (current === running) {
|
||||
await stopDeepSeekWeb();
|
||||
} else {
|
||||
killTree(running.child, 'SIGKILL');
|
||||
}
|
||||
const tail = output.trim().slice(-800);
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
+5
-1
@@ -109,7 +109,11 @@ export type HookEventType =
|
||||
| 'elicitation_response'
|
||||
| 'stop'
|
||||
| 'teammate_idle'
|
||||
| 'task_completed';
|
||||
| 'task_completed'
|
||||
// No Claude Code hook behind this one: it is the DeepSeek status bridge's
|
||||
// "a turn STARTED" report (see deepseek-status-shim.ts). Keep in step with
|
||||
// HookEventSchema in web/schemas.ts.
|
||||
| 'agent_working';
|
||||
|
||||
// ========== API Response Types ==========
|
||||
|
||||
|
||||
@@ -363,3 +363,39 @@ export function getDeepSeekCliVersion(): string | null {
|
||||
export function profileExists(name: string): boolean {
|
||||
return existsSync(join(resolveDshHome(), 'profiles', name, 'package.json'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a DeepSeek session cannot start, or null when it can.
|
||||
*
|
||||
* Availability for this mode is TWO questions, not one, because `dsh` is a
|
||||
* profile launcher rather than an agent: the binary must resolve (and prove it
|
||||
* is the harness and not Debian's dancer's shell), AND a profile that can occupy
|
||||
* a pane must exist. Every create path — both HTTP routes AND cron fires — must
|
||||
* ask this before constructing a Session, or the pane boots the box's default
|
||||
* profile, which may be a logging web server or a one-shot that exits on
|
||||
* arrival, and the prompt is typed into it.
|
||||
*/
|
||||
export function resolveDeepSeekLaunchError(requestedProfile?: string): string | null {
|
||||
if (!isDeepSeekAvailable()) return getDeepSeekNotFoundMessage();
|
||||
|
||||
const profiles = listDeepSeekProfiles();
|
||||
if (requestedProfile) {
|
||||
const match = profiles.find((p) => p.name === requestedProfile);
|
||||
if (!match) {
|
||||
return `DeepSeek Harness profile "${requestedProfile}" does not exist. Create it with: dsh plugin --profile ${requestedProfile} add <package>`;
|
||||
}
|
||||
if (match.kind === 'web' || match.kind === 'headless') {
|
||||
return `DeepSeek Harness profile "${requestedProfile}" is a ${match.kind} profile and cannot run in a terminal session. Pick an interactive profile, or open the web profile as a Codeman web tab.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!resolveDefaultDeepSeekProfile(profiles)) {
|
||||
return (
|
||||
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only the web and headless ' +
|
||||
'profiles, so the terminal agent comes from a plugin — install one with: ' +
|
||||
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui'
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -657,7 +657,8 @@
|
||||
</button>
|
||||
<!-- The DeepSeek Harness browser UI is the vendor's OWN interactive
|
||||
surface (the terminal one is third-party), so it gets a shortcut:
|
||||
this starts `dsh web` in a shell session and opens it as a tab.
|
||||
POST /api/deepseek/web starts a background `dsh web` fenced to
|
||||
this origin, and the URL opens as a managed web tab.
|
||||
Shown only when dsh is installed. -->
|
||||
<button class="run-mode-option run-mode-option--web" id="runModeDeepSeekWeb" style="display: none;" onclick="app.runDeepSeekWeb()">
|
||||
<span class="run-mode-dot deepseek"></span>DeepSeek web UI…
|
||||
|
||||
@@ -581,6 +581,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
menu.appendChild(header);
|
||||
|
||||
for (const webview of this.webviews ? this.webviews.values() : []) {
|
||||
// Managed records are Codeman-owned shortcut state (the DeepSeek web UI
|
||||
// writes one), not saved dashboards: same filter as the desktop run menu,
|
||||
// or the phone picker lists a stale 127.0.0.1:<port> row that dies on the
|
||||
// next server restart with no affordance here to restart it.
|
||||
if (webview.managed) continue;
|
||||
const option = document.createElement('button');
|
||||
option.type = 'button';
|
||||
option.className = 'mobile-overview-run-option';
|
||||
|
||||
@@ -577,7 +577,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!wvData.success) throw new Error(wvData.error || 'Failed to save the web tab');
|
||||
webview = wvData.data.webview || wvData.data;
|
||||
}
|
||||
await this.loadWebviews?.();
|
||||
// refreshWebviews, not a hopeful optional-chain: openWebview() reads
|
||||
// this.webviews and silently no-ops on an id it has not loaded, so
|
||||
// skipping the refresh made the FIRST click create the record but open
|
||||
// nothing (the SSE round-trip had not landed yet).
|
||||
await this.refreshWebviews?.();
|
||||
|
||||
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Serving on ${url} - opening it as a tab.`);
|
||||
if (webview?.id) await this.openWebview(webview.id);
|
||||
|
||||
@@ -3892,6 +3892,24 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* DeepSeek Harness: the #4d6bfe blue identity, matching
|
||||
.btn-toolbar.btn-run.mode-deepseek and .run-mode-dot.deepseek so the welcome
|
||||
action reads as the same backend. */
|
||||
.welcome-btn-deepseek {
|
||||
background: linear-gradient(135deg, #101a4d 0%, #2740c4 55%, #4d6bfe 100%);
|
||||
border-color: rgba(124, 147, 255, 0.4);
|
||||
color: #eef2ff;
|
||||
box-shadow: 0 2px 8px rgba(77, 107, 254, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.welcome-btn-deepseek:hover {
|
||||
background: linear-gradient(135deg, #16225f 0%, #3350e6 55%, #6b83ff 100%);
|
||||
box-shadow: 0 4px 20px rgba(77, 107, 254, 0.3), 0 0 40px rgba(39, 64, 196, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(150, 170, 255, 0.5);
|
||||
color: #f8faff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.welcome-btn-gemini {
|
||||
background: linear-gradient(135deg, #10243f 0%, #174ea6 55%, #4f46e5 100%);
|
||||
border-color: rgba(96, 165, 250, 0.4);
|
||||
|
||||
@@ -102,6 +102,19 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort):
|
||||
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
|
||||
}
|
||||
// A dsh approval is an ALERT, not an answerable card: the dialog belongs to
|
||||
// a third-party TUI whose keystroke contract Codeman has not measured, the
|
||||
// Claude-shaped option parser never reads options off its frames, and
|
||||
// verifyStillAnswerable() can therefore never be conclusive for it — so the
|
||||
// '1'/Esc below would be a blind keystroke into a foreign composer. The item
|
||||
// still raises the red alert and clears on the harness's own working/stop
|
||||
// reports; answering happens in the terminal.
|
||||
if (session.mode === 'deepseek') {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'DeepSeek Harness approvals must be answered in the terminal: the dialog belongs to a third-party TUI whose keystrokes Codeman cannot verify.'
|
||||
);
|
||||
}
|
||||
|
||||
// Re-capture the pane before aiming keystrokes at it: if the dialog was
|
||||
// answered in the terminal moments ago, the digit would land in whatever
|
||||
|
||||
@@ -36,6 +36,23 @@ const APPROVAL_KIND_BY_EVENT: Record<string, ApprovalKind> = {
|
||||
*/
|
||||
const APPROVAL_RESOLVING_EVENTS = new Set(['stop', 'elicitation_complete', 'elicitation_response', 'agent_working']);
|
||||
|
||||
/**
|
||||
* Last DeepSeek status-bridge sequence number seen per session.
|
||||
*
|
||||
* The Herdr contract the dsh TUI speaks stamps every report with `--seq <n>`
|
||||
* and RETRIES failed deliveries with backoff — so a stale report can land
|
||||
* AFTER a newer one, and applying it in arrival order resolves an approval
|
||||
* with a retried `working` while the harness sits blocked, or releases a wait
|
||||
* with a retried `idle` mid-turn. A report whose seq is not newer than the
|
||||
* last accepted one is dropped, but only inside a short window: the TUI's
|
||||
* retry backoff is seconds, so a LOWER seq arriving after the window is a
|
||||
* restarted TUI's fresh numbering (same pane, new generation), not a stale
|
||||
* retry, and must be accepted. Insertion-order eviction bounds the map.
|
||||
*/
|
||||
const dshSeqBySession = new Map<string, { seq: number; at: number }>();
|
||||
const DSH_SEQ_STALE_WINDOW_MS = 60_000;
|
||||
const DSH_SEQ_MAX_SESSIONS = 500;
|
||||
|
||||
export function registerHookEventRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
|
||||
@@ -46,6 +63,22 @@ export function registerHookEventRoutes(
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
|
||||
// DeepSeek status-bridge ordering: drop a stale retried report (see
|
||||
// dshSeqBySession above). Success rather than an error, so the shim exits 0
|
||||
// and the TUI does not keep retrying a report that will stay stale.
|
||||
if (data && data.source === 'dsh-status-shim' && typeof data.seq === 'number') {
|
||||
const last = dshSeqBySession.get(sessionId);
|
||||
const now = Date.now();
|
||||
if (last && data.seq <= last.seq && now - last.at < DSH_SEQ_STALE_WINDOW_MS) {
|
||||
return {};
|
||||
}
|
||||
if (!dshSeqBySession.has(sessionId) && dshSeqBySession.size >= DSH_SEQ_MAX_SESSIONS) {
|
||||
const oldest = dshSeqBySession.keys().next().value;
|
||||
if (oldest !== undefined) dshSeqBySession.delete(oldest);
|
||||
}
|
||||
dshSeqBySession.set(sessionId, { seq: data.seq, at: now });
|
||||
}
|
||||
|
||||
// Wake anything blocked on `GET /api/sessions/:id/wait`. Hooks are the only
|
||||
// DEFINITIVE signals Codeman gets (`idle` is inferred from output stabilization
|
||||
// and can flap mid-turn), so these two are what an orchestrating agent should
|
||||
@@ -163,12 +196,15 @@ export function registerHookEventRoutes(
|
||||
// the browser loaded with. Debounced, so a hook burst costs one broadcast.
|
||||
ctx.broadcastSessionStateDebounced(sessionId);
|
||||
|
||||
// Send push notifications for hook events
|
||||
// Send push notifications for hook events. Push Approve/Deny actions ride
|
||||
// on approvalId, and the answer route refuses keystrokes for dsh dialogs
|
||||
// (third-party TUI, unmeasured contract) — so a dsh push stays a plain
|
||||
// notification instead of offering buttons whose answer would be refused.
|
||||
ctx.sendPushNotifications(`hook:${event}`, {
|
||||
sessionId,
|
||||
sessionName,
|
||||
...safeData,
|
||||
...(approvalId && { approvalId }),
|
||||
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
|
||||
});
|
||||
|
||||
// Track in run summary
|
||||
|
||||
@@ -394,11 +394,12 @@ 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.
|
||||
* hands back privilege the config clamp above just removed — or, for the last,
|
||||
* redirects a credential the server injects.
|
||||
*
|
||||
* 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.
|
||||
* All are DeepSeek's, and all 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
|
||||
@@ -407,8 +408,14 @@ export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
|
||||
* - `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 `_configureDeepSeek()`
|
||||
* 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.)
|
||||
*/
|
||||
const OWNER_CLAMPED_ENV_KEYS = ['DSH_PERMISSION_MODE', 'DSH_HOME'] as const;
|
||||
const OWNER_CLAMPED_ENV_KEYS = ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'] as const;
|
||||
|
||||
/**
|
||||
* Env-var half of the multi-user bypass clamp.
|
||||
@@ -456,30 +463,11 @@ export const _clampEnvOverridesForOwner = clampEnvOverridesForOwner;
|
||||
* and exits, so both would present as "the tab immediately died".
|
||||
*/
|
||||
async function resolveDeepSeekLaunchError(requestedProfile?: string): Promise<string | null> {
|
||||
const { isDeepSeekAvailable, getDeepSeekNotFoundMessage, listDeepSeekProfiles, resolveDefaultDeepSeekProfile } =
|
||||
await import('../../utils/deepseek-cli-resolver.js');
|
||||
if (!isDeepSeekAvailable()) return getDeepSeekNotFoundMessage();
|
||||
|
||||
const profiles = listDeepSeekProfiles();
|
||||
if (requestedProfile) {
|
||||
const match = profiles.find((p) => p.name === requestedProfile);
|
||||
if (!match) {
|
||||
return `DeepSeek Harness profile "${requestedProfile}" does not exist. Create it with: dsh plugin --profile ${requestedProfile} add <package>`;
|
||||
}
|
||||
if (match.kind === 'web' || match.kind === 'headless') {
|
||||
return `DeepSeek Harness profile "${requestedProfile}" is a ${match.kind} profile and cannot run in a terminal session. Pick an interactive profile, or open the web profile as a Codeman web tab.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!resolveDefaultDeepSeekProfile()) {
|
||||
return (
|
||||
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only the web and headless ' +
|
||||
'profiles, so the terminal agent comes from a plugin — install one with: ' +
|
||||
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui'
|
||||
);
|
||||
}
|
||||
return null;
|
||||
// Thin async wrapper: the implementation moved into the resolver module so
|
||||
// CRON fires can ask the same question before constructing a Session; the
|
||||
// dynamic import keeps this file's startup free of the probe machinery.
|
||||
const { resolveDeepSeekLaunchError: impl } = await import('../../utils/deepseek-cli-resolver.js');
|
||||
return impl(requestedProfile);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1299,6 +1287,7 @@ export function registerSessionRoutes(
|
||||
session.mode !== 'antigravity' &&
|
||||
session.mode !== 'pi' &&
|
||||
session.mode !== 'grok' &&
|
||||
session.mode !== 'deepseek' &&
|
||||
ctx.store.getConfig().ralphEnabled &&
|
||||
!session.ralphTracker.autoEnableDisabled
|
||||
) {
|
||||
@@ -2921,6 +2910,7 @@ export function registerSessionRoutes(
|
||||
antigravityConfig ||
|
||||
piConfig ||
|
||||
grokConfig ||
|
||||
deepSeekConfig ||
|
||||
openCodeConfig
|
||||
) {
|
||||
return createErrorResponse(
|
||||
|
||||
@@ -547,7 +547,16 @@ export function registerSystemRoutes(
|
||||
return { success: true, data: getDeepSeekWebStatus() };
|
||||
});
|
||||
|
||||
app.delete('/api/deepseek/web', async () => {
|
||||
app.delete('/api/deepseek/web', async (req) => {
|
||||
// Same bar as POST: the server is a single shared instance, so in
|
||||
// multi-user mode stopping it out from under other users' tabs is a
|
||||
// privileged act (single-user and granted owners are unaffected).
|
||||
if (isMultiUserMode() && !(await canUsernameRunPrivilegedCommands(getAuthUser(req).username))) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.FORBIDDEN,
|
||||
'Stopping the DeepSeek web UI requires the can-bypass-permissions grant'
|
||||
);
|
||||
}
|
||||
const { stopDeepSeekWeb } = await import('../../deepseek-web-server.js');
|
||||
await stopDeepSeekWeb();
|
||||
return { success: true, data: { stopped: true } };
|
||||
|
||||
@@ -179,6 +179,16 @@ export interface HookCapabilityOptions {
|
||||
* session emit hook events at all.
|
||||
*/
|
||||
deepSeekStatusReporting?: boolean;
|
||||
/**
|
||||
* True when the pane's harness runs somewhere the status bridge cannot reach:
|
||||
* a docker case (`docker exec` does not carry the local tmux env into the
|
||||
* container, and the loopback-bound API is unreachable from it) or a
|
||||
* remote-SSH case (the `HERDR_*` triple is set on the LOCAL ssh process, not
|
||||
* the remote shell). Such a session never posts a hook event however the
|
||||
* statusReporting flag is set, so `until=stop` on it would burn its whole
|
||||
* timeout on every turn.
|
||||
*/
|
||||
deepSeekBridgeUnreachable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,7 +231,9 @@ export function hooksAvailableForMode(mode: SessionMode, options: HookCapability
|
||||
// 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;
|
||||
if (mode === 'deepseek') {
|
||||
return options.deepSeekStatusReporting !== false && options.deepSeekBridgeUnreachable !== true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -234,8 +246,15 @@ export function hooksAvailableForMode(mode: SessionMode, options: HookCapability
|
||||
* 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 };
|
||||
export function sessionHookOptions(session: {
|
||||
deepSeekStatusReporting?: boolean;
|
||||
docker?: unknown;
|
||||
remote?: unknown;
|
||||
}): HookCapabilityOptions {
|
||||
return {
|
||||
deepSeekStatusReporting: session.deepSeekStatusReporting,
|
||||
deepSeekBridgeUnreachable: Boolean(session.docker || session.remote),
|
||||
};
|
||||
}
|
||||
|
||||
/** Outcome of resolving a caller-supplied wait target against a session's mode. */
|
||||
@@ -291,8 +310,11 @@ export function resolveWaitSignals(
|
||||
// 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.`
|
||||
? options.deepSeekBridgeUnreachable
|
||||
? `Signal(s) ${rejected.join(', ')} never fire for this deepseek session: it runs in a container or on ` +
|
||||
`a remote host, where the local status bridge cannot reach the harness. Use idle or exit.`
|
||||
: `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.`,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user