delete the claude-done hook and its unauthenticated endpoint
The chain was: a Stop hook in Claude's settings curls POST /api/hooks/claude-done, the platform POSTs /_officer/panel-refresh to the pty sidecar, the sidecar sends a `panel-refresh` frame to every attached terminal, and the Claude Code panel bumps `preview:refresh` and `files:refresh-signal`. It has never fired. generateClaudeSettings writes settings.json into the MANAGED home under DATA_PATH, but HOME_DIR points terminals at the owner's real login home — which is where Claude reads its settings from. Verified on this machine: no claude-done hook exists in ~/.claude/settings.json, and DATA_PATH/*/home/.claude does not exist at all. Deleting rather than repairing it, because the Chat panel already does exactly this job from onTurnComplete — in-process, conditioned on the turn having made tool calls, with no hook, no HTTP round trip, and no endpoint. The chat UI is where agent work happens; the terminal TUI is not the destination. Also removes /api/hooks/claude-done, which was mounted above protectedRouter and so was the one unauthenticated write-ish endpoint on the API surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -255,10 +255,15 @@ Now the structural fix, which is also most of the Pass 1 claude findings:
|
|||||||
The rest of the Pass 1 claude findings, now unblocked: the transcript reader
|
The rest of the Pass 1 claude findings, now unblocked: the transcript reader
|
||||||
(`chat/claude-sessions.ts`, 361 lines), the turn-loop state machine (`chat/websocket.ts:168-312`),
|
(`chat/claude-sessions.ts`, 361 lines), the turn-loop state machine (`chat/websocket.ts:168-312`),
|
||||||
session CRUD (`chat/chat.ts`), the model catalogue (`chat/list-models.ts:5-9`), CLI install/auth
|
session CRUD (`chat/chat.ts`), the model catalogue (`chat/list-models.ts:5-9`), CLI install/auth
|
||||||
(`server-settings/claude-code.ts`), and `generate-container-context.ts`'s settings writer. Also point
|
(`server-settings/claude-code.ts`), and `generate-container-context.ts`'s settings writer.
|
||||||
the `Stop` hook at the sidecar instead of `http://localhost:5000/api/hooks/claude-done`
|
|
||||||
(`generate-container-context.ts:144-153`, `hono.ts:79-86`) — as written, that hook silently fails
|
The `Stop` hook that used to be part of this stage is gone rather than moved. It curled
|
||||||
during exactly the restart window we care about.
|
`http://localhost:5000/api/hooks/claude-done` so the file browser would refresh when the terminal's
|
||||||
|
Claude finished a turn, and it had never once fired: the writer installs it into the managed home under
|
||||||
|
`DATA_PATH`, while `HOME_DIR` points terminals at the owner's real login home, which is where Claude
|
||||||
|
reads its settings from. The whole chain — unauthenticated endpoint, sidecar `POST /_officer/panel-refresh`,
|
||||||
|
`panel-refresh` frame, `onPanelRefresh` prop — was deleted on 2026-08-04. The Chat panel already does the
|
||||||
|
same job from `onTurnComplete`, in-process, with no hook and no HTTP round trip.
|
||||||
|
|
||||||
### Stage 5 — the harder question: surviving a *sidecar* restart
|
### Stage 5 — the harder question: surviving a *sidecar* restart
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||||
|
|
||||||
// `/api/terminal/*` is a plain auth-and-forward proxy onto the pty sidecar's own listener, like every
|
// `/api/terminal/*` is a plain auth-and-forward proxy onto the pty sidecar's own listener, like every
|
||||||
// other HTTP sidecar. The sidecar owns `/_officer/sessions` (list), `DELETE /_officer/sessions/:id` (kill)
|
// other HTTP sidecar. The sidecar owns `/_officer/sessions` (list) and `DELETE /_officer/sessions/:id`
|
||||||
// and `POST /_officer/panel-refresh`; the platform knows none of those contracts, only where to send them.
|
// (kill); the platform knows neither contract, only where to send them.
|
||||||
//
|
//
|
||||||
// `/api/terminal/ws` does NOT come through here — Bun's route table takes it first and hands it to the
|
// `/api/terminal/ws` does NOT come through here — Bun's route table takes it first and hands it to the
|
||||||
// byte relay in `websocket.ts`.
|
// byte relay in `websocket.ts`.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ServerWebSocket } from 'bun';
|
import type { ServerWebSocket } from 'bun';
|
||||||
import { getTerminalWsUrl, getTerminalHttpUrl } from './sidecar-server';
|
import { getTerminalWsUrl } from './sidecar-server';
|
||||||
|
|
||||||
// Byte relay between the browser and the pty sidecar's own socket.
|
// Byte relay between the browser and the pty sidecar's own socket.
|
||||||
//
|
//
|
||||||
@@ -93,12 +93,3 @@ export const terminalWebsocket = {
|
|||||||
|
|
||||||
drain() {},
|
drain() {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// The claude-done hook asks attached terminals to refresh their panel. The sidecar holds those sockets
|
|
||||||
// now, so this is a POST to it rather than a loop over sockets officer used to own. Fire-and-forget: a
|
|
||||||
// missed refresh is a stale panel, not a failure worth surfacing.
|
|
||||||
export const broadcastPanelRefresh = (_email: string): void => {
|
|
||||||
const base = getTerminalHttpUrl();
|
|
||||||
if (!base) return;
|
|
||||||
fetch(`${base}/_officer/panel-refresh`, { method: 'POST' }).catch(() => {});
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -2,11 +2,8 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { itemsDir, getHomeDir, DATA_PATH } from '@@/data-path';
|
import { itemsDir, getHomeDir, DATA_PATH } from '@@/data-path';
|
||||||
|
|
||||||
type HookEntry = { type: string; command: string };
|
// Whatever the host's settings.json holds is preserved verbatim; we only add the two permission keys below.
|
||||||
type HookRule = { matcher?: Record<string, unknown>; hooks: HookEntry[] };
|
type ClaudeSettings = Record<string, unknown>;
|
||||||
type ClaudeSettings = Record<string, unknown> & {
|
|
||||||
hooks?: Record<string, HookRule[]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type FrontmatterEntry = { name: string; description: string };
|
type FrontmatterEntry = { name: string; description: string };
|
||||||
|
|
||||||
@@ -141,17 +138,11 @@ export function generateClaudeSettings(email: string, username?: string): string
|
|||||||
// no host settings
|
// no host settings
|
||||||
}
|
}
|
||||||
|
|
||||||
const hookCommand = `curl -s -X POST http://localhost:5000/api/hooks/claude-done -H 'Content-Type: application/json' -d '{"email":"${email}"}'`;
|
// A Stop hook used to be injected here, curling /api/hooks/claude-done so the file browser would refresh
|
||||||
const hooks = settings.hooks ?? {};
|
// when the terminal's Claude finished. It never fired: this writes to the MANAGED home under DATA_PATH,
|
||||||
const stopRules = hooks.Stop ?? [];
|
// while HOME_DIR points terminals at the owner's real login home, where Claude actually reads its
|
||||||
const hasOurHook = stopRules.some((rule) => rule.hooks?.some((h) => h.command?.includes('/api/hooks/claude-done')));
|
// settings. The chat UI — the destination for agent work — refreshes those panels itself from
|
||||||
|
// onTurnComplete, with no hook, no HTTP round trip and no unauthenticated endpoint.
|
||||||
if (!hasOurHook) {
|
|
||||||
stopRules.push({ hooks: [{ type: 'command', command: hookCommand }] });
|
|
||||||
}
|
|
||||||
|
|
||||||
hooks.Stop = stopRules;
|
|
||||||
settings.hooks = hooks;
|
|
||||||
settings.defaultMode = 'bypassPermissions';
|
settings.defaultMode = 'bypassPermissions';
|
||||||
settings.skipDangerousModePermissionPrompt = true;
|
settings.skipDangerousModePermissionPrompt = true;
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import { desktopRouter } from './api/desktop/rest';
|
|||||||
import { bugReportRouter } from './api/bug-report/bug-report';
|
import { bugReportRouter } from './api/bug-report/bug-report';
|
||||||
import { chatRouter } from './api/chat/chat';
|
import { chatRouter } from './api/chat/chat';
|
||||||
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
|
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
|
||||||
import { broadcastPanelRefresh } from './api/terminal/websocket';
|
|
||||||
import { CustomError } from './custom-errors';
|
import { CustomError } from './custom-errors';
|
||||||
import { userMiddleware, bodyParser, isOriginAllowed, originScopeMiddleware } from './_middlewares';
|
import { userMiddleware, bodyParser, isOriginAllowed, originScopeMiddleware } from './_middlewares';
|
||||||
import { isMusicOriginExempt } from './_middlewares/origin-validation';
|
import { isMusicOriginExempt } from './_middlewares/origin-validation';
|
||||||
@@ -84,14 +83,6 @@ honoServer.route('/api/waitlist', waitlistRouter);
|
|||||||
// notifications WebSocket is upgraded at the serve level (server.tsx).
|
// notifications WebSocket is upgraded at the serve level (server.tsx).
|
||||||
honoServer.route('/api/vault', vaultRouter);
|
honoServer.route('/api/vault', vaultRouter);
|
||||||
honoServer.get('/api/integrations/google/callback', googleCallbackHandler);
|
honoServer.get('/api/integrations/google/callback', googleCallbackHandler);
|
||||||
honoServer.post('/api/hooks/claude-done', async (ctx) => {
|
|
||||||
const body = await ctx.req.json().catch(() => null);
|
|
||||||
const email = (body as Record<string, unknown> | null)?.email;
|
|
||||||
if (typeof email === 'string' && email.includes('@')) {
|
|
||||||
broadcastPanelRefresh(email);
|
|
||||||
}
|
|
||||||
return ctx.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
const protectedRouter = createRouter();
|
const protectedRouter = createRouter();
|
||||||
protectedRouter.use(bodyParser());
|
protectedRouter.use(bodyParser());
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import * as store from './sessions.mjs';
|
|||||||
// browser and relays bytes here without reading them.
|
// browser and relays bytes here without reading them.
|
||||||
//
|
//
|
||||||
// The socket protocol is the one the browser already speaks, unchanged: {input,resize} in,
|
// The socket protocol is the one the browser already speaks, unchanged: {input,resize} in,
|
||||||
// {output,replay,exit,panel-refresh} out. That is deliberate — the frontend did not have to move for the
|
// {output,replay,exit} out. That is deliberate — the frontend did not have to move for the
|
||||||
// transport to.
|
// transport to.
|
||||||
|
|
||||||
const wsSend = (socket) => ({
|
const wsSend = (socket) => ({
|
||||||
@@ -37,12 +37,6 @@ export function startServer() {
|
|||||||
return json(res, killed ? 200 : 404, { ok: killed });
|
return json(res, killed ? 200 : 404, { ok: killed });
|
||||||
}
|
}
|
||||||
|
|
||||||
// The claude-done hook: tell attached terminals to refresh their panel.
|
|
||||||
if (url.pathname === '/_officer/panel-refresh' && req.method === 'POST') {
|
|
||||||
store.broadcastPanelRefresh();
|
|
||||||
return json(res, 200, { ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
json(res, 404, { error: 'not found' });
|
json(res, 404, { error: 'not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -170,11 +170,6 @@ export function list() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tell every attached client to refresh its panel — fired by the platform's claude-done hook. */
|
|
||||||
export function broadcastPanelRefresh() {
|
|
||||||
for (const session of sessions.values()) broadcast(session, { type: 'panel-refresh' });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function killAll() {
|
export function killAll() {
|
||||||
for (const session of sessions.values()) {
|
for (const session of sessions.values()) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -11,13 +11,15 @@ type CommandTerminalWrapperProps = {
|
|||||||
panelId: string;
|
panelId: string;
|
||||||
command: string;
|
command: string;
|
||||||
statePrefix: string;
|
statePrefix: string;
|
||||||
onPanelRefresh?: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CommandTerminalWrapper = ({ panelId, command, statePrefix, onPanelRefresh }: CommandTerminalWrapperProps) => {
|
export const CommandTerminalWrapper = ({ panelId, command, statePrefix }: CommandTerminalWrapperProps) => {
|
||||||
const { dashboardId, cwd } = useWorkspace();
|
const { dashboardId, cwd } = useWorkspace();
|
||||||
const stateKey = dashboardId ? `ws-${statePrefix}-${dashboardId}` : `ws-${statePrefix}-default`;
|
const stateKey = dashboardId ? `ws-${statePrefix}-${dashboardId}` : `ws-${statePrefix}-default`;
|
||||||
const { value: terminals, setValue: setTerminals } = useDashboardState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
const { value: terminals, setValue: setTerminals } = useDashboardState<Record<string, string>>(
|
||||||
|
stateKey,
|
||||||
|
EMPTY_TERMINALS,
|
||||||
|
);
|
||||||
const sessionId = terminals[panelId];
|
const sessionId = terminals[panelId];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,7 +45,6 @@ export const CommandTerminalWrapper = ({ panelId, command, statePrefix, onPanelR
|
|||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
initialInput={fullCommand}
|
initialInput={fullCommand}
|
||||||
onPanelRefresh={onPanelRefresh}
|
|
||||||
onConnectionChange={onConnectionChange}
|
onConnectionChange={onConnectionChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ export type TerminalViewProps = {
|
|||||||
onExit?: () => void;
|
onExit?: () => void;
|
||||||
onCommandDone?: (exitCode: number, output: string) => void;
|
onCommandDone?: (exitCode: number, output: string) => void;
|
||||||
onDisconnect?: () => void;
|
onDisconnect?: () => void;
|
||||||
onPanelRefresh?: () => void;
|
|
||||||
onConnectionChange?: (state: TerminalConnectionState) => void;
|
onConnectionChange?: (state: TerminalConnectionState) => void;
|
||||||
/** The shell's own title (OSC 0/2) — what's actually running, for the panel header. */
|
/** The shell's own title (OSC 0/2) — what's actually running, for the panel header. */
|
||||||
onTitleChange?: (title: string) => void;
|
onTitleChange?: (title: string) => void;
|
||||||
@@ -89,7 +88,6 @@ export const TerminalView = ({
|
|||||||
onExit,
|
onExit,
|
||||||
onCommandDone,
|
onCommandDone,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
onPanelRefresh,
|
|
||||||
onConnectionChange,
|
onConnectionChange,
|
||||||
onTitleChange,
|
onTitleChange,
|
||||||
onBell,
|
onBell,
|
||||||
@@ -105,7 +103,6 @@ export const TerminalView = ({
|
|||||||
const onExitRef = useRef<TerminalViewProps['onExit']>(onExit);
|
const onExitRef = useRef<TerminalViewProps['onExit']>(onExit);
|
||||||
const onCommandDoneRef = useRef<TerminalViewProps['onCommandDone']>(onCommandDone);
|
const onCommandDoneRef = useRef<TerminalViewProps['onCommandDone']>(onCommandDone);
|
||||||
const onDisconnectRef = useRef<TerminalViewProps['onDisconnect']>(onDisconnect);
|
const onDisconnectRef = useRef<TerminalViewProps['onDisconnect']>(onDisconnect);
|
||||||
const onPanelRefreshRef = useRef<TerminalViewProps['onPanelRefresh']>(onPanelRefresh);
|
|
||||||
const onConnectionChangeRef = useRef<TerminalViewProps['onConnectionChange']>(onConnectionChange);
|
const onConnectionChangeRef = useRef<TerminalViewProps['onConnectionChange']>(onConnectionChange);
|
||||||
const commandRef = useRef(command);
|
const commandRef = useRef(command);
|
||||||
const initialInputRef = useRef(initialInput);
|
const initialInputRef = useRef(initialInput);
|
||||||
@@ -114,7 +111,6 @@ export const TerminalView = ({
|
|||||||
onExitRef.current = onExit;
|
onExitRef.current = onExit;
|
||||||
onCommandDoneRef.current = onCommandDone;
|
onCommandDoneRef.current = onCommandDone;
|
||||||
onDisconnectRef.current = onDisconnect;
|
onDisconnectRef.current = onDisconnect;
|
||||||
onPanelRefreshRef.current = onPanelRefresh;
|
|
||||||
onConnectionChangeRef.current = onConnectionChange;
|
onConnectionChangeRef.current = onConnectionChange;
|
||||||
commandRef.current = command;
|
commandRef.current = command;
|
||||||
initialInputRef.current = initialInput;
|
initialInputRef.current = initialInput;
|
||||||
@@ -295,8 +291,6 @@ export const TerminalView = ({
|
|||||||
onExitRef.current?.();
|
onExitRef.current?.();
|
||||||
} else if (msg.type === 'detached') {
|
} else if (msg.type === 'detached') {
|
||||||
term.write('\r\n[Session taken over]\r\n');
|
term.write('\r\n[Session taken over]\r\n');
|
||||||
} else if (msg.type === 'panel-refresh') {
|
|
||||||
onPanelRefreshRef.current?.();
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||||
import { TerminalSquare, Monitor, Columns2, PenLine, Sparkles, ListTree } from 'lucide-react';
|
import { TerminalSquare, Monitor, Columns2, PenLine, Sparkles, ListTree } from 'lucide-react';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
|
||||||
import { TerminalWrapper } from './TerminalWrapper';
|
import { TerminalWrapper } from './TerminalWrapper';
|
||||||
import { HostTerminalWrapper } from './HostTerminalWrapper';
|
import { HostTerminalWrapper } from './HostTerminalWrapper';
|
||||||
import { CommandTerminalWrapper } from './CommandTerminalWrapper';
|
import { CommandTerminalWrapper } from './CommandTerminalWrapper';
|
||||||
@@ -29,24 +27,12 @@ const NvimWrapper = ({ panelId }: { panelId: string }) => (
|
|||||||
<CommandTerminalWrapper panelId={panelId} command="nvim" statePrefix="nvim" />
|
<CommandTerminalWrapper panelId={panelId} command="nvim" statePrefix="nvim" />
|
||||||
);
|
);
|
||||||
|
|
||||||
const ClaudeCodeWrapper = ({ panelId }: { panelId: string }) => {
|
// No turn-complete refresh here. Claude's Stop hook was supposed to drive one, but it was written into the
|
||||||
const [, setPreviewRefresh] = usePanelChannel<number>('preview:refresh', 0);
|
// managed home under DATA_PATH while HOME_DIR points terminals at the owner's real login home — so it was
|
||||||
const [, setFilesRefresh] = usePanelChannel<number>('files:refresh-signal', 0);
|
// never installed and never fired. The Chat panel does the same job from onTurnComplete, in-process.
|
||||||
|
const ClaudeCodeWrapper = ({ panelId }: { panelId: string }) => (
|
||||||
const onPanelRefresh = useCallback(() => {
|
<CommandTerminalWrapper panelId={panelId} command="claude --dangerously-skip-permissions" statePrefix="claude-code" />
|
||||||
setPreviewRefresh(Date.now());
|
);
|
||||||
setFilesRefresh(Date.now());
|
|
||||||
}, [setPreviewRefresh, setFilesRefresh]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CommandTerminalWrapper
|
|
||||||
panelId={panelId}
|
|
||||||
command="claude --dangerously-skip-permissions"
|
|
||||||
statePrefix="claude-code"
|
|
||||||
onPanelRefresh={onPanelRefresh}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user