Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e7b0378
test: add end-to-end install, MCP and security checks for releases
erkamyaman Oct 17, 2026
33ba0bf
fix: mask secrets in prose, component form fields and store arguments
erkamyaman Oct 17, 2026
2f4cba6
style: format the redaction changes
erkamyaman Oct 17, 2026
75ceeb8
fix: build browser and Node entries apart and load the hub from the p…
erkamyaman Oct 17, 2026
3625308
fix: give proxyAuth an explicit return type
erkamyaman Oct 17, 2026
cc9862b
fix(mcp): check agent tool arguments and mark refusals as errors
erkamyaman Oct 17, 2026
f2a33f8
test: add end-to-end panel and resilience checks for releases
erkamyaman Oct 17, 2026
d1662ca
fix: reconnect the overlay after a server restart and pair the extens…
erkamyaman Oct 17, 2026
e5a297d
build(extension): rebuild the panel bundle
erkamyaman Oct 17, 2026
95f8da4
fix: tighten tool input, secret masking, the hub gate and overlay trust
erkamyaman Oct 17, 2026
8bd1f06
build(extension): rebuild the panel bundle
erkamyaman Oct 17, 2026
8278a74
perf(store): cache secret key checks and slice diffs on NgRx updates
erkamyaman Oct 17, 2026
cf42174
perf(overlay): send one page heartbeat instead of idle resends
erkamyaman Oct 17, 2026
61f5f79
fix(overlay): keep idle pages fresh and retry failed pushes
erkamyaman Oct 17, 2026
4bb70b8
test: run the default Analog install cell on pnpm
erkamyaman Oct 17, 2026
9ec032d
fix: mask late form secrets in events and close review gaps
erkamyaman Oct 17, 2026
abddcaf
build(extension): rebuild the panel bundle
erkamyaman Oct 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/skills/devtools-inspector/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ Read `docs/contributing/coding-standards.md` ("Reading data from the page") befo
- `serialize.ts`: safe, size-limited serialization.
- `router.ts` `providerOf()`: find a service through the injector resolution path.
- Never write attributes into the app's DOM. Never run app code (validators, guards) on a timer unless the user turned recording on.
- Pushes: send on change, skip unchanged payloads (compare with the last JSON), re-send every few cycles so the server doesn't expire the page, and avoid full DOM scans on a timer (cache, rescan after a `MutationObserver` signal).
- Pushes: send on change, skip unchanged payloads (compare with the last JSON), and let the page heartbeat keep the page alive: re-send an unchanged report only when `keepaliveDue`/`keepaliveAfter` says so (the server lost the page), and avoid full DOM scans on a timer (cache, rescan after a `MutationObserver` signal).
- Every report carries `pageId` from `claimPageId()`. Call `forget-<area>-page` from `leave()`.

## Server side (`devframe.ts`, `rpc/`)

- Keep a `Map<pageId, report>` with `reportedAt`, drop pages older than 15 seconds in the shared expiry interval, and write the combined value into the shared state.
- Keep a `Map<pageId, report>` with `reportedAt`, drop pages older than `ttl(pageId)` (from `visibility.ttl()`, so a page with a current heartbeat never expires) in the shared expiry interval, and write the combined value into the shared state.
- Page actions that the panel triggers (restore, highlight, run) go panel -> `request-<area>-action` -> broadcast `<area>-action` to the page -> `<area>-action-result`, keyed by `requestId`, with a timeout.
- Source scans in `rpc/` enrich or stand in for live data. They must return a `kind` for anything the Dashboard counts.

Expand Down
1 change: 1 addition & 0 deletions app/src/__tests__/analog-tab-restore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function fakeClient(analog: boolean): DevframeRpcClient {
sharedState: () => Promise.resolve({ value: () => null, on: () => () => {} }),
};
return {
status: 'connected',
connectionMeta: { configs: {} },
scope: () => ({ rpc }),
events: { on: () => () => {} },
Expand Down
1 change: 1 addition & 0 deletions app/src/__tests__/angular-native-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ function fakeClient(initial: Pages) {
),
};
const client = {
status: 'connected',
connectionMeta: { configs: {} },
scope: () => ({ rpc }),
events: { on: () => () => {} },
Expand Down
113 changes: 112 additions & 1 deletion app/src/__tests__/app-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,16 @@ const connection = vi.hoisted(() => ({
answers: [] as boolean[],
/** Sends a `connection:status` event to the last connected client's listeners. */
emitStatus: (_status: string) => {},
/** `emitStatus` for each client, in the order they were made. */
emitters: [] as ((status: string) => void)[],
/** The options each attempt passed to `connectDevframe`. */
options: [] as unknown[],
/** How many clients the panel closed. */
closed: 0,
/** The status each new client starts with. */
initialStatus: 'connected',
/** The one-time code the fake server accepts. */
code: '123456',
}));

function fakeClient(): DevframeRpcClient {
Expand All @@ -21,8 +27,31 @@ function fakeClient(): DevframeRpcClient {
sharedState: () => Promise.resolve({ value: () => null, on: () => () => {} }),
};
const listeners: ((status: string) => void)[] = [];
connection.emitStatus = (status) => listeners.forEach((listener) => listener(status));
const client = {
status: connection.initialStatus,
connection: { authToken: undefined as string | undefined },
requestAuthCode: vi.fn(() => Promise.resolve()),
requestTrustWithCode: vi.fn(async (code: string) => {
if (code !== connection.code) return false;
client.connection.authToken = 'token-for-code';
connection.emitStatus('connected');
return true;
}),
};
connection.emitStatus = (status) => {
client.status = status;
listeners.forEach((listener) => listener(status));
};
connection.emitters.push(connection.emitStatus);
return {
get status() {
return client.status;
},
get connection() {
return client.connection;
},
requestAuthCode: client.requestAuthCode,
requestTrustWithCode: client.requestTrustWithCode,
connectionMeta: { configs: {} },
scope: () => ({ rpc }),
events: {
Expand Down Expand Up @@ -63,7 +92,9 @@ afterEach(() => {
connection.attempts = 0;
connection.answers = [];
connection.options = [];
connection.emitters = [];
connection.closed = 0;
connection.initialStatus = 'connected';
TestBed.resetTestingModule();
});

Expand Down Expand Up @@ -110,6 +141,8 @@ describe('App connection retry', () => {
expect(status(fixture)).toBe('Live');

// The dev server restarted: the old client is closed for good.
const kept = globalThis as Record<string, unknown>;
kept['__DEVFRAME_CONNECTION__'] = { connectionMeta: { websocket: 9777 } };
connection.emitStatus('disconnected');
await settle(fixture);
expect(status(fixture)).toBe('Disconnected');
Expand All @@ -118,6 +151,8 @@ describe('App connection retry', () => {
await vi.waitFor(() => expect(connection.attempts).toBe(2), { timeout: 3000 });
await settle(fixture);
expect(status(fixture)).toBe('Live');
// It read the connection file again: the server may listen on another port now.
expect(kept['__DEVFRAME_CONNECTION__']).toBeUndefined();
fixture.destroy();
});

Expand Down Expand Up @@ -151,6 +186,82 @@ describe('App connection retry', () => {
});
});

describe('App before the server trusts it', () => {
it('shows Connecting, not Live, until the socket is trusted, and the error when it fails', async () => {
connection.answers = [true, false];
connection.initialStatus = 'connecting';
const fixture = TestBed.createComponent(App);
await settle(fixture);
expect(status(fixture)).toBe('Connecting…');
expect(host(fixture).textContent).toContain('Connecting to the devtools server…');
expect(host(fixture).querySelector('app-dashboard')).toBeNull();
expect(connection.options[0]).toMatchObject({ simpleAuth: false });

// The server is down: the reused connection file resolves, the socket does not open.
connection.emitStatus('error');
await settle(fixture);
expect(status(fixture)).toBe('Disconnected');
expect(host(fixture).querySelector('.connection-error')).not.toBeNull();
fixture.destroy();
});

it('does not start a second client while one still waits for the server', async () => {
connection.answers = [false, true, true];
connection.initialStatus = 'connecting';
const fixture = TestBed.createComponent(App);
document.body.appendChild(host(fixture));
await settle(fixture);
const retry = () => host(fixture).querySelector<HTMLButtonElement>('.connection-error button');

// The first retry finds the connection file; its socket is still opening.
retry()!.click();
await settle(fixture);
expect(connection.attempts).toBe(2);
expect(retry()).not.toBeNull();

retry()!.click();
await settle(fixture);
expect(connection.attempts).toBe(2);

connection.emitters[0]!('connected');
await settle(fixture);
expect(status(fixture)).toBe('Live');
fixture.destroy();
});

it('asks for the one-time code when the server does not trust the panel, then connects', async () => {
connection.answers = [true];
connection.initialStatus = 'connecting';
const fixture = TestBed.createComponent(App);
document.body.appendChild(host(fixture));
await settle(fixture);
connection.emitStatus('unauthorized');
await settle(fixture);
expect(status(fixture)).toBe('Code needed');
const form = host(fixture).querySelector('app-auth-code')!;
expect(form).not.toBeNull();
expect(form.querySelector('h2')?.textContent).toBe('Enter the one-time code');
const input = form.querySelector<HTMLInputElement>('input')!;
expect(document.activeElement).toBe(input);

input.value = '000000';
input.dispatchEvent(new Event('input'));
form.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
await settle(fixture);
expect(form.querySelector('[role="alert"]')?.textContent).toContain("didn't match");
expect(status(fixture)).toBe('Code needed');

input.value = ' 123456 ';
input.dispatchEvent(new Event('input'));
form.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
await settle(fixture);
expect(host(fixture).querySelector('app-auth-code')).toBeNull();
expect(status(fixture)).toBe('Live');
expect(host(fixture).querySelector('app-dashboard')).not.toBeNull();
fixture.destroy();
});
});

describe('App tab bar', () => {
it('keeps one tab in the Tab order and moves along the bar with arrow keys', async () => {
connection.answers = [true];
Expand Down
199 changes: 199 additions & 0 deletions app/src/__tests__/auth-token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest';
import { crossServerOrigin, panelAuth } from '../auth-token';

const DEVFRAME_KEY = '__DEVFRAME_CONNECTION_AUTH_TOKEN__';
const extension = {
href: 'chrome-extension://dcogniffeelebaolkkfbopmjcblhblfk/ui/index.html?baseURL=x',
origin: 'chrome-extension://dcogniffeelebaolkkfbopmjcblhblfk',
};

function memoryStorage(initial: Record<string, string> = {}) {
const data = new Map(Object.entries(initial));
return {
data,
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => void data.set(key, value),
removeItem: (key: string) => void data.delete(key),
};
}

describe('panel auth tokens', () => {
it('treats a server on the panel origin as its own', () => {
const hub = {
href: 'http://localhost:4000/__devframes/pangular/',
origin: 'http://localhost:4000',
};
expect(crossServerOrigin('/__devframes/pangular/', hub)).toBeNull();
expect(crossServerOrigin(['/__pangular/', './'], hub)).toBeNull();
expect(crossServerOrigin(undefined, hub)).toBeNull();
const storage = memoryStorage({ [DEVFRAME_KEY]: 'own' });
expect(panelAuth('/__devframes/pangular/', hub, storage).token).toBeUndefined();
// devframe keeps managing the token of its own origin.
expect(storage.data.get(DEVFRAME_KEY)).toBe('own');
});

it('keeps one token per server origin in the extension', () => {
const storage = memoryStorage();
const vite = 'http://localhost:5173/__devframes/pangular/';
const hub = 'http://localhost:4000/__devframes/pangular/';
expect(crossServerOrigin(vite, extension)).toBe('http://localhost:5173');

const first = panelAuth(vite, extension, storage);
expect(first.token).toBeUndefined();
first.remember('vite-token');
panelAuth(hub, extension, storage).remember('hub-token');

expect(panelAuth(vite, extension, storage).token).toBe('vite-token');
expect(panelAuth(hub, extension, storage).token).toBe('hub-token');
});

it("never leaves devframe's shared slot for the next server to send", () => {
const storage = memoryStorage({ [DEVFRAME_KEY]: 'token-of-another-server' });
const auth = panelAuth('http://localhost:5173/', extension, storage);
expect(storage.data.has(DEVFRAME_KEY)).toBe(false);
storage.data.set(DEVFRAME_KEY, 'copied-by-devframe');
auth.remember('new-token');
expect(storage.data.has(DEVFRAME_KEY)).toBe(false);
expect(auth.token).toBe('new-token');
});

it('ignores an empty token', () => {
const storage = memoryStorage();
panelAuth('http://localhost:5173/', extension, storage).remember(undefined);
expect(storage.data.size).toBe(0);
});
});

/** Two documents on one origin: a shared storage area and a shared broadcast bus. */
function sharedOrigin() {
const data = new Map<string, string>();
const bus = new Set<{ name: string; onmessage: ((event: { data: unknown }) => void) | null }>();
const panel = () => {
class PanelStorage {
getItem(key: string) {
return data.get(key) ?? null;
}
setItem(key: string, value: string) {
data.set(key, value);
}
removeItem(key: string) {
data.delete(key);
}
}
class PanelChannel {
onmessage: ((event: { data: unknown }) => void) | null = null;
constructor(readonly name: string) {
bus.add(this);
}
postMessage(message: unknown) {
for (const other of bus) {
if (other !== this && other.name === this.name) other.onmessage?.({ data: message });
}
}
close() {
bus.delete(this);
}
}
const scope = {
Storage: PanelStorage as unknown as typeof Storage,
BroadcastChannel: PanelChannel as unknown as typeof BroadcastChannel,
};
return { scope, storage: new PanelStorage() };
};
return { data, panel };
}

/** What devframe's client does with the one-time code token, as in devframe/client. */
function devframeClient(
scope: { BroadcastChannel: typeof BroadcastChannel },
storage: Pick<Storage, 'setItem'>,
sentToServer: string[],
) {
let channel: BroadcastChannel | undefined;
try {
channel = new scope.BroadcastChannel('devframe-auth');
} catch {
// devframe leaves the channel out when it cannot open one.
}
const store = (token: string) => {
try {
storage.setItem(DEVFRAME_KEY, token);
} catch {
// As devframe: storage may be blocked.
}
};
if (channel) {
channel.onmessage = (event: MessageEvent) => {
const data = event.data as { type?: string; authToken?: string };
if (data?.type === 'auth-update' && data.authToken) {
store(data.authToken);
sentToServer.push(data.authToken);
}
};
}
return {
trustWithCode(token: string) {
store(token);
try {
channel?.postMessage({ type: 'auth-update', authToken: token });
} catch {
// As devframe.
}
},
};
}

describe('two extension panels on different servers', () => {
it("never hand one server's token to the other", () => {
const origin = sharedOrigin();
const first = origin.panel();
const second = origin.panel();
const authA = panelAuth('http://localhost:4000/', extension, first.storage, first.scope);
const authB = panelAuth('http://localhost:5173/', extension, second.storage, second.scope);
const sentToA: string[] = [];
const sentToB: string[] = [];
const clientA = devframeClient(first.scope, first.storage, sentToA);
devframeClient(second.scope, second.storage, sentToB);

clientA.trustWithCode('token-of-a');
authA.remember('token-of-a');

expect(sentToB).toEqual([]);
expect(origin.data.has(DEVFRAME_KEY)).toBe(false);
expect(authB.token).toBeUndefined();
expect(panelAuth('http://localhost:5173/', extension, second.storage, second.scope).token).toBe(
undefined,
);
expect(panelAuth('http://localhost:4000/', extension, first.storage, first.scope).token).toBe(
'token-of-a',
);
});

it('leave the channel and the slot to devframe for panels next to their server', () => {
const origin = sharedOrigin();
const hub = {
href: 'http://localhost:4000/__devframes/pangular/',
origin: 'http://localhost:4000',
};
const first = origin.panel();
const second = origin.panel();
panelAuth('/__devframes/pangular/', hub, first.storage, first.scope);
panelAuth('/__devframes/pangular/', hub, second.storage, second.scope);
const sent: string[] = [];
const client = devframeClient(first.scope, first.storage, []);
devframeClient(second.scope, second.storage, sent);
client.trustWithCode('hub-token');
// Same server: devframe shares the token between its tabs, as it should.
expect(sent).toEqual(['hub-token']);
expect(origin.data.get(DEVFRAME_KEY)).toBe('hub-token');
});

it('keep other broadcast channels and storage keys working', () => {
const origin = sharedOrigin();
const panel = origin.panel();
panelAuth('http://localhost:4000/', extension, panel.storage, panel.scope);
expect(() => new panel.scope.BroadcastChannel('other')).not.toThrow();
panel.storage.setItem('pangular-tab', 'routes');
expect(panel.storage.getItem('pangular-tab')).toBe('routes');
});
});
1 change: 1 addition & 0 deletions app/src/__tests__/capacitor-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ function fakeClient(initial: Record<string, CapacitorPage> | undefined) {
Promise.resolve(name === 'capacitor' ? capacitor : { value: () => null, on: () => () => {} }),
};
const client = {
status: 'connected',
connectionMeta: { configs: {} },
scope: () => ({ rpc }),
events: { on: () => () => {} },
Expand Down
Loading
Loading