dsh-cindy-host 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +462 -0
  2. package/LICENSE +21 -0
  3. package/README.md +178 -0
  4. package/cordis.patch.yml +6 -0
  5. package/doc/architecture.md +34 -0
  6. package/doc/cindy-android-verify.md +362 -0
  7. package/doc/cindy-mobile-freeze-handoff.md +124 -0
  8. package/doc/cindy-phone-link.md +2902 -0
  9. package/doc/desktop-runtime-fix-20260930.md +69 -0
  10. package/doc/development-plan.md +19 -0
  11. package/doc/file-peer-apply-recovery-20260921.md +35 -0
  12. package/doc/file-peer-installed-hotfix-20260921.md +53 -0
  13. package/doc/file-peer-minimal-apply-20260921.md +78 -0
  14. package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
  15. package/doc/file-peer-phone-export-observation-20260921.md +75 -0
  16. package/doc/file-peer-product-contract-audit-20260921.md +106 -0
  17. package/doc/file-peer-root-cause-20260921.md +53 -0
  18. package/doc/file-transfer.md +50 -0
  19. package/doc/host-performance-20260930.md +91 -0
  20. package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
  21. package/doc/new-session-sync-fix-20260930.md +59 -0
  22. package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
  23. package/doc/projection-read-probe.md +77 -0
  24. package/doc/publishing.md +259 -0
  25. package/doc/releasing.md +149 -0
  26. package/doc/research.md +24 -0
  27. package/doc/session-discovery-2026-09-20.md +67 -0
  28. package/lib/client.js +763 -0
  29. package/package.json +84 -0
  30. package/src/auth-session.js +189 -0
  31. package/src/authorization-policy.js +59 -0
  32. package/src/cindy-channels.js +1707 -0
  33. package/src/cindy-login-flow.js +159 -0
  34. package/src/cindy-login.js +80 -0
  35. package/src/cindy-message-row.js +432 -0
  36. package/src/cindy-session-row.js +171 -0
  37. package/src/console-sink.js +6 -0
  38. package/src/contracts.js +26 -0
  39. package/src/credential-store.js +128 -0
  40. package/src/demo.js +9 -0
  41. package/src/dsh-active-sessions.js +59 -0
  42. package/src/dsh-host-source.js +62 -0
  43. package/src/dsh-message-fold.js +608 -0
  44. package/src/dsh-plugin.js +1932 -0
  45. package/src/dsh-session-source.js +529 -0
  46. package/src/file-peer-protocol.js +7 -0
  47. package/src/file-peer-rtc.js +41 -0
  48. package/src/fixture-source.js +13 -0
  49. package/src/host-approvals.js +329 -0
  50. package/src/host-attachments.js +249 -0
  51. package/src/host-authorization.js +51 -0
  52. package/src/host-demo.js +3 -0
  53. package/src/host-export-jobs.js +356 -0
  54. package/src/host-export-owner.js +62 -0
  55. package/src/host-file-browser.js +400 -0
  56. package/src/host-file-ice.js +63 -0
  57. package/src/host-file-peer.js +289 -0
  58. package/src/host-file-reference.js +16 -0
  59. package/src/host-files.js +148 -0
  60. package/src/host-frame-budget.js +310 -0
  61. package/src/host-goals.js +284 -0
  62. package/src/host-history-view-limits.js +22 -0
  63. package/src/host-history-view.js +417 -0
  64. package/src/host-input-queue.js +505 -0
  65. package/src/host-media-fetch.js +511 -0
  66. package/src/host-media.js +678 -0
  67. package/src/host-models.js +231 -0
  68. package/src/host-palette.js +111 -0
  69. package/src/host-push-channels.js +37 -0
  70. package/src/host-reconnect.js +54 -0
  71. package/src/host-routes.js +266 -0
  72. package/src/host-settings.js +41 -0
  73. package/src/host-status.js +216 -0
  74. package/src/host-transfer-diagnostics.js +50 -0
  75. package/src/host.js +2497 -0
  76. package/src/login.js +31 -0
  77. package/src/logout.js +3 -0
  78. package/src/projection-read-model-sink.js +6 -0
  79. package/src/projection.js +43 -0
  80. package/src/session-flags.js +160 -0
  81. package/src/session-publisher.js +123 -0
  82. package/src/session-read-model.js +6 -0
@@ -0,0 +1,329 @@
1
+ /**
2
+ * The inbound half of DSH approvals: a question DSH asks, answered by the Cindy
3
+ * controller instead of the local UI.
4
+ *
5
+ * DSH raises `approval/request` as a **waterfall** event
6
+ * (`@deepseek-ai/dsh-user-approval`), and the chain's terminal answerer is
7
+ * fail-closed. That shape dictates the whole design here:
8
+ *
9
+ * - if no Cindy controller is watching the asking session, this registry is not
10
+ * in the chain's business and the answerer must call `next()` so the local UI
11
+ * (or the fail-closed default) decides — a Host must never swallow a question
12
+ * the user could have answered at the desk;
13
+ * - if a controller *is* watching, the question is registered, pushed, and the
14
+ * answerer waits for the phone's `maker:resolve-interaction`.
15
+ *
16
+ * Outcomes are DSH's own vocabulary (`'allowed-once' | 'rejected' | 'cancelled' |
17
+ * 'unavailable'`), not the controller's: `'allowed-once'` is the only grant DSH
18
+ * defines, so every non-allow answer maps to `'rejected'`.
19
+ */
20
+
21
+ /** The controller's interaction kind for a permission question. */
22
+ export const PERMISSION_KIND = 'permission';
23
+
24
+ /**
25
+ * Map a controller decision onto a DSH approval outcome.
26
+ *
27
+ * The controller sends `{ kind: 'permission', behavior: 'allow' | … }`.
28
+ * @param decision - the raw `maker:resolve-interaction` decision.
29
+ * @returns the outcome, or null when this is not a permission decision.
30
+ */
31
+ export function decisionOutcome(decision) {
32
+ if (decision === null || typeof decision !== 'object') return null;
33
+ if (typeof decision.kind === 'string' && decision.kind !== PERMISSION_KIND) return null;
34
+ const behavior = typeof decision.behavior === 'string'
35
+ ? decision.behavior
36
+ : (typeof decision.decision === 'string' ? decision.decision : null);
37
+ if (behavior === null) return null;
38
+ return behavior === 'allow' ? 'allowed-once' : 'rejected';
39
+ }
40
+
41
+ /**
42
+ * The controller's interaction kind for a structured question.
43
+ *
44
+ * DSH's `user-questions/request` waterfall is a *different* seam from
45
+ * `approval/request`, and the controller renders it as its own card — but the
46
+ * answer travels the same `maker:resolve-interaction` channel, so both share this
47
+ * registry and are told apart by kind. Without an answerer here the tool call
48
+ * waits for a human nobody can answer, and the conversation stops.
49
+ */
50
+ export const QUESTION_KIND = 'ask_user_question';
51
+
52
+ /**
53
+ * Translate the controller's question answer into DSH's shape.
54
+ *
55
+ * The two sides key answers differently, and that is the whole job:
56
+ * - the controller answers by **question text** (`answerKey` is the question's
57
+ * own string), one string per question, JSON-encoded as an array when the
58
+ * question is multi-select;
59
+ * - DSH answers by question **id**, as `{ id, selected: string[], custom? }`.
60
+ *
61
+ * The questions that were asked are therefore the lookup table, and anything the
62
+ * controller returned that is not one of the offered labels is carried as
63
+ * `custom` rather than silently dropped.
64
+ *
65
+ * @param questions - the DSH questions that were asked.
66
+ * @param decision - the controller's `{ kind, answers }`.
67
+ * @returns DSH's `AskUserQuestionAnswer`.
68
+ */
69
+ export function questionAnswerOf(questions, decision) {
70
+ // The decision's own kind must match: a permission decision aimed at a
71
+ // question would otherwise be read as "every question unanswered", which the
72
+ // controller would take as a real answer.
73
+ if (decision?.kind !== undefined && decision.kind !== QUESTION_KIND) return null;
74
+ const given = decision?.answers;
75
+ if (given === null || typeof given !== 'object') return { answers: [] };
76
+ const answers = [];
77
+ for (const question of Array.isArray(questions) ? questions : []) {
78
+ const raw = given[question?.question];
79
+ const labels = new Set((Array.isArray(question?.options) ? question.options : [])
80
+ .map((option) => (typeof option?.label === 'string' ? option.label : ''))
81
+ .filter((label) => label !== ''));
82
+ let chosen = typeof raw === 'string' && raw !== '' ? [raw] : [];
83
+ if (chosen.length > 0 && question?.multiSelect === true) {
84
+ try {
85
+ const parsed = JSON.parse(chosen[0]);
86
+ if (Array.isArray(parsed)) chosen = parsed.filter((entry) => typeof entry === 'string' && entry !== '');
87
+ } catch {
88
+ // Not the encoded array: keep the single string as given.
89
+ }
90
+ }
91
+ const selected = chosen.filter((label) => labels.has(label));
92
+ const free = chosen.filter((label) => !labels.has(label));
93
+ answers.push({
94
+ id: String(question?.id ?? ''),
95
+ selected,
96
+ ...(free.length === 0 ? {} : { custom: free.join(', ') }),
97
+ });
98
+ }
99
+ return { answers };
100
+ }
101
+
102
+ /**
103
+ * Build the pending-interaction registry.
104
+ * @param options - injectable clock and timers.
105
+ * @returns the registry the channels and the answerers share.
106
+ */
107
+ export function createApprovalRegistry({
108
+ now = () => new Date(),
109
+ timeoutMs = 120_000,
110
+ setTimer = setTimeout,
111
+ clearTimer = clearTimeout,
112
+ onDismissed,
113
+ } = {}) {
114
+ /** requestId -> { sessionId, request, settle, timer } */
115
+ const pending = new Map();
116
+ /** Web bridges observing structured questions; observing never owns or settles them. */
117
+ const questionObservers = new Set();
118
+ let counter = 0;
119
+
120
+ function questionRequestEvent(request) {
121
+ return {
122
+ type: 'interaction-request',
123
+ sessionId: request.sessionId,
124
+ requestId: request.requestId,
125
+ kind: request.kind,
126
+ questions: request.questions.map((question) => ({ ...question })),
127
+ };
128
+ }
129
+
130
+ function notifyQuestionObservers(event) {
131
+ for (const observer of questionObservers) {
132
+ try {
133
+ observer(event);
134
+ } catch {
135
+ // An unavailable Web observer must not affect the single pending answer.
136
+ }
137
+ }
138
+ }
139
+
140
+ function nextRequestId(prefix = 'dsh-approval') {
141
+ counter += 1;
142
+ return `${prefix}-${counter}`;
143
+ }
144
+
145
+ /**
146
+ * Register one pending interaction and hand back its lifetime controls.
147
+ *
148
+ * Both seams go through here so their timeouts, aborts and dismissals behave
149
+ * identically — a question that can hang a turn deserves the same care as an
150
+ * approval that can.
151
+ */
152
+ function register({ sessionId, request, prefix, signal, answerOf }) {
153
+ let settle;
154
+ const answered = new Promise((resolve) => {
155
+ settle = resolve;
156
+ });
157
+
158
+ const finish = (outcome) => {
159
+ const entry = pending.get(request.requestId);
160
+ if (entry === undefined) return;
161
+ pending.delete(request.requestId);
162
+ if (entry.timer !== null) clearTimer(entry.timer);
163
+ if (entry.onAbort !== null && signal !== undefined) signal.removeEventListener('abort', entry.onAbort);
164
+ // Every close — answered, timed out, or withdrawn — tells the watching
165
+ // controllers the card is done. Without it a second screen keeps showing a
166
+ // question that no longer exists, and the panel only clears on its next
167
+ // poll.
168
+ if (typeof onDismissed === 'function') {
169
+ try {
170
+ onDismissed({ sessionId, requestId: request.requestId });
171
+ } catch {
172
+ // A broken listener must not strand the answer the asker is waiting on.
173
+ }
174
+ }
175
+ if (request.kind === QUESTION_KIND) {
176
+ notifyQuestionObservers({
177
+ type: 'interaction-dismissed',
178
+ sessionId,
179
+ requestId: request.requestId,
180
+ kind: request.kind,
181
+ });
182
+ }
183
+ settle(outcome);
184
+ };
185
+
186
+ // The asker withdrawing the question settles it immediately, and a late
187
+ // answer from the controller is then discarded by `finish`'s guard.
188
+ const onAbort = signal === undefined ? null : () => finish(answerOf.cancelled);
189
+ if (onAbort !== null) signal.addEventListener('abort', onAbort, { once: true });
190
+
191
+ const timer = timeoutMs > 0
192
+ ? setTimer(() => finish(answerOf.cancelled), timeoutMs)
193
+ : null;
194
+ if (typeof timer?.unref === 'function') timer.unref();
195
+
196
+ pending.set(request.requestId, { sessionId, request, settle: finish, timer, onAbort, answerOf });
197
+ if (request.kind === QUESTION_KIND) notifyQuestionObservers(questionRequestEvent(request));
198
+ return { requestId: request.requestId, request, answered };
199
+ }
200
+
201
+ /**
202
+ * Register a question and wait for the controller's answer.
203
+ * @param options - the question, the asking session, and the caller's abort signal.
204
+ * @returns DSH's outcome vocabulary.
205
+ */
206
+ async function ask({ sessionId, toolName, reason, callId, signal }) {
207
+ const requestId = nextRequestId();
208
+ const request = {
209
+ kind: PERMISSION_KIND,
210
+ requestId,
211
+ sessionId,
212
+ toolName,
213
+ ...(typeof reason === 'string' && reason !== '' ? { reason } : {}),
214
+ ...(typeof callId === 'string' ? { callId } : {}),
215
+ createdAt: now().toISOString(),
216
+ };
217
+ // DSH's approval vocabulary, and the controller's mapping onto it.
218
+ return register({
219
+ sessionId,
220
+ request,
221
+ signal,
222
+ answerOf: {
223
+ cancelled: 'cancelled',
224
+ from: (decision) => decisionOutcome(decision),
225
+ },
226
+ });
227
+ }
228
+
229
+ /**
230
+ * Register a structured question and wait for the controller's answer.
231
+ *
232
+ * DSH's `user-questions/request` waterfall blocks the tool call until this
233
+ * resolves, which is why it cannot be left unanswered: a controller that never
234
+ * sees the card cannot answer it, and the conversation stops there.
235
+ *
236
+ * @param options - the questions, the asking session, and the caller's abort signal.
237
+ * @returns DSH's `AskUserQuestionAnswer`; a cancellation answers every question with nothing.
238
+ */
239
+ async function askUser({ sessionId, questions, signal }) {
240
+ const items = Array.isArray(questions) ? questions : [];
241
+ const requestId = nextRequestId('dsh-question');
242
+ const request = {
243
+ kind: QUESTION_KIND,
244
+ requestId,
245
+ sessionId,
246
+ // The controller reads `questions` and keys its answer by the question text.
247
+ questions: items.map((question) => ({ ...question })),
248
+ createdAt: now().toISOString(),
249
+ };
250
+ const cancelled = { answers: items.map((question) => ({ id: String(question?.id ?? ''), selected: [] })) };
251
+ return register({
252
+ sessionId,
253
+ request,
254
+ signal,
255
+ answerOf: {
256
+ cancelled,
257
+ from: (decision) => questionAnswerOf(items, decision),
258
+ },
259
+ });
260
+ }
261
+
262
+ /** The questions still open for one session, in the controller's shape. */
263
+ function list(sessionId) {
264
+ const rows = [];
265
+ for (const entry of pending.values()) {
266
+ if (sessionId !== undefined && entry.sessionId !== sessionId) continue;
267
+ rows.push({ request: { ...entry.request } });
268
+ }
269
+ return rows;
270
+ }
271
+
272
+ /**
273
+ * Observe structured questions without acquiring any right to settle them.
274
+ *
275
+ * Registration first replays all still-pending question entries, so a Web bridge
276
+ * that reconnects renders the same registry the phone lists. It returns a
277
+ * disposer; disconnecting only stops observation and never cancels a turn.
278
+ */
279
+ function observeUserQuestions(observer) {
280
+ if (typeof observer !== 'function') return () => {};
281
+ questionObservers.add(observer);
282
+ for (const entry of pending.values()) {
283
+ if (entry.request.kind !== QUESTION_KIND) continue;
284
+ try {
285
+ observer(questionRequestEvent(entry.request));
286
+ } catch {
287
+ // A failed initial replay is no different from a failed live push.
288
+ }
289
+ }
290
+ return () => questionObservers.delete(observer);
291
+ }
292
+
293
+ /**
294
+ * Answer one pending interaction.
295
+ *
296
+ * The mapping comes from the **stored** entry, not from the decision's own
297
+ * kind: the entry knows which seam is waiting, so a permission decision aimed
298
+ * at a question — or the reverse — is refused rather than mistranslated.
299
+ * @param requestId - the id the controller echoes back.
300
+ * @param decision - the controller's decision payload.
301
+ * @returns whether the answer was accepted.
302
+ */
303
+ function settle(requestId, decision) {
304
+ const entry = pending.get(requestId);
305
+ if (entry === undefined) return { accepted: false };
306
+ const outcome = entry.answerOf.from(decision);
307
+ if (outcome === null) return { accepted: false };
308
+ entry.settle(outcome);
309
+ return { accepted: true };
310
+ }
311
+
312
+
313
+ /** Settle one pending interaction with an outcome already validated by DSH's local UI. */
314
+ function settleOutcome(requestId, outcome) {
315
+ const entry = pending.get(requestId);
316
+ if (entry === undefined) return { accepted: false };
317
+ entry.settle(outcome);
318
+ return { accepted: true };
319
+ }
320
+
321
+ /** Forget every open question (the switch going off). */
322
+ function clear() {
323
+ for (const requestId of [...pending.keys()]) {
324
+ pending.get(requestId).settle(pending.get(requestId).answerOf.cancelled);
325
+ }
326
+ }
327
+
328
+ return { ask, askUser, list, observeUserQuestions, settle, settleOutcome, clear, size: () => pending.size };
329
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Turn a controller's attachment into a DSH prompt content part.
3
+ *
4
+ * The controller attaches files **inside the message**, not over a channel of
5
+ * their own: `maker:send` / `maker:input:enqueue` carry
6
+ * `files?: RemoteSerializedAttachment[]`. Two forms arrive, and both are serveable:
7
+ *
8
+ * - **host path** — an absolute path on this machine (the file browser's "send to
9
+ * session"). Readable through `ctx.fs`, so it becomes a durable attachment.
10
+ * - **uploaded** — the bytes went to the Cindy account's OSS staging area and the
11
+ * reference is a transit ref (`cindy-oss-attach://…` or the pre-rebrand
12
+ * `xdt-oss-attach://…`). Fetching it is the controlled end's job and this Host
13
+ * does it with its own account credential (`host-media.js`). Phones use the
14
+ * **legacy** scheme, so both must be recognized; a reference this Host cannot
15
+ * fetch is reported per attachment rather than guessed at.
16
+ *
17
+ * DSH's prompt content accepts exactly two attachment shapes
18
+ * (`PromptContentPart` in dsh-api-session-controller):
19
+ *
20
+ * - `{ type: 'image', mediaType, data: <base64> }` — the Host promotes the bytes
21
+ * to a durable reference itself;
22
+ * - `{ type: 'file', receiptId }` — the receipt of a preceding `uploadFile`.
23
+ *
24
+ * So images are read and encoded here, and files go through
25
+ * `ctx.fileUploads.upload(agent, { data, name }, signal)`.
26
+ *
27
+ * @module dsh-cindy-host/host-attachments
28
+ */
29
+ import { isAttachmentOssRef, parseAttachmentOssRef } from './host-media.js';
30
+
31
+ /** Longest attachment this Host will read into memory. */
32
+ export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
33
+
34
+ /**
35
+ * Image extensions DSH's attachment service admits, mapped to its media types.
36
+ *
37
+ * `ImageMediaType` is a closed union (`image/png | image/jpeg | image/webp |
38
+ * image/gif`), so an extension outside it is sent as a file rather than being
39
+ * forced into a media type the service would reject.
40
+ */
41
+ export const IMAGE_MEDIA_TYPE_BY_EXT = Object.freeze({
42
+ '.png': 'image/png',
43
+ '.jpg': 'image/jpeg',
44
+ '.jpeg': 'image/jpeg',
45
+ '.webp': 'image/webp',
46
+ '.gif': 'image/gif',
47
+ });
48
+
49
+ /** The extension of a path, lowercase, including the dot. */
50
+ export function extensionOf(path) {
51
+ const text = typeof path === 'string' ? path : '';
52
+ const cut = Math.max(text.lastIndexOf('/'), text.lastIndexOf('\\'));
53
+ const leaf = cut < 0 ? text : text.slice(cut + 1);
54
+ const dot = leaf.lastIndexOf('.');
55
+ return dot <= 0 ? '' : leaf.slice(dot).toLowerCase();
56
+ }
57
+
58
+ /**
59
+ * Which media type an attachment claims, if it claims an image one.
60
+ *
61
+ * The controller's `mimeType` is taken only when it agrees with a media type DSH
62
+ * accepts; otherwise the extension decides, and a non-image extension means the
63
+ * bytes travel as a file.
64
+ * @param attachment - `{ path, name, mimeType }` from the controller.
65
+ * @returns a DSH `ImageMediaType`, or null for a non-image.
66
+ */
67
+ export function imageMediaTypeOf(attachment) {
68
+ const declared = typeof attachment?.mimeType === 'string' ? attachment.mimeType.toLowerCase() : '';
69
+ if (Object.values(IMAGE_MEDIA_TYPE_BY_EXT).includes(declared)) return declared;
70
+ const byExtension = IMAGE_MEDIA_TYPE_BY_EXT[extensionOf(attachment?.path) ];
71
+ return byExtension ?? null;
72
+ }
73
+
74
+ /**
75
+ * Build the materializer over whatever filesystem and upload services exist.
76
+ *
77
+ * Either service may be absent when the profile does not compose it, which is
78
+ * reported per attachment rather than as a failure of the whole message: a prompt
79
+ * with an unresolvable attachment still carries its text, and the controller is
80
+ * told what was dropped.
81
+ *
82
+ * @param options - `{ fileSystem, fileUploads, resolveAttachmentRef, maxBytes }`.
83
+ * `resolveAttachmentRef` fetches an uploaded attachment's bytes from the account's
84
+ * media staging area; without it a transit ref is reported as unsupported rather
85
+ * than passed to a filesystem as if it were a path.
86
+ * @returns `{ materialize }`, which answers `{ parts, dropped, consumed }` —
87
+ * `consumed` being the staging keys whose bytes were taken, for the caller to
88
+ * release once the prompt has landed.
89
+ */
90
+ export function createAttachmentMaterializer({ fileSystem, fileUploads, resolveAttachmentRef, maxBytes = MAX_ATTACHMENT_BYTES } = {}) {
91
+ /** Read one whole file through the composed filesystem, or say why not. */
92
+ async function readWholeFile(path) {
93
+ if (fileSystem === undefined || fileSystem === null) return { ok: false, reason: 'no-filesystem' };
94
+ let target;
95
+ try {
96
+ target = typeof fileSystem.resolve === 'function' ? await fileSystem.resolve(path) : undefined;
97
+ } catch {
98
+ return { ok: false, reason: 'unreadable' };
99
+ }
100
+ if (target === undefined) return { ok: false, reason: 'unreadable' };
101
+ let info;
102
+ try {
103
+ info = await fileSystem.stat(target);
104
+ } catch {
105
+ return { ok: false, reason: 'unreadable' };
106
+ }
107
+ if (info?.type === 'directory') return { ok: false, reason: 'directory' };
108
+ const size = Number.isFinite(info?.size) ? info.size : null;
109
+ if (size !== null && size > maxBytes) return { ok: false, reason: 'oversize' };
110
+ try {
111
+ const bytes = await fileSystem.readByteRange(target, { offset: 0, length: size === null ? maxBytes : size });
112
+ const buffer = Buffer.from(bytes);
113
+ if (buffer.length > maxBytes) return { ok: false, reason: 'oversize' };
114
+ return { ok: true, buffer };
115
+ } catch {
116
+ // A sandbox denial surfaces here; it is the policy's answer, not a crash.
117
+ return { ok: false, reason: 'unreadable' };
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Turn bytes this Host now holds into the prompt part the agent reads.
123
+ *
124
+ * One place for both sources, because the *shape* is DSH's contract and must not
125
+ * depend on where the bytes came from.
126
+ */
127
+ async function partFor({ buffer, name, mediaType, agent, signal }) {
128
+ if (mediaType !== null) return { part: { type: 'image', mediaType, data: buffer.toString('base64'), name } };
129
+ if (typeof fileUploads?.upload !== 'function') return { dropped: 'no-upload-service' };
130
+ try {
131
+ const uploaded = await fileUploads.upload(agent, { data: buffer.toString('base64'), name }, signal);
132
+ const receiptId = typeof uploaded?.receiptId === 'string' ? uploaded.receiptId : '';
133
+ if (receiptId === '') return { dropped: 'no-receipt' };
134
+ return { part: { type: 'file', receiptId } };
135
+ } catch (error) {
136
+ return { dropped: `upload-failed: ${String(error?.message ?? error)}` };
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Materialize the controller's attachments for one prompt.
142
+ * @param options - the receiving agent, the controller's attachments, and a signal.
143
+ * @returns the content parts (text excluded), what was dropped and why, and the
144
+ * staging keys the bytes came out of.
145
+ */
146
+ async function materialize({ agent, attachments: incoming = [], signal } = {}) {
147
+ const parts = [];
148
+ const dropped = [];
149
+ const consumed = [];
150
+ for (const attachment of Array.isArray(incoming) ? incoming : []) {
151
+ const path = typeof attachment?.path === 'string' ? attachment.path : '';
152
+ const name = typeof attachment?.name === 'string' && attachment.name !== ''
153
+ ? attachment.name
154
+ : leafNameOf(path);
155
+ if (path === '') {
156
+ dropped.push({ name, reason: 'no-path' });
157
+ continue;
158
+ }
159
+
160
+ let buffer;
161
+ let mediaType;
162
+ if (isAttachmentOssRef(path)) {
163
+ const ref = parseAttachmentOssRef(path);
164
+ if (ref === null) {
165
+ dropped.push({ name, reason: 'malformed-ref' });
166
+ continue;
167
+ }
168
+ if (typeof resolveAttachmentRef !== 'function') {
169
+ dropped.push({ name, reason: 'oss-ref-unsupported' });
170
+ continue;
171
+ }
172
+ const fetched = await resolveAttachmentRef(ref, { signal });
173
+ if (fetched?.ok !== true) {
174
+ dropped.push({ name, reason: String(fetched?.reason ?? 'fetch-failed') });
175
+ continue;
176
+ }
177
+ buffer = fetched.buffer;
178
+ // The reference's own claim about the type is the sender's, and it is the
179
+ // one the phone wrote when it uploaded the bytes; the attachment's `mimeType`
180
+ // is the fallback.
181
+ mediaType = imageMediaTypeOf({ mimeType: fetched.mimeType || ref.mimeType || attachment.mimeType, path: ref.originalName || name });
182
+ const built = await partFor({ buffer, name: fetched.name || name, mediaType, agent, signal });
183
+ if (built.part === undefined) dropped.push({ name, reason: built.dropped });
184
+ else {
185
+ parts.push(built.part);
186
+ consumed.push(ref.ossKey);
187
+ }
188
+ continue;
189
+ }
190
+
191
+ const read = await readWholeFile(path);
192
+ if (!read.ok) {
193
+ dropped.push({ name, reason: read.reason });
194
+ continue;
195
+ }
196
+ mediaType = imageMediaTypeOf(attachment);
197
+ const built = await partFor({ buffer: read.buffer, name, mediaType, agent, signal });
198
+ if (built.part === undefined) dropped.push({ name, reason: built.dropped });
199
+ else parts.push(built.part);
200
+ }
201
+ return { parts, dropped, consumed };
202
+ }
203
+
204
+ return { materialize };
205
+ }
206
+
207
+ /** The leaf name of a path, for a display name that never carries a directory. */
208
+ function leafNameOf(path) {
209
+ const text = typeof path === 'string' ? path.replace(/[\\/]+$/, '') : '';
210
+ const cut = Math.max(text.lastIndexOf('/'), text.lastIndexOf('\\'));
211
+ return cut < 0 ? text : text.slice(cut + 1);
212
+ }
213
+
214
+ /**
215
+ * The controller's own attachments, split into what this Host can serve.
216
+ *
217
+ * Two forms are serveable and both are kept: an absolute host path, and an upload
218
+ * transit reference (either scheme), whose bytes this Host fetches with its own
219
+ * account credential. A path that is neither — a relative path, or some other
220
+ * `scheme://` this Host has no way to resolve — is dropped here rather than handed
221
+ * to a filesystem: the controller's claim about where a file lives is not evidence
222
+ * that it lives there.
223
+ *
224
+ * @param message - the controller's `QueuedRemoteMessage` (or send argument).
225
+ * @returns the serveable attachments, in submission order.
226
+ */
227
+ export function hostAttachmentsOf(message) {
228
+ if (message === null || typeof message !== 'object') return [];
229
+ const files = Array.isArray(message.files) ? message.files : [];
230
+ const out = [];
231
+ for (const file of files) {
232
+ if (file === null || typeof file !== 'object') continue;
233
+ const path = typeof file.path === 'string' ? file.path.trim() : '';
234
+ if (path === '') continue;
235
+ const name = typeof file.name === 'string' && file.name !== '' ? file.name : leafNameOf(path);
236
+ const mimeType = typeof file.mimeType === 'string' ? file.mimeType : '';
237
+ const category = typeof file.category === 'string' ? file.category : '';
238
+ if (isAttachmentOssRef(path)) {
239
+ // A transit ref, not a path: the materializer fetches it. `name` is kept for
240
+ // the drop report, because a ref carries its own `originalName`.
241
+ out.push({ path, name, mimeType, category });
242
+ continue;
243
+ }
244
+ if (path.includes('://')) continue;
245
+ if (!/^[A-Za-z]:[\\/]/.test(path) && !path.startsWith('/')) continue;
246
+ out.push({ path, name, mimeType, category });
247
+ }
248
+ return out;
249
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The link-accept this Host answers a controller's `link-open` with.
3
+ *
4
+ * Two payload fields matter beyond the formality of accepting:
5
+ *
6
+ * - `allowlistHash` — a fingerprint of the channel set, so a controller can notice that
7
+ * the two ends disagree about what exists.
8
+ * - `capabilities` — the **optional end-to-end** features this controlled end offers.
9
+ * They are negotiated here and nowhere else: the phone's transport reads
10
+ * `accepted.capabilities` before it will even call a gated channel
11
+ * (`apps/mobile/src/device-link/historyViewCapability.ts` refuses the three
12
+ * `local-db:messages:*view*` channels unless `history-view-v1` is present). Serving a
13
+ * gated channel without advertising its capability therefore changes nothing at all —
14
+ * which is exactly the trap this file now documents.
15
+ */
16
+
17
+ const CHANNELS = ['device-link:subscribe', 'local-db:sessions:list'];
18
+
19
+ /**
20
+ * Optional capabilities this Host offers.
21
+ *
22
+ * `history-view-v1` (`DEVICE_LINK_CAPABILITY_HISTORY_VIEW_V1` in the Cindy protocol) turns
23
+ * on the work-grouped history window: the controller keeps a projection with its own cursor
24
+ * instead of re-deriving continuity from 20-row pages, which is what made re-entering a
25
+ * session drop the history it had already loaded.
26
+ */
27
+ export const HOST_CAPABILITIES = Object.freeze(['history-view-v1']);
28
+
29
+ export function computeAllowlistHash() { const text = [...CHANNELS].sort().join('\n'); let hash = 0x811c9dc5; for (let i = 0; i < text.length; i++) { hash ^= text.charCodeAt(i); hash = Math.imul(hash, 0x01000193) >>> 0; } return hash.toString(16).padStart(8, '0'); }
30
+
31
+ /**
32
+ * Accept one controller's link and tell it what this Host can do.
33
+ * @param frame - the `link-open` frame being answered.
34
+ * @param acceptedControllers - the set of controllers this Host accepts.
35
+ * @param capabilities - what to advertise; defaults to {@link HOST_CAPABILITIES}.
36
+ * @returns the `link-accept` frame.
37
+ */
38
+ export function acceptLink(frame, acceptedControllers, capabilities = HOST_CAPABILITIES) {
39
+ acceptedControllers.add(frame.src);
40
+ return {
41
+ v: 1,
42
+ kind: 'link-accept',
43
+ id: frame.id,
44
+ dst: frame.src,
45
+ payload: {
46
+ appVersion: '0.1.0',
47
+ allowlistHash: computeAllowlistHash(),
48
+ ...(capabilities.length === 0 ? {} : { capabilities: [...capabilities] }),
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,3 @@
1
+ import { FixtureDshSource } from './fixture-source.js';
2
+ import { startHost } from './host.js';
3
+ await startHost(new FixtureDshSource());