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:
Codeman maintainer
2026-08-25 19:01:29 +02:00
parent c30dfaf0e7
commit a628737d1f
18 changed files with 311 additions and 74 deletions
+32 -1
View File
@@ -16,7 +16,7 @@ import { buildSpawnCommand } from '../src/tmux-manager.js';
import { defaultDockerCommandForMode } from '../src/docker-hosts.js';
import { defaultRemoteCommandForMode } from '../src/remote-hosts.js';
import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js';
import { hooksAvailableForMode, resolveWaitSignals } from '../src/web/session-wait-registry.js';
import { hooksAvailableForMode, resolveWaitSignals, sessionHookOptions } from '../src/web/session-wait-registry.js';
import { _clampExternalCliBypassForOwner, _clampEnvOverridesForOwner } from '../src/web/routes/session-routes.js';
import { DEEPSEEK_STATE_TO_HOOK_EVENT } from '../src/deepseek-status-shim.js';
import { readFileSync } from 'node:fs';
@@ -228,6 +228,24 @@ describe('DeepSeek status bridge', () => {
expect(resolveWaitSignals(undefined, on).until).toContain('stop');
});
it('refuses stop/blocked on a docker or remote dsh session, where the bridge cannot reach the harness', () => {
// `docker exec` does not carry the local tmux env into the container and the
// remote shell never sees the local `HERDR_*` setenv, so such a session can
// never post a hook event however statusReporting is set — accepting
// `until=stop` there burns the caller's whole timeout on every turn.
expect(hooksAvailableForMode('deepseek', { deepSeekBridgeUnreachable: true })).toBe(false);
const unreachable = { mode: 'deepseek' as const, deepSeekBridgeUnreachable: true };
const rejected = resolveWaitSignals('stop', unreachable);
expect(rejected.until).toEqual([]);
expect(rejected.error).toContain('container or on a remote host');
// The default set degrades instead of erroring, exactly like the disarmed case.
expect(resolveWaitSignals(undefined, unreachable)).toEqual({ until: ['idle', 'exit'], error: null });
// sessionHookOptions() is what lifts the fact off a live session.
expect(sessionHookOptions({ docker: { containerName: 'c' } }).deepSeekBridgeUnreachable).toBe(true);
expect(sessionHookOptions({ remote: { hostId: 'h' } }).deepSeekBridgeUnreachable).toBe(true);
expect(sessionHookOptions({}).deepSeekBridgeUnreachable).toBe(false);
});
it('keeps the hook predicate out of the two gates that mean "is this claude"', () => {
// Read My Mind and intent capture read Claude's own transcript, so they mean
// mode === 'claude'. They used to ask hooksAvailableForMode(), which was the
@@ -323,6 +341,19 @@ describe('DeepSeek multi-user clamp: the env-var half', () => {
expect(out).toEqual({});
});
it("strips DEEPSEEK_BASE_URL, which would aim the server's own forwarded API key at a foreign host", async () => {
// _configureDeepSeek() exports the SERVER's DEEPSEEK_API_KEY into every dsh
// pane, and applyEnvOverrides() lands after it — so a non-granted owner who
// could set the base URL would have the operator's key sent as a bearer
// credential to an endpoint of their choosing. Their OWN key stays settable:
// that removes privilege rather than granting it.
const out = await _clampEnvOverridesForOwner('nobody', {
DEEPSEEK_BASE_URL: 'https://attacker.example/v1',
DEEPSEEK_API_KEY: 'sk-their-own',
});
expect(out).toEqual({ DEEPSEEK_API_KEY: 'sk-their-own' });
});
it('leaves unrelated overrides alone, and returns the same object when there is nothing to strip', async () => {
const input = { DEEPSEEK_API_KEY: 'sk-test', CODEX_HOME: '/tmp/cx' };
const out = await _clampEnvOverridesForOwner('nobody', input);