Skip to content

Commit a4902c6

Browse files
committed
fix(hooks): fail-closed writes, exact hook identity, /ws privilege gate
1 parent 43d5602 commit a4902c6

31 files changed

Lines changed: 2998 additions & 305 deletions

CLAUDE.md

Lines changed: 33 additions & 20 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,15 @@ pixel-agents --help
108108

109109
The default bind address is `127.0.0.1`. Binding to `0.0.0.0` exposes the UI and WebSocket to the local network; do this only on a trusted network.
110110

111+
Open the URL the CLI prints — it carries a `?token=` for this session. Any browser can watch the office without it, but installing or removing hooks (which edits your agent tool's own settings file) is only offered to a session that has the token, so an untokened client on the network cannot approve it. Open the bare address instead and the hooks toggle in Settings is refused, and reports the actual install state rather than appearing to work.
112+
113+
Treat that URL as a secret: the token is a bearer capability, not proof of being local. Whoever holds it can approve the hook install from anywhere the server is reachable — so don't paste the URL into a shared channel, and note that it also lands in your browser history and (unredacted) in the server's own request log.
114+
111115
Pass `--no-terminal` to disable the embedded terminal — watch agents without launching or attaching to them from the browser.
112116

113117
### Running the extension and standalone together
114118

115-
The extension and standalone CLI can run at the same time. Each server registers under `~/.pixel-agents/servers/`; the Claude hook script sends events to all active registrations. VS Code and standalone keep separate agents, seats, and settings while using the shared office layout.
119+
The extension and standalone CLI can run at the same time. Each server registers under `~/.pixel-agents/servers/`; the hook script sends events to all active registrations. VS Code and standalone keep separate agents, seats, and settings while using the shared office layout.
116120

117121
Stop a standalone server with **Ctrl+C**. It removes only its own registration.
118122

adapters/vscode/PixelAgentsViewProvider.ts

Lines changed: 119 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
writeLayoutToFile,
3535
} from '../../server/src/layoutPersistence.js';
3636
import { PathSet } from '../../server/src/pathKey.js';
37+
import { CONSENT_INSTALL_MESSAGE } from '../../server/src/providers/hook/claude/consentCopy.js';
3738
import { claudeProvider, copyHookScript } from '../../server/src/providers/index.js';
3839
import { PixelAgentsServer } from '../../server/src/server.js';
3940
import {
@@ -220,37 +221,137 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider {
220221
});
221222
}
222223

223-
/** Install hooks + copy the hook script, surfacing installer errors
224-
* (e.g. an unparseable settings.json) instead of swallowing them. */
225-
private installHooksAndScript(port: number | undefined, token: string | undefined): void {
226-
claudeProvider
227-
.installHooks(port !== undefined ? `http://127.0.0.1:${port}` : '', token ?? '')
228-
.then(() => {
229-
this.sendOrBuffer({ type: 'hooksStatus', installed: true });
230-
})
231-
.catch((err: unknown) => {
224+
/** Copy the hook script, THEN install the settings.json entries, surfacing
225+
* every failure instead of swallowing it.
226+
*
227+
* Script first, deliberately: an entry whose command points at a script that
228+
* is not on disk makes Claude Code spawn a dead `node` for every event, so a
229+
* failed copy must abort the install rather than run alongside it. And
230+
* `hooksStatus: true` is sent ONLY after both steps succeeded — it reports
231+
* actual install state, never intent (core/asyncapi.yaml). */
232+
private async installHooksAndScript(
233+
port: number | undefined,
234+
token: string | undefined,
235+
): Promise<void> {
236+
if (!copyHookScript(this.context.extensionPath)) {
237+
vscode.window.showErrorMessage(
238+
'Pixel Agents: could not install the hook script — hooks not installed.',
239+
);
240+
await this.reportHooksStatus();
241+
return;
242+
}
243+
try {
244+
await claudeProvider.installHooks(
245+
port !== undefined ? `http://127.0.0.1:${port}` : '',
246+
token ?? '',
247+
);
248+
} catch (err: unknown) {
249+
vscode.window.showErrorMessage(
250+
`Pixel Agents: ${err instanceof Error ? err.message : String(err)}`,
251+
);
252+
await this.reportHooksStatus();
253+
return;
254+
}
255+
this.sendOrBuffer({ type: 'hooksStatus', installed: true });
256+
}
257+
258+
/**
259+
* The ordinary Settings-toggle path (the consent gate is separate, below).
260+
*
261+
* The preference is persisted only AFTER the install/uninstall settled, and
262+
* only when the resulting on-disk state agrees with what was asked. Writing
263+
* it first is how a user gets stranded: a failed uninstall leaves the entries
264+
* on disk and still firing, while a persisted hooks-off makes the next
265+
* activation skip the consent/install path entirely — never asked again, and
266+
* the checkbox reads "off" so clicking it would install rather than remove.
267+
*/
268+
private async setHooksEnabled(enabled: boolean): Promise<void> {
269+
if (enabled) {
270+
// An explicit Settings toggle IS the consent to modify settings.json.
271+
grantHooksConsent();
272+
const serverConfig = this.pixelAgentsServer?.getConfig();
273+
await this.installHooksAndScript(serverConfig?.port, serverConfig?.token);
274+
} else {
275+
try {
276+
await claudeProvider.uninstallHooks();
277+
} catch (err: unknown) {
232278
vscode.window.showErrorMessage(
233279
`Pixel Agents: ${err instanceof Error ? err.message : String(err)}`,
234280
);
281+
}
282+
}
283+
// Re-derive rather than trust the call: installHooksAndScript already
284+
// swallows its own failures into an error message, and either way the file
285+
// on disk is the only authority for what is actually firing.
286+
let installed: boolean;
287+
try {
288+
installed = await claudeProvider.areHooksInstalled();
289+
} catch {
290+
return; // the failure was already surfaced; do not also persist on a guess
291+
}
292+
if (installed === enabled) {
293+
this.adapter.setSetting(GLOBAL_KEY_HOOKS_ENABLED, enabled);
294+
this.runtime.hooksEnabled.current = enabled;
295+
console.log(`[Pixel Agents] Hooks ${enabled ? 'enabled' : 'disabled'} by user`);
296+
}
297+
// Report the truth either way: on failure the entries are still on disk and
298+
// still firing, and a checkbox stuck "off" over live hooks offers no retry.
299+
this.sendOrBuffer({ type: 'hooksStatus', installed });
300+
}
301+
302+
/** Broadcast the ACTUAL install state, re-derived from settings.json.
303+
* Every failure path calls this so the Settings checkbox — which renders
304+
* install state, not the preference — self-corrects instead of keeping the
305+
* optimistic value the click produced. Standalone gets this for free
306+
* (clientMessageHandler re-derives after every toggle); VS Code has no
307+
* equivalent seam, so failure paths report explicitly. */
308+
private async reportHooksStatus(): Promise<void> {
309+
try {
310+
this.sendOrBuffer({
311+
type: 'hooksStatus',
312+
installed: await claudeProvider.areHooksInstalled(),
235313
});
236-
if (!copyHookScript(this.context.extensionPath)) {
237-
console.warn('[Pixel Agents] Hook script not copied, hooks may not fire');
314+
} catch {
315+
// Never let a status broadcast mask the error already surfaced.
238316
}
239317
}
240318

241319
/** First-run consent gate: never touch ~/.claude/settings.json until the
242320
* user has approved it once (persisted in config.json, shared with the
243-
* standalone CLI). Hooks already present count as consent granted to a
244-
* pre-consent version. "Not Now" and a plain dismissal both leave
245-
* everything untouched and ask again next startup; only the explicit
246-
* "Don't Ask Again" persists hooks-off. */
321+
* standalone CLI).
322+
*
323+
* It asks exactly one population: the one with NOTHING of ours installed.
324+
* With our hooks ALREADY present but no recorded consent — a pre-consent
325+
* version installed them silently — consent is granted here and the install
326+
* runs with no prompt at all. That install is the 14 -> 12 migration, and it
327+
* only ever REDUCES scope: it drops UserPromptSubmit and TaskCreated, the two
328+
* events that forwarded prompt text and were consumed by nothing. Nothing
329+
* this user already had is expanded, so the friction of a prompt buys them
330+
* nothing they do not already have. (This is deliberately NOT the general
331+
* rule: consent for a fresh install is still asked for, in full, below.)
332+
*
333+
* A non-modal notification, carrying the FULL disclosure in its message. A
334+
* startup consent gate must not block the workbench, and it does not have to:
335+
* a notification with buttons renders permanently expanded (`canCollapse` is
336+
* `!hasActions`) with no line clamp, and truncates only at 1000 characters —
337+
* which the composed message stays under, pinned by consentCopy.test.ts. The
338+
* facts therefore live in the message itself; a "Details" affordance would
339+
* put the disclosure one click away, which is the gap this gate exists to
340+
* close.
341+
*
342+
* The Info toast auto-hides after ~10 s WITHOUT closing the notification: it
343+
* parks in the notification bell with its buttons intact and this promise
344+
* still pending. So an unanswered or dismissed prompt writes nothing and
345+
* asks again next startup; only the explicit "Don't Ask Again" persists
346+
* hooks-off. */
247347
private async installHooksWithConsent(port: number, token: string): Promise<void> {
248348
if (!readConfig().hooksConsentGiven) {
249349
if (await claudeProvider.areHooksInstalled()) {
350+
// Already installed and already firing: grant and migrate silently.
250351
grantHooksConsent();
251352
} else {
252353
const choice = await vscode.window.showInformationMessage(
253-
"To show your agents in real time, Pixel Agents needs to add its hooks to ~/.claude/settings.json. Your existing settings are kept safe, and you can remove Pixel Agents' hooks at any time.",
354+
CONSENT_INSTALL_MESSAGE,
254355
'Install Hooks',
255356
'Not Now',
256357
"Don't Ask Again",
@@ -265,7 +366,7 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider {
265366
grantHooksConsent();
266367
}
267368
}
268-
this.installHooksAndScript(port, token);
369+
await this.installHooksAndScript(port, token);
269370
}
270371

271372
resolveWebviewView(webviewView: vscode.WebviewView) {
@@ -344,28 +445,7 @@ export class PixelAgentsViewProvider implements vscode.WebviewViewProvider {
344445
} else if (message.type === 'setGhostHeadlessAgents') {
345446
this.adapter.setSetting(GLOBAL_KEY_GHOST_HEADLESS_AGENTS, message.enabled);
346447
} else if (message.type === 'setHooksEnabled') {
347-
const enabled = message.enabled as boolean;
348-
this.adapter.setSetting(GLOBAL_KEY_HOOKS_ENABLED, enabled);
349-
this.runtime.hooksEnabled.current = enabled;
350-
if (enabled) {
351-
// An explicit Settings toggle IS the consent to modify settings.json.
352-
grantHooksConsent();
353-
const serverConfig = this.pixelAgentsServer?.getConfig();
354-
this.installHooksAndScript(serverConfig?.port, serverConfig?.token);
355-
console.log('[Pixel Agents] Hooks enabled by user');
356-
} else {
357-
claudeProvider
358-
.uninstallHooks()
359-
.then(() => {
360-
this.sendOrBuffer({ type: 'hooksStatus', installed: false });
361-
console.log('[Pixel Agents] Hooks disabled by user');
362-
})
363-
.catch((err: unknown) => {
364-
vscode.window.showErrorMessage(
365-
`Pixel Agents: ${err instanceof Error ? err.message : String(err)}`,
366-
);
367-
});
368-
}
448+
void this.setHooksEnabled(message.enabled as boolean);
369449
} else if (message.type === 'setHooksInfoShown') {
370450
this.adapter.setSetting(GLOBAL_KEY_HOOKS_INFO_SHOWN, true);
371451
} else if (message.type === 'setShowAreas') {

e2e/README.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ This section is auto-generated. Do not edit between the markers; CI fails on dri
173173

174174
<!-- BEGIN:E2E-INVENTORY -->
175175

176-
77 tests total. Generated by `scripts/generate-e2e-inventory.mjs`. Re-run after adding or removing tests.
176+
84 tests total. Generated by `scripts/generate-e2e-inventory.mjs`. Re-run after adding or removing tests.
177177

178178
### `@area:spawn` (2 tests)
179179

@@ -205,11 +205,17 @@ This section is auto-generated. Do not edit between the markers; CI fails on dri
205205
- `e2e/claude/hooks-on/lifecycle.spec.ts:877` — rapid /clear then new tool within 500ms lands on the reassigned agent (Hooks ON / lifecycle)
206206
- `e2e/claude/hooks-on/lifecycle.spec.ts:935` — close via X prevents re-adoption of old JSONL during dismissal cooldown (Hooks ON / lifecycle)
207207

208-
### `@area:cross-cutting` (13 tests)
208+
### `@area:cross-cutting` (19 tests)
209209

210210
- `e2e/claude/hooks-off/lifecycle.spec.ts:735` — agentToolsClear fires at turn end via turn_duration JSONL record (Hooks OFF / lifecycle)
211211
- `e2e/claude/hooks-off/lifecycle.spec.ts:800` — heuristic permission timer is cancelled when an agent is closed via overlay (Hooks OFF / lifecycle)
212212
- `e2e/claude/hooks-off/lifecycle.spec.ts:879` — sub-agent permission bubble fires on stalled non-exempt sub-tool via heuristic timer (Hooks OFF / lifecycle)
213+
- `e2e/claude/hooks-on/consent.spec.ts:141` — fresh install: the prompt discloses scope and Install writes the hooks (Hooks consent gate)
214+
- `e2e/claude/hooks-on/consent.spec.ts:182` — Not Now writes nothing and leaves consent ungranted (Hooks consent gate)
215+
- `e2e/claude/hooks-on/consent.spec.ts:206` — Don't Ask Again writes nothing and persists hooks off (Hooks consent gate)
216+
- `e2e/claude/hooks-on/consent.spec.ts:240` — a pre-consent 14-event install migrates to 12 with no prompt (Hooks consent gate / pre-consent install)
217+
- `e2e/claude/hooks-on/consent.spec.ts:284` — Settings toggle removes the migrated hooks and keeps third-party entries (Hooks consent gate / pre-consent install)
218+
- `e2e/claude/hooks-on/consent.spec.ts:330` — a failed uninstall does not persist hooks-off (Hooks consent gate / toggle-off failure)
213219
- `e2e/claude/hooks-on/lifecycle.spec.ts:1034` — done sound chime fires on agentStatus waiting (Hooks ON / lifecycle)
214220
- `e2e/claude/hooks-on/lifecycle.spec.ts:1127` — restored agents skip the matrix spawn animation (Hooks ON / lifecycle)
215221
- `e2e/claude/hooks-on/lifecycle.spec.ts:1211` — tool status text matches every PreToolUse tool name (Hooks ON / lifecycle)
@@ -240,9 +246,10 @@ This section is auto-generated. Do not edit between the markers; CI fails on dri
240246
- `e2e/claude/hooks-off/matrix.spec.ts:253` — external inline teammate adopted via JSONL polling (Hooks OFF / matrix)
241247
- `e2e/claude/hooks-off/matrix.spec.ts:312` — external tmux teammate adopted via JSONL polling (Hooks OFF / matrix)
242248

243-
### `@area:standalone` (8 tests)
249+
### `@area:standalone` (9 tests)
244250

245-
- `e2e/standalone/hooks.spec.ts:10` — propagates hook-driven lifecycle into the browser UI (Standalone / hooks)
251+
- `e2e/standalone/hooks.spec.ts:11` — propagates hook-driven lifecycle into the browser UI (Standalone / hooks)
252+
- `e2e/standalone/hooks.spec.ts:111` — the hooks checkbox reflects install state and its click is the consent grant (Standalone / hooks)
246253
- `e2e/standalone/multi-server-hooks.spec.ts:31` — extension and standalone both stay hook-driven without cross-contamination (Standalone / multi-server hooks)
247254
- `e2e/standalone/ui.spec.ts:27` — closeAgent despawns the character (Standalone / UI)
248255
- `e2e/standalone/ui.spec.ts:61` — Debug View renders JSONL diagnostics in standalone (Standalone / UI)

e2e/fixtures/pixel-agents.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,14 @@ export const test = base.extend<{
7070
seedConfig: unknown;
7171
/** Pre-seed `~/.pixel-agents/layout.json` (must carry a high layoutRevision). */
7272
seedLayout: unknown;
73+
/** Pre-seed `~/.claude/settings.json` (e.g. an existing hook install). */
74+
seedClaudeSettings: unknown;
7375
/** Folder basenames for a multi-root workspace (>1 → multi-root). */
7476
workspaceFolders: string[];
7577
}>({
7678
seedConfig: [undefined, { option: true }],
7779
seedLayout: [undefined, { option: true }],
80+
seedClaudeSettings: [undefined, { option: true }],
7881
workspaceFolders: [[], { option: true }],
7982
// Auto-fixture: tag every test with Allure epic + feature derived from its
8083
// @area: annotation and enclosing describe path. Runs before pixelAgents.
@@ -85,10 +88,15 @@ export const test = base.extend<{
8588
},
8689
{ auto: true },
8790
],
88-
pixelAgents: async ({ seedConfig, seedLayout, workspaceFolders }, use, testInfo) => {
91+
pixelAgents: async (
92+
{ seedConfig, seedLayout, seedClaudeSettings, workspaceFolders },
93+
use,
94+
testInfo,
95+
) => {
8996
const session = await launchVSCode(testInfo.title, {
9097
seedConfig,
9198
seedLayout,
99+
seedClaudeSettings,
92100
workspaceFolders,
93101
});
94102
const { window, tmpHome, workspaceDir, mockLogFile } = session;

e2e/helpers/launch.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ export interface LaunchOptions {
4545
seedConfig?: unknown;
4646
/** Pre-seed `~/.pixel-agents/layout.json` (written before the panel loads). */
4747
seedLayout?: unknown;
48+
/**
49+
* Pre-seed `~/.claude/settings.json` (written before the extension activates).
50+
* For consent specs that need an EXISTING hook install to be present at the
51+
* moment the gate runs — the gate reads it during activation, so a test-body
52+
* write would be too late.
53+
*/
54+
seedClaudeSettings?: unknown;
4855
}
4956

5057
/**
@@ -97,6 +104,19 @@ export async function launchVSCode(
97104
if (opts.seedLayout !== undefined) {
98105
fs.writeFileSync(path.join(paDir, 'layout.json'), JSON.stringify(opts.seedLayout, null, 2));
99106
}
107+
if (opts.seedClaudeSettings !== undefined) {
108+
const claudeHomeDir = path.join(tmpHome, '.claude');
109+
fs.mkdirSync(claudeHomeDir, { recursive: true });
110+
// A string is written VERBATIM, so a spec can seed a deliberately
111+
// unparseable file (JSON.stringify would turn it into a valid quoted
112+
// string, i.e. the opposite of what such a test needs).
113+
fs.writeFileSync(
114+
path.join(claudeHomeDir, 'settings.json'),
115+
typeof opts.seedClaudeSettings === 'string'
116+
? opts.seedClaudeSettings
117+
: JSON.stringify(opts.seedClaudeSettings, null, 2),
118+
);
119+
}
100120

101121
// Enable Claude Agent Teams in the test workspace. Real Claude Code reads this
102122
// env from .claude/settings.local.json on startup; without it, team mode is gated

0 commit comments

Comments
 (0)