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.
- package/CHANGELOG.md +462 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/cordis.patch.yml +6 -0
- package/doc/architecture.md +34 -0
- package/doc/cindy-android-verify.md +362 -0
- package/doc/cindy-mobile-freeze-handoff.md +124 -0
- package/doc/cindy-phone-link.md +2902 -0
- package/doc/desktop-runtime-fix-20260930.md +69 -0
- package/doc/development-plan.md +19 -0
- package/doc/file-peer-apply-recovery-20260921.md +35 -0
- package/doc/file-peer-installed-hotfix-20260921.md +53 -0
- package/doc/file-peer-minimal-apply-20260921.md +78 -0
- package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
- package/doc/file-peer-phone-export-observation-20260921.md +75 -0
- package/doc/file-peer-product-contract-audit-20260921.md +106 -0
- package/doc/file-peer-root-cause-20260921.md +53 -0
- package/doc/file-transfer.md +50 -0
- package/doc/host-performance-20260930.md +91 -0
- package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
- package/doc/new-session-sync-fix-20260930.md +59 -0
- package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
- package/doc/projection-read-probe.md +77 -0
- package/doc/publishing.md +259 -0
- package/doc/releasing.md +149 -0
- package/doc/research.md +24 -0
- package/doc/session-discovery-2026-09-20.md +67 -0
- package/lib/client.js +763 -0
- package/package.json +84 -0
- package/src/auth-session.js +189 -0
- package/src/authorization-policy.js +59 -0
- package/src/cindy-channels.js +1707 -0
- package/src/cindy-login-flow.js +159 -0
- package/src/cindy-login.js +80 -0
- package/src/cindy-message-row.js +432 -0
- package/src/cindy-session-row.js +171 -0
- package/src/console-sink.js +6 -0
- package/src/contracts.js +26 -0
- package/src/credential-store.js +128 -0
- package/src/demo.js +9 -0
- package/src/dsh-active-sessions.js +59 -0
- package/src/dsh-host-source.js +62 -0
- package/src/dsh-message-fold.js +608 -0
- package/src/dsh-plugin.js +1932 -0
- package/src/dsh-session-source.js +529 -0
- package/src/file-peer-protocol.js +7 -0
- package/src/file-peer-rtc.js +41 -0
- package/src/fixture-source.js +13 -0
- package/src/host-approvals.js +329 -0
- package/src/host-attachments.js +249 -0
- package/src/host-authorization.js +51 -0
- package/src/host-demo.js +3 -0
- package/src/host-export-jobs.js +356 -0
- package/src/host-export-owner.js +62 -0
- package/src/host-file-browser.js +400 -0
- package/src/host-file-ice.js +63 -0
- package/src/host-file-peer.js +289 -0
- package/src/host-file-reference.js +16 -0
- package/src/host-files.js +148 -0
- package/src/host-frame-budget.js +310 -0
- package/src/host-goals.js +284 -0
- package/src/host-history-view-limits.js +22 -0
- package/src/host-history-view.js +417 -0
- package/src/host-input-queue.js +505 -0
- package/src/host-media-fetch.js +511 -0
- package/src/host-media.js +678 -0
- package/src/host-models.js +231 -0
- package/src/host-palette.js +111 -0
- package/src/host-push-channels.js +37 -0
- package/src/host-reconnect.js +54 -0
- package/src/host-routes.js +266 -0
- package/src/host-settings.js +41 -0
- package/src/host-status.js +216 -0
- package/src/host-transfer-diagnostics.js +50 -0
- package/src/host.js +2497 -0
- package/src/login.js +31 -0
- package/src/logout.js +3 -0
- package/src/projection-read-model-sink.js +6 -0
- package/src/projection.js +43 -0
- package/src/session-flags.js +160 -0
- package/src/session-publisher.js +123 -0
- 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
|
+
}
|
package/src/host-demo.js
ADDED