mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user