diff --git a/package.json b/package.json index 169bc99..dd3593e 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ }, "client": { "inject": [ + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", diff --git a/src/client/historyPage.ts b/src/client/historyPage.ts index fe47034..ddd4db6 100644 --- a/src/client/historyPage.ts +++ b/src/client/historyPage.ts @@ -11,11 +11,21 @@ * map into the same conversation-node shapes the window join delivers (a * thin display subset of dsh's own fold). Fetched nodes cache per session — * history is immutable, so a seq never needs fetching twice. + * + * The remote face is resolved through the DECLARED inject + * (`watchHistoryFaces`): this plugin's module inject lists only slots/ + * locale, and a top-level `remote.session` would stall the whole plugin on + * pre-0.1.2 hosts that never mount the namespace — while NONDECLARED reads + * of the traced service proxy throw "cannot get property … without inject" + * and can take a view down. The injection callback runs under a fiber that + * declares both `remote` and `remote.session` (the dsh idiom), so the + * property path resolves; the guarded reflect reads beneath it degrade to + * the legacy face or to no face instead of ever throwing. */ import type { - ClientCtx, ConnectionFace, ContentFetcher, ConversationNodeLike, HeaderFetcher, - HistoryEntryLike, SessionPageFace, + ClientCtx, ContentFetcher, ConversationNodeLike, HeaderFetcher, + HistoryEntryLike, SessionPageFace, SessionsHistoryFace, } from './services' import type { HeaderEpochContent } from '../shared/types' @@ -163,6 +173,69 @@ function serviceOf(ctx: ClientCtx, name: string): unknown { } } +/** + * Walk a (possibly traced/proxied) service value down a plain-property path, + * degrading at the FIRST throw. cordis's undeclared-service proxies are one + * hostile object class — any accessor backed by host state can throw too — + * and the no-white-screen contract needs the whole chain guarded, not just + * the `ctx.get` call. + */ +function readKeysOf(value: unknown, ...keys: string[]): unknown { + let current = value + for (const key of keys) { + if (current === null || typeof current !== 'object') return undefined + try { + current = (current as Record)[key] + } catch { + return undefined + } + } + return current +} + +/** The `page` verb of a history face, re-proved and bound to its owner. */ +function readPageOf(face: unknown): SessionPageFace['page'] | undefined { + const fn = readKeysOf(face, 'page') + return typeof fn === 'function' ? (fn as SessionPageFace['page']).bind(face) : undefined +} + +/** The `history` verb of the legacy api client face, re-proved and bound. */ +function readHistoryOf(face: unknown): SessionsHistoryFace['history'] | undefined { + const sessions = readKeysOf(face, 'api', 'sessions') + const fn = readKeysOf(sessions, 'history') + return typeof fn === 'function' ? (fn as SessionsHistoryFace['history']).bind(sessions) : undefined +} + +/** + * The 0.1.2+ gateway history page verb, resolved through the DECLARED inject + * (see {@link watchHistoryFaces}) and bound up front: a method extracted + * unbound loses `this`, and the traced `remote` proxy that hands it out + * requires the inject to resolve at all. + */ +let declaredPage: SessionPageFace['page'] | undefined + +/** + * Register the plugin's 0.1.2+ history faces with the harness through the + * DECLARED inject — both `remote` AND `remote.session` (the ui-chat idiom) + * must be in one fiber's requirement list, because the traced `remote` + * proxy resolves `.session` through the context and each name needs the + * other's declaration. Pre-0.1.2 hosts never provide either name, so the + * callback simply never fires and the legacy `connection` face carries the + * reads. The callback re-runs on every unload/remount, so it owns the + * slot's lifetime. The face itself is re-proven (a never-fired or hostile + * invocation leaves the slot unset — nothing here can throw). + */ +export function watchHistoryFaces(ctx: ClientCtx): void { + ctx.inject(['remote', 'remote.session'], (c) => { + const session = (c as ClientCtx & { remote?: { session?: SessionPageFace } }).remote?.session + const page = session !== undefined ? readPageOf(session) : undefined + declaredPage = page + return () => { + declaredPage = undefined + } + }) +} + /** The rows array of a history/page response, under every served envelope. */ function rowsOf(response: unknown): readonly unknown[] { // Envelope unwrapping, both served shapes: the 0.1.2 remote resolves to @@ -195,20 +268,19 @@ function rowsOf(response: unknown): readonly unknown[] { * pre-0.1.2 api client verb (`connection.api.sessions.history`) beneath it. * Both cut message-aligned pages, so the returned read covers `seq` whenever * the durable log still holds it: the newer face pins the inclusive cut to - * the seq itself, the legacy reader uses the exclusive bound one past it. - * Undefined when no face exists (older hosts) — callers keep their static - * degradation. + * the seq itself, the legacy reader uses the exclusive bound one past it — + * and because a non-declared read of the traced remote proxy throws + * ("cannot get property … without inject"), the page face prefers the + * injection-resolved slot and only then tries the reflect read. Every + * service property access degrades at its own guard instead of taking the + * view down. Undefined when no face exists (older hosts) — callers keep + * their static degradation. */ function pageReadersOf(ctx: ClientCtx, sessionId: string): ((seq: number) => Promise) | undefined { - // The 0.1.2+ session namespace is a traced cordis service literally named - // `remote.session` — read through `ctx.get`, whose reflect read needs no - // inject declaration and yields undefined on harnesses that never mount - // the namespace (0.1.1). The faces are bound up front: a method extracted - // unbound loses `this`, and a transport relying on it would throw. - const pageFace = serviceOf(ctx, 'remote.session') as SessionPageFace | undefined - const page = pageFace !== undefined && typeof pageFace.page === 'function' ? pageFace.page.bind(pageFace) : undefined - const historyFace = (serviceOf(ctx, 'connection') as ConnectionFace | undefined)?.api?.sessions - const history = historyFace !== undefined && typeof historyFace.history === 'function' ? historyFace.history.bind(historyFace) : undefined + // The declared-inject face wins; the reflect read beneath it is the + // bounded second chance on hosts where the inject callback never fired. + const page = declaredPage ?? readPageOf(serviceOf(ctx, 'remote.session')) + const history = readHistoryOf(serviceOf(ctx, 'connection')) if (page === undefined && history === undefined) return undefined return (seq) => { if (page !== undefined) { diff --git a/src/client/index.ts b/src/client/index.ts index 55ccd5f..55d3cde 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -30,6 +30,7 @@ import type { ClientCtx } from './services' import { createContextSettings, type SettingsField, type SettingsScopeBinderFace } from './settings' import { makeContextView } from './components/contextView' import { makeContextJumpButton } from './components/contextJump' +import { watchHistoryFaces } from './historyPage' import { makeViewKit } from './viewkit' // Theme-native styles: the bundle's global-CSS channel injects each sheet as @@ -67,6 +68,13 @@ function apply(ctx: ClientCtx): void { const t = ctx.locale.bind(NS) const kit = makeViewKit(t) + // History faces of the 0.1.2+ gateway remotes, resolved through the + // DECLARED inject — a non-declared read of the traced `remote.session` + // proxy throws ("cannot get property … without inject") and would take + // the browser down with the view. Injection waits for the service and + // never fires on hosts that lack the namespace (the pre-0.1.2 legacy + // `connection` face carries those reads instead). + watchHistoryFaces(ctx) const settings = createContextSettings() const ContextView = makeContextView(ctx, kit, settings) diff --git a/src/client/services.ts b/src/client/services.ts index 8aea463..0cc6edd 100644 --- a/src/client/services.ts +++ b/src/client/services.ts @@ -540,16 +540,17 @@ export interface SessionsHistoryFace { /** * The seq-anchored history page verb of the dsh 0.1.2+ gateway remotes: the * session namespace mounts as a traced cordis service literally named - * `remote.session`, so `ctx.get('remote.session')` reaches it without an - * inject declaration (undefined on harnesses that never mount it — the - * 0.1.1 remotes carry no session namespace). `throughSeq` is the inclusive - * log cut (a seq that must exist in the log), `beforeSeq` the exclusive - * upper bound, and the response wraps the rows in a `ClientResult`-style - * envelope. Rows are `SessionHistoryRecord`s — `{type:'event', event}` - * entries plus packed `{type:'chunks', …}` runs the mapper skips (every - * event the fold needs — user/assistant messages, tool calls/results, - * compaction summaries — is always served verbatim; only streaming deltas - * pack). + * `remote.session`. The plugin resolves it through the DECLARED inject + * (`watchHistoryFaces` in historyPage.ts — a non-declared read of the + * traced proxy throws), so reads are undefined on harnesses that never + * mount it (the 0.1.1 remotes carry no session namespace) rather than + * crashing. `throughSeq` is the inclusive log cut (a seq that must exist in + * the log), `beforeSeq` the exclusive upper bound, and the response wraps + * the rows in a `ClientResult`-style envelope. Rows are + * `SessionHistoryRecord`s — `{type:'event', event}` entries plus packed + * `{type:'chunks', …}` runs the mapper skips (every event the fold needs — + * user/assistant messages, tool calls/results, compaction summaries — is + * always served verbatim; only streaming deltas pack). */ export interface SessionPageFace { page(request: { diff --git a/tests/client/compat/clientFaces.spec.ts b/tests/client/compat/clientFaces.spec.ts index b973714..30a6470 100644 --- a/tests/client/compat/clientFaces.spec.ts +++ b/tests/client/compat/clientFaces.spec.ts @@ -22,7 +22,7 @@ import { createContextSettings } from '../../../src/client/settings' import { DICT_EN } from '../../../src/client/i18n' import type { ContextTimeline } from '../../../src/shared/types' import { h } from '../../../src/client/react' -import { asClientCtx } from '../helpers/harness' +import { asClientCtx, TestClientCtx } from '../helpers/harness' import { click, makeKit, mount, queryAll, text } from '../helpers/kit' import { baselineCtx, chatSeat, convNodes, sessionSeat } from './baselineFaces' @@ -137,6 +137,34 @@ for (const baseline of BASELINES) { assert.ok(text(m.container).includes('window node hello'), 'the conversation-window join surfaced the seat node') await m.unmount() }) + + test('a throwing history-service proxy degrades the tab, never crashes it', async () => { + // Issue #42: an undeclared-service proxy can throw on the property READ + // itself ("cannot get property "remote.session" without inject") — the + // no-white-screen guarantee must degrade it to the static hint, not let + // it escape the view and take the whole web client down. + const ctx = new TestClientCtx() + if (baseline.client.historyFace === 'remote.session.page') { + ctx.setService('remote.session', { + get page() { throw new Error('cannot get property "remote.session" without inject') }, + }) + } else { + ctx.setService('connection', { get api() { throw new Error('hostile connection') } }) + } + const settings = createContextSettings() + const View = makeContextView(asClientCtx(ctx), kit, settings) + const nodes = convNodes() + const props: SessionStandardProps = { + sessionId: 's-face', + useProjection: (key: string) => (key === 'contextTimeline' ? timeline() : undefined), + ...(baseline.client.chatNodesSeat === 'useChat' + ? { useChat: chatSeat(nodes) } + : { useSession: sessionSeat(nodes) }), + } + const m = await mount(h(View, props)) + assert.ok(text(m.container).includes(DICT_EN['overview.title']), 'the tab still rendered around a hostile face') + await m.unmount() + }) }) } diff --git a/tests/client/helpers/harness.ts b/tests/client/helpers/harness.ts index 74289be..3696b74 100644 --- a/tests/client/helpers/harness.ts +++ b/tests/client/helpers/harness.ts @@ -96,14 +96,21 @@ export class TestClientCtx { const p = this.pending[i] if (p.deps.every(d => this.services.has(d))) { this.pending.splice(i, 1) - p.cb(this) + this.runInjected(p.cb) } } } + private runInjected(cb: (ctx: TestClientCtx) => void): void { + // Cordis runs the inject callback as a disposable plugin: a returned + // disposer belongs to the fiber and runs on unload. + const dispose = cb(this) + if (typeof dispose === 'function') this.disposers.push(dispose) + } + inject(deps: string[] | Record, cb: (ctx: TestClientCtx) => void): void { const list = Array.isArray(deps) ? deps : Object.keys(deps) - if (list.every(d => this.services.has(d))) cb(this) + if (list.every(d => this.services.has(d))) this.runInjected(cb) else this.pending.push({ deps: list, cb }) } diff --git a/tests/client/historyPage.spec.ts b/tests/client/historyPage.spec.ts index b07d61d..57e0afd 100644 --- a/tests/client/historyPage.spec.ts +++ b/tests/client/historyPage.spec.ts @@ -4,7 +4,8 @@ import assert from 'node:assert/strict' import { describe, test } from 'vitest' -import { makeContentFetcher, makeHeaderFetcher, pageNodesOf } from '../../src/client/historyPage' +import { makeContentFetcher, makeHeaderFetcher, pageNodesOf, watchHistoryFaces } from '../../src/client/historyPage' +import { asClientCtx, TestClientCtx } from './helpers/harness' /** One well-formed history row. */ function row(type: string, seq: number, data: unknown): unknown { @@ -224,6 +225,87 @@ describe('makeContentFetcher — the per-session targeted read', () => { assert.equal(makeContentFetcher(hostile('connection'), 's'), undefined) }) + // The issue-42 failure: the injected-service PROXY throws on the `.page` + // read itself ("cannot get property "remote.session" without inject") — + // the guard around `ctx.get` alone cannot catch it. A throwing getter must + // degrade to the legacy face (or to no face), never out of the fetcher. + test('a throwing service-property read degrades to the legacy face', async () => { + const calls: { beforeSeq: number }[] = [] + const hostilePage = { + get page() { + throw new Error('cannot get property "remote.session" without inject') + }, + } + const fetch = makeContentFetcher(ctxWith({ + 'remote.session': hostilePage, + connection: { api: apiFace([row('user/message', 7, { content: [{ type: 'text', text: 'LEGACY BODY' }] })], calls) }, + }), 's')! + const node = await fetch(7) + assert.deepEqual(calls, [{ beforeSeq: 8 }], 'the throwing page face falls back to the legacy verb') + assert.equal((node?.content as { text: string }[])[0]?.text, 'LEGACY BODY') + // No legacy face beneath it: the fetcher itself degrades, no throw. + assert.equal(makeContentFetcher(ctxWith({ 'remote.session': hostilePage }), 's'), undefined) + }) + + test('a throwing connection-property chain degrades to no fetcher', () => { + const hostileApi = { + get api() { + throw new Error('cannot get property "api" without inject') + }, + } + assert.equal(makeContentFetcher(ctxWith({ connection: hostileApi }), 's'), undefined) + assert.equal(makeContentFetcher(ctxWith({ connection: { api: { get sessions() { throw new Error('hostile sessions') } } } }), 's'), undefined) + }) + + test('the declared remote.session inject carries the page read and unloads cleanly', async () => { + // The harness mirrors the traced `remote` service: factory→`.session` + // is the facade property path the DECLARED fiber resolves. The direct + // `remote.session` service value is hostile on purpose — the injection + // path must be the one that carries the read. + const calls: unknown[] = [] + const declared = { + session: { + page(request: unknown, _signal: AbortSignal): Promise { + calls.push(request) + return Promise.resolve({ ok: true, value: { records: [] } }) + }, + }, + } + const ctx = new TestClientCtx() + ctx.setService('remote', declared) + ctx.setService('remote.session', { get page() { throw new Error('cannot get property "remote.session" without inject') } }) + watchHistoryFaces(asClientCtx(ctx)) + const fetch = makeContentFetcher(asClientCtx(ctx), 'sess-1')! + const node = await fetch(7) + assert.deepEqual(calls, [{ address: { kind: 'session', sessionId: 'sess-1' }, throughSeq: 7, beforeSeq: 8 }]) + assert.equal(node, null, 'the declared page face mapped the (empty) page') + // Unload clears the slot: the hostile reflect read (and the absent + // legacy face) then degrade to no fetcher — nothing stales across + // plugin reloads. + ctx.dispose() + assert.equal(makeContentFetcher(asClientCtx(ctx), 's'), undefined, 'the unloaded slot leaves no face behind') + }) + + test('the declared inject never fires without both remote services', () => { + const ctx = new TestClientCtx() + // Only `remote`: the injected fiber requires BOTH names, so nothing + // fires and the legacy verb carries the reads (the pre-0.1.2 line). + ctx.setService('remote', { session: {} }) + watchHistoryFaces(asClientCtx(ctx)) + ctx.dispose() + }) + + test('the declared inject tolerates a shapeless remote service', () => { + const ctx = new TestClientCtx() + // Both names exist but the facade bears no session namespace: the + // callback fires and leaves the slot unset, never throwing. + ctx.setService('remote', {}) + ctx.setService('remote.session', {}) + watchHistoryFaces(asClientCtx(ctx)) + assert.equal(makeContentFetcher(asClientCtx(ctx), 's'), undefined) + ctx.dispose() + }) + test('the 0.1.2 page face wins over the legacy history verb and maps its records', async () => { const calls: unknown[] = [] // The real 0.1.2 shape: the remote resolves to the ClientResult itself.