@sparkelf/dsh-plugin-mobile-gateway 0.8.0
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/LICENSE +21 -0
- package/PROTOCOL.md +1180 -0
- package/README.md +281 -0
- package/bin/setup-ip.mjs +474 -0
- package/cordis.patch.yml +44 -0
- package/docs/assets/mobile-device-management.png +0 -0
- package/docs/assets/public-access-ui.png +0 -0
- package/docs/assets/whale-girl-ios-app-promo-16x9.png +0 -0
- package/docs/blog-mobile-gateway.md +542 -0
- package/docs/dsh-0.1.5-rc.2-compatibility-audit.md +255 -0
- package/docs/dsh-0.1.6-alpha.1-compatibility-audit.md +122 -0
- package/docs/dsh-rc2-mobile-integration.md +155 -0
- package/docs/multi-gateway-app-integration.md +124 -0
- package/docs/multi-gateway-phase1-acceptance.md +179 -0
- package/docs/multi-gateway-todo.md +137 -0
- package/docs/remote-gateway-refactor-plan.md +52 -0
- package/docs/runtime-architecture.architecture.json +264 -0
- package/docs/runtime-architecture.html +15001 -0
- package/docs/runtime-architecture.visual-check.html +32 -0
- package/docs/runtime-architecture.visual-check.json +548 -0
- package/docs/session-agent-preset-app-integration.md +93 -0
- package/docs/typert-remote-gateway-feature-checklist.md +294 -0
- package/helper/dsh_mobile_gateway_helper.py +227 -0
- package/lib/client.js +520 -0
- package/lib/devices.js +209 -0
- package/lib/dsh-host-adapter.mjs +466 -0
- package/lib/gateway-state.mjs +57 -0
- package/lib/index.mjs +3197 -0
- package/lib/session-follower.mjs +136 -0
- package/lib/wire-json.mjs +7 -0
- package/package.json +50 -0
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,3197 @@
|
|
|
1
|
+
import { stringifyWireFrame } from './wire-json.mjs'
|
|
2
|
+
// dsh-plugin-mobile-gateway — persistent Host plugin.
|
|
3
|
+
//
|
|
4
|
+
// Two directions:
|
|
5
|
+
// 1. Agent -> mobile: registers a WebSocket endpoint at /ws/mobile on the dsh
|
|
6
|
+
// web server and forwards agent session output (the same `session/event`
|
|
7
|
+
// feed the browser UI consumes) to connected mobile clients as curated
|
|
8
|
+
// JSON text frames.
|
|
9
|
+
// 2. Mobile -> agent: clients send a message through the same socket; the
|
|
10
|
+
// plugin admits it through the DSH Remote Gateway Host API.
|
|
11
|
+
// so it enters the target session exactly like a browser-submitted prompt.
|
|
12
|
+
//
|
|
13
|
+
// Wire protocol (JSON text frames):
|
|
14
|
+
// first pair: Sec-WebSocket-Protocol: dsh-mobile-v1, dsh-pair.<one-time-code>
|
|
15
|
+
// X-DSH-Device-ID: <stable installation UUID>
|
|
16
|
+
// -> { "kind": "paired", "token", "device" } (exactly once)
|
|
17
|
+
// later connects: Authorization: Bearer <device-token>
|
|
18
|
+
// or Sec-WebSocket-Protocol: dsh-mobile-v1, dsh-auth.<device-token>
|
|
19
|
+
// client -> server: { "type": "ping" }
|
|
20
|
+
// { "type": "subscribe", "sessionId": "..." } (optional filter)
|
|
21
|
+
// { "type": "unsubscribe" }
|
|
22
|
+
// { "type": "message", "sessionId"?, "text"?, "images"?:
|
|
23
|
+
// [{ "mediaType", "data", "name"? }], "mode"?: "queue"|"steer",
|
|
24
|
+
// "workspaceId"?, "cwd"?, "clientTimeZone"? }
|
|
25
|
+
// sessionId omitted -> a new session is created first; the
|
|
26
|
+
// new session can be placed in a workspace via workspaceId
|
|
27
|
+
// (or cwd; at most one, workspaceId wins)
|
|
28
|
+
// { "type": "workspaces" } -> workspace list
|
|
29
|
+
// { "type": "sessions" } -> session list
|
|
30
|
+
// { "type": "history", "sessionId", "beforeSeq"?, "maxMessages"?,
|
|
31
|
+
// "maxBytes"?, "view"?: "conversation" }
|
|
32
|
+
// -> raw SessionEvent page (scheme A), capped at maxBytes
|
|
33
|
+
// (default 4 MiB); hasMore + nextBeforeSeq page backward;
|
|
34
|
+
// view:"conversation" trims chunk/header events and tool output
|
|
35
|
+
// { "type": "attachment", "sessionId", "attachmentId" }
|
|
36
|
+
// -> verified image metadata + canonical base64 bytes
|
|
37
|
+
// { "type": "file-list", "requestId"?, "sessionId", "path"? }
|
|
38
|
+
// { "type": "file-download-open", "requestId", "sessionId", "path" }
|
|
39
|
+
// { "type": "file-download-read", "transferId", "offset" }
|
|
40
|
+
// { "type": "file-download-cancel", "transferId" }
|
|
41
|
+
// { "type": "search", "query" } -> session search
|
|
42
|
+
// { "type": "host" } -> host.describe snapshot (incl. default provider/model)
|
|
43
|
+
// { "type": "default-model" } -> agentDefaultModel.currentSelection()
|
|
44
|
+
// { "type": "save-default-model", "provider", "model", "reasoningEffort"? }
|
|
45
|
+
// { "type": "directories", "path"? } -> server dir listing (fs-based)
|
|
46
|
+
// { "type": "directory-create", "path", "name" } -> create one child directory
|
|
47
|
+
// { "type": "workspace-create", "path" } -> create workspace over a dir
|
|
48
|
+
// { "type": "fork", "sessionId", "atSeq"? } -> branch a new session from a completed turn
|
|
49
|
+
// { "type": "session-cancel", "sessionId" } -> stop the active turn and retain queued work
|
|
50
|
+
// { "type": "queue-update", "sessionId", "itemId",
|
|
51
|
+
// "action": "edit"|"remove"|"steer", "text"? }
|
|
52
|
+
// -> edit/remove one pending item or move it into the current turn
|
|
53
|
+
// { "type": "session-archive", "sessionId" } -> hide a session from workspace surfaces
|
|
54
|
+
// { "type": "session-rename", "sessionId", "title" } -> persist a user-owned session title
|
|
55
|
+
// { "type": "models", "sessionId"? } -> per-session catalog (with sessionId) or global (without)
|
|
56
|
+
// { "type": "providers" } -> configurable provider list (live/dormant)
|
|
57
|
+
// { "type": "commands", "sessionId" } -> slash-command catalog for this session
|
|
58
|
+
// { "type": "command-execute", "sessionId", "line", "images"? } -> execute a Host command
|
|
59
|
+
// { "type": "command-options", "sessionId", "command" } -> normalized submenu options
|
|
60
|
+
// { "type": "command-select", "sessionId", "command", "optionId" } -> apply submenu option
|
|
61
|
+
// { "type": "select-model", "sessionId", "provider", "model", "reasoningEffort"? }
|
|
62
|
+
// { "type": "permission-options", "sessionId"? } -> permission presets (+ session knobs)
|
|
63
|
+
// { "type": "permission", "sessionId", "name" } -> switch preset via /permission command
|
|
64
|
+
// { "type": "context-usage", "sessionId" } -> tokenUsage + contextPressure projections
|
|
65
|
+
// { "type": "session-stats", "sessionId" } -> sessionStats + tokenUsage projections
|
|
66
|
+
// (the input-box stats strip source)
|
|
67
|
+
// { "type": "tasks", "sessionId" } -> current todo-list projection
|
|
68
|
+
// { "type": "goal", "sessionId" } -> current goal projection
|
|
69
|
+
// { "type": "goal-edit", "sessionId", "ref", "objective"?, "maxGoalRounds"? }
|
|
70
|
+
// { "type": "goal-pause"|"goal-resume"|"goal-clear", "sessionId", "ref" }
|
|
71
|
+
// { "type": "agent-presets" } -> preset roster (+ isDefault)
|
|
72
|
+
// { "type": "defaults" } -> default agent preset + default permission
|
|
73
|
+
// { "type": "set-default", "target": "agent-preset"|"permission", "value" }
|
|
74
|
+
// { "type": "question-answer", "rpcId", "sessionId", "answers": [...] }
|
|
75
|
+
// { "type": "question-cancel", "rpcId", "sessionId" }
|
|
76
|
+
// { "type": "approval-response", "rpcId", "sessionId", "approvalId",
|
|
77
|
+
// "outcome": "allowed-once"|"rejected" }
|
|
78
|
+
// server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "commands", "tasks", "goals", "session-cancel", "queue-control", "session-archive", "session-rename", "file-downloads"],
|
|
79
|
+
// "authenticated", "device"?, "port", "clients" }
|
|
80
|
+
// { "kind": "pong", "at" }
|
|
81
|
+
// { "kind": "subscribed", "sessionId" }
|
|
82
|
+
// { "kind": "sent", "sessionId", "mode", "command"? }
|
|
83
|
+
// { "kind": "workspaces" | "sessions" | "history" | "search", ...data }
|
|
84
|
+
// { "kind": "commands", "sessionId", "groups": [
|
|
85
|
+
// { "id", "title", "items": [{ "id", "name", "description", "ui" }] }
|
|
86
|
+
// ] }
|
|
87
|
+
// { "kind": "command-options", "sessionId", "command", "options": [
|
|
88
|
+
// { "id", "label", "detail"?, "description"?, "selected" }
|
|
89
|
+
// ] }
|
|
90
|
+
// { "kind": "command-executed", "sessionId", "line", "commandId", "result" }
|
|
91
|
+
// { "kind": "command-selected", "sessionId", "command", "selected" }
|
|
92
|
+
// { "kind": "attachment", "sessionId", "attachment", "data" }
|
|
93
|
+
// { "kind": "file-list", "requestId"?, "sessionId", "path", "entries" }
|
|
94
|
+
// { "kind": "file-download-opened", "requestId", "transferId", "sessionId",
|
|
95
|
+
// "path", "name", "mediaType", "size", "chunkBytes" }
|
|
96
|
+
// { "kind": "file-download-chunk", "transferId", "offset", "data", "eof", "sha256"? }
|
|
97
|
+
// { "kind": "file-download-cancelled", "transferId" }
|
|
98
|
+
// { "kind": "session-cancelled", "sessionId", "accepted": true }
|
|
99
|
+
// { "kind": "queue-item-updated", "sessionId", "itemId", "action", "accepted": true }
|
|
100
|
+
// { "kind": "session-queues", "queues": { "<sessionId>": [...] } }
|
|
101
|
+
// { "kind": "session-queue", "sessionId", "items": [...] }
|
|
102
|
+
// { "kind": "session-archived", "sessionId", "archivedSessionIds" }
|
|
103
|
+
// { "kind": "session-renamed", "sessionId", "title", "seq" }
|
|
104
|
+
// { "kind": "session-archives", "archivedSessionIds" }
|
|
105
|
+
// { "kind": "session-title-changed", "sessionId", "title", "seq", "time", "source"? }
|
|
106
|
+
// { "kind": "error", "code", "message", "requestType"?, "sessionId"? }
|
|
107
|
+
// { "kind": "event", "sessionId", "seq", "time", "event": { ... } }
|
|
108
|
+
// { "kind": "tasks", "sessionId", "asOfSeq", "todos" }
|
|
109
|
+
// { "kind": "goal", "sessionId", "asOfSeq", "goal" }
|
|
110
|
+
// { "kind": "goal-edit"|"goal-pause"|"goal-resume", "sessionId", "ref" }
|
|
111
|
+
// { "kind": "goal-clear", "sessionId", "cleared": true }
|
|
112
|
+
// { "kind": "tasks-updated", "sessionId", "asOfSeq", "todos" }
|
|
113
|
+
// { "kind": "goal-updated", "sessionId", "asOfSeq", "goal" }
|
|
114
|
+
// { "kind": "question-requested", "rpcId", "sessionId", "questions", "replay"? }
|
|
115
|
+
// { "kind": "question-response", "rpcId", "sessionId", "action", "accepted", "reason"? }
|
|
116
|
+
// { "kind": "question-resolved", "rpcId", "sessionId", "outcome" }
|
|
117
|
+
// { "kind": "approval-requested", "rpcId", "sessionId", "approvalId",
|
|
118
|
+
// "toolName", "callId"?, "reason"?, "replay"? }
|
|
119
|
+
// { "kind": "approval-response", "rpcId", "sessionId", "approvalId",
|
|
120
|
+
// "outcome", "accepted", "reason"? }
|
|
121
|
+
// { "kind": "approval-resolved", "rpcId", "sessionId", "approvalId", "outcome" }
|
|
122
|
+
import fs from 'node:fs'
|
|
123
|
+
import fsp from 'node:fs/promises'
|
|
124
|
+
import http from 'node:http'
|
|
125
|
+
import net from 'node:net'
|
|
126
|
+
import os from 'node:os'
|
|
127
|
+
import path from 'node:path'
|
|
128
|
+
import crypto from 'node:crypto'
|
|
129
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
130
|
+
import { WebSocketServer } from 'ws'
|
|
131
|
+
import devicesModule from './devices.js'
|
|
132
|
+
import { createDshHostAdapter, DSH_VERSION, SESSION_FORMAT_VERSION } from './dsh-host-adapter.mjs'
|
|
133
|
+
import { createSessionFollower } from './session-follower.mjs'
|
|
134
|
+
import { createGatewayState, GATEWAY_MODES } from './gateway-state.mjs'
|
|
135
|
+
import QRCode from 'qrcode'
|
|
136
|
+
|
|
137
|
+
const { createRegistry } = devicesModule
|
|
138
|
+
|
|
139
|
+
const PLUGIN_VERSION = (() => {
|
|
140
|
+
try {
|
|
141
|
+
return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
142
|
+
} catch {
|
|
143
|
+
return 'unknown'
|
|
144
|
+
}
|
|
145
|
+
})()
|
|
146
|
+
const MAX_PREVIEW = 400
|
|
147
|
+
const LOG_FILE = '/tmp/mobile-gateway.log'
|
|
148
|
+
const HELPER_SOCKET = '/run/dsh-mobile-gateway/helper.sock'
|
|
149
|
+
const DEFAULT_WS_PATH = '/ws/mobile'
|
|
150
|
+
const DEFAULT_PAIRING_TTL_MS = 5 * 60 * 1000
|
|
151
|
+
const DEFAULT_GATEWAY_WAIT_TIMEOUT_MS = 5 * 60 * 1000
|
|
152
|
+
const DEFAULT_FILE_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024
|
|
153
|
+
const DEFAULT_FILE_DOWNLOAD_CHUNK_BYTES = 512 * 1024
|
|
154
|
+
const DEFAULT_FILE_DOWNLOAD_IDLE_MS = 2 * 60 * 1000
|
|
155
|
+
const DEFAULT_FILE_DOWNLOAD_MAX_TRANSFERS = 4
|
|
156
|
+
const INTERACTION_PROTOCOL_REVISION = 'question-approval-v2'
|
|
157
|
+
// DSH 0.1.1 allows up to 100 MiB of decoded images in one prompt. Base64 plus
|
|
158
|
+
// the JSON envelope needs roughly 4/3 of that on the wire. Keep this separately
|
|
159
|
+
// configurable so deployments may choose a lower transport ceiling.
|
|
160
|
+
const DEFAULT_MAX_WS_PAYLOAD_BYTES = 144 * 1024 * 1024
|
|
161
|
+
const MAX_MANAGEMENT_BODY_BYTES = 16 * 1024
|
|
162
|
+
const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
|
|
163
|
+
const COMMAND_UI_OVERRIDES = Object.freeze({
|
|
164
|
+
permission: Object.freeze({
|
|
165
|
+
kind: 'select',
|
|
166
|
+
optionsRequest: 'command-options',
|
|
167
|
+
selectionRequest: 'command-select',
|
|
168
|
+
}),
|
|
169
|
+
model: Object.freeze({
|
|
170
|
+
kind: 'select',
|
|
171
|
+
optionsRequest: 'command-options',
|
|
172
|
+
selectionRequest: 'command-select',
|
|
173
|
+
}),
|
|
174
|
+
})
|
|
175
|
+
const COMMAND_CATALOG_COPY = Object.freeze({
|
|
176
|
+
zh: Object.freeze({
|
|
177
|
+
locale: 'zh-CN',
|
|
178
|
+
commandsTitle: '命令',
|
|
179
|
+
skillsTitle: '技能',
|
|
180
|
+
userOnly: '仅用户',
|
|
181
|
+
modelDescription: '选择本会话使用的模型',
|
|
182
|
+
hints: Object.freeze({
|
|
183
|
+
plan: '描述你的任务以生成计划',
|
|
184
|
+
goal: '输入目标,智能体将持续执行',
|
|
185
|
+
}),
|
|
186
|
+
}),
|
|
187
|
+
en: Object.freeze({
|
|
188
|
+
locale: 'en',
|
|
189
|
+
commandsTitle: 'Commands',
|
|
190
|
+
skillsTitle: 'Skills',
|
|
191
|
+
userOnly: 'user-only',
|
|
192
|
+
modelDescription: 'Select the model for this conversation',
|
|
193
|
+
hints: Object.freeze({
|
|
194
|
+
plan: 'describe your task to generate plan',
|
|
195
|
+
goal: 'describe the objective for a long-running task',
|
|
196
|
+
}),
|
|
197
|
+
}),
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// Cordis validates this schema at plugin load and fills these defaults. Keep
|
|
201
|
+
// the defaults conservative: installing the bundle must never create an
|
|
202
|
+
// unauthenticated network control plane.
|
|
203
|
+
const Config = Schema.object({
|
|
204
|
+
path: Schema.string().default(DEFAULT_WS_PATH),
|
|
205
|
+
requireAuth: Schema.boolean().default(true),
|
|
206
|
+
gatewayEnabled: Schema.boolean().default(false),
|
|
207
|
+
gatewayMode: Schema.union(GATEWAY_MODES),
|
|
208
|
+
gatewayStateFile: Schema.string().default(''),
|
|
209
|
+
gatewayName: Schema.string().default(''),
|
|
210
|
+
endpoints: Schema.array(Schema.string()).default([]),
|
|
211
|
+
gatewayWaitTimeoutMs: Schema.natural().min(30_000).max(30 * 60 * 1000).default(DEFAULT_GATEWAY_WAIT_TIMEOUT_MS),
|
|
212
|
+
maxPayloadBytes: Schema.natural().min(1024 * 1024).max(160 * 1024 * 1024).default(DEFAULT_MAX_WS_PAYLOAD_BYTES),
|
|
213
|
+
fileDownloadsEnabled: Schema.boolean().default(true),
|
|
214
|
+
fileDownloadMaxBytes: Schema.natural().min(1024 * 1024).max(2 * 1024 * 1024 * 1024).default(DEFAULT_FILE_DOWNLOAD_MAX_BYTES),
|
|
215
|
+
fileDownloadChunkBytes: Schema.natural().min(64 * 1024).max(1024 * 1024).default(DEFAULT_FILE_DOWNLOAD_CHUNK_BYTES),
|
|
216
|
+
fileDownloadIdleMs: Schema.natural().min(10_000).max(30 * 60 * 1000).default(DEFAULT_FILE_DOWNLOAD_IDLE_MS),
|
|
217
|
+
fileDownloadMaxTransfers: Schema.natural().min(1).max(16).default(DEFAULT_FILE_DOWNLOAD_MAX_TRANSFERS),
|
|
218
|
+
adminLoopbackOnly: Schema.boolean().default(true),
|
|
219
|
+
publicUrl: Schema.string().default(''),
|
|
220
|
+
deviceFile: Schema.string().default(''),
|
|
221
|
+
pairingTtlMs: Schema.natural().min(30_000).max(15 * 60 * 1000).default(DEFAULT_PAIRING_TTL_MS),
|
|
222
|
+
allowQueryToken: Schema.boolean().default(false),
|
|
223
|
+
publicUrlFile: Schema.string().default('/etc/dsh-mobile-gateway/public-url'),
|
|
224
|
+
lanEnabled: Schema.boolean().default(false),
|
|
225
|
+
lanHost: Schema.string().default('0.0.0.0'),
|
|
226
|
+
lanPort: Schema.natural().min(1).max(65535).default(3081),
|
|
227
|
+
lanAdvertiseHost: Schema.string().default(''),
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
function log(line) {
|
|
231
|
+
const msg = `[${new Date().toISOString()}] ${line}`
|
|
232
|
+
console.log('mobile-gateway:', msg)
|
|
233
|
+
try {
|
|
234
|
+
fs.appendFileSync(LOG_FILE, msg + '\n')
|
|
235
|
+
} catch (error) {
|
|
236
|
+
// never let logging break the gateway
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Extract plain text from a ContentBlock[] (text blocks only).
|
|
241
|
+
function textOf(blocks) {
|
|
242
|
+
let text = ''
|
|
243
|
+
for (const block of blocks) {
|
|
244
|
+
if (block && block.type === 'text' && typeof block.text === 'string') text += block.text
|
|
245
|
+
}
|
|
246
|
+
return text
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function imagesOf(blocks) {
|
|
250
|
+
const images = []
|
|
251
|
+
for (const block of blocks) {
|
|
252
|
+
if (!block || block.type !== 'image' || !block.attachment) continue
|
|
253
|
+
const attachment = block.attachment
|
|
254
|
+
images.push({
|
|
255
|
+
attachmentId: attachment.attachmentId,
|
|
256
|
+
mediaType: attachment.mediaType,
|
|
257
|
+
bytes: attachment.bytes,
|
|
258
|
+
width: attachment.width,
|
|
259
|
+
height: attachment.height,
|
|
260
|
+
...(attachment.name ? { name: attachment.name } : {}),
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
return images
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Build the small, owned JSON wire record for one session event. Reads only
|
|
267
|
+
// leaf fields of the live SessionEvent — never serializes live objects.
|
|
268
|
+
function buildWireEvent(session, event) {
|
|
269
|
+
const base = { kind: 'event', sessionId: String(session.id), seq: event.seq, time: event.time,
|
|
270
|
+
...(event.surfaceOp === undefined ? {} : { surfaceOp: event.surfaceOp }),
|
|
271
|
+
...(event.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: event.sourceEventSeqs }),
|
|
272
|
+
}
|
|
273
|
+
const d = event.data || {}
|
|
274
|
+
switch (event.type) {
|
|
275
|
+
case 'user/message': {
|
|
276
|
+
const images = imagesOf(d.content || [])
|
|
277
|
+
return Object.assign(base, {
|
|
278
|
+
event: {
|
|
279
|
+
type: 'user/message',
|
|
280
|
+
text: textOf(d.content || []),
|
|
281
|
+
source: d.source && d.source.kind,
|
|
282
|
+
...(typeof d.id === 'string' && d.id ? { raw: { id: d.id } } : {}),
|
|
283
|
+
...(images.length ? { images } : {}),
|
|
284
|
+
},
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
case 'assistant/message': {
|
|
288
|
+
const blocks = (d.message && d.message.content) || []
|
|
289
|
+
let text = ''
|
|
290
|
+
let reasoning = ''
|
|
291
|
+
const toolCalls = []
|
|
292
|
+
const images = imagesOf(blocks)
|
|
293
|
+
for (const block of blocks) {
|
|
294
|
+
if (!block) continue
|
|
295
|
+
if (block.type === 'text' && typeof block.text === 'string') text += block.text
|
|
296
|
+
else if (block.type === 'reasoning' && typeof block.text === 'string') reasoning += block.text
|
|
297
|
+
else if (block.type === 'tool-call') toolCalls.push({ id: block.id, name: block.name, arguments: block.arguments })
|
|
298
|
+
}
|
|
299
|
+
return Object.assign(base, {
|
|
300
|
+
event: { type: 'assistant/message', turn: d.turn, step: d.step, text, reasoning, toolCalls,
|
|
301
|
+
...(d.interrupted === true ? { interrupted: true } : {}),
|
|
302
|
+
...(d.usage === undefined ? {} : { usage: d.usage }),
|
|
303
|
+
...(images.length ? { images } : {}),
|
|
304
|
+
},
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
case 'assistant/attempt':
|
|
308
|
+
return Object.assign(base, { event: { type: event.type, turn: d.turn, step: d.step, stream: d.stream } })
|
|
309
|
+
case 'session/title':
|
|
310
|
+
return Object.assign(base, {
|
|
311
|
+
event: {
|
|
312
|
+
type: 'session/title',
|
|
313
|
+
title: d.title,
|
|
314
|
+
...(d.source ? { source: d.source } : {}),
|
|
315
|
+
},
|
|
316
|
+
})
|
|
317
|
+
case 'agent-preset/selected':
|
|
318
|
+
return Object.assign(base, { event: { type: event.type, agentPreset: d.agentPreset } })
|
|
319
|
+
case 'tool/call':
|
|
320
|
+
return Object.assign(base, {
|
|
321
|
+
event: { type: 'tool/call', turn: d.turn, step: d.step, callId: d.callId, name: d.name, arguments: d.arguments },
|
|
322
|
+
})
|
|
323
|
+
case 'tool/result': {
|
|
324
|
+
let preview = ''
|
|
325
|
+
for (const block of (d.message && d.message.content) || []) {
|
|
326
|
+
for (const inner of (block && block.content) || []) {
|
|
327
|
+
if (inner.type === 'text' && typeof inner.text === 'string') preview += inner.text
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (preview.length > MAX_PREVIEW) preview = preview.slice(0, MAX_PREVIEW) + '…'
|
|
331
|
+
return Object.assign(base, {
|
|
332
|
+
event: {
|
|
333
|
+
type: 'tool/result',
|
|
334
|
+
turn: d.turn,
|
|
335
|
+
step: d.step,
|
|
336
|
+
callId: d.message && d.message.source && d.message.source.callId,
|
|
337
|
+
isError: !!d.error || ((d.message && d.message.content) || []).some(block => block?.type === 'tool-result' && block.isError === true),
|
|
338
|
+
preview,
|
|
339
|
+
},
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
case 'command/run':
|
|
343
|
+
return Object.assign(base, {
|
|
344
|
+
event: {
|
|
345
|
+
type: 'command/run',
|
|
346
|
+
commandId: d.commandId,
|
|
347
|
+
name: d.name,
|
|
348
|
+
...(typeof d.args === 'string' ? { args: d.args } : {}),
|
|
349
|
+
...(d.source ? { source: d.source } : {}),
|
|
350
|
+
},
|
|
351
|
+
})
|
|
352
|
+
case 'command/done':
|
|
353
|
+
return Object.assign(base, {
|
|
354
|
+
event: {
|
|
355
|
+
type: 'command/done',
|
|
356
|
+
commandId: d.commandId,
|
|
357
|
+
outcome: d.kind,
|
|
358
|
+
...(typeof d.text === 'string' ? { text: d.text } : {}),
|
|
359
|
+
...(typeof d.sourceEventSeq === 'number' ? { sourceEventSeq: d.sourceEventSeq } : {}),
|
|
360
|
+
},
|
|
361
|
+
})
|
|
362
|
+
case 'compaction/start':
|
|
363
|
+
return Object.assign(base, {
|
|
364
|
+
event: {
|
|
365
|
+
type: 'compaction/start',
|
|
366
|
+
compactionId: d.compactionId,
|
|
367
|
+
...(d.sourceCommandId ? { sourceCommandId: d.sourceCommandId } : {}),
|
|
368
|
+
turn: d.turn ?? null,
|
|
369
|
+
},
|
|
370
|
+
})
|
|
371
|
+
case 'compaction/summary':
|
|
372
|
+
return Object.assign(base, {
|
|
373
|
+
event: {
|
|
374
|
+
type: 'compaction/summary',
|
|
375
|
+
compactionId: d.compactionId,
|
|
376
|
+
...(d.sourceCommandId ? { sourceCommandId: d.sourceCommandId } : {}),
|
|
377
|
+
shadowedItemCount: Array.isArray(d.shadowedSeqs) ? d.shadowedSeqs.length : null,
|
|
378
|
+
shadowedTokenCount: typeof d.shadowedTokenCount === 'number' ? d.shadowedTokenCount : null,
|
|
379
|
+
},
|
|
380
|
+
})
|
|
381
|
+
case 'compaction/end':
|
|
382
|
+
return Object.assign(base, {
|
|
383
|
+
event: {
|
|
384
|
+
type: 'compaction/end',
|
|
385
|
+
compactionId: d.compactionId,
|
|
386
|
+
...(d.sourceCommandId ? { sourceCommandId: d.sourceCommandId } : {}),
|
|
387
|
+
turn: d.turn ?? null,
|
|
388
|
+
...(typeof d.error === 'string' ? { error: d.error } : {}),
|
|
389
|
+
},
|
|
390
|
+
})
|
|
391
|
+
case 'turn/start':
|
|
392
|
+
case 'turn/end':
|
|
393
|
+
case 'step/start':
|
|
394
|
+
case 'step/end':
|
|
395
|
+
return Object.assign(base, {
|
|
396
|
+
event: { type: event.type, turn: d.turn, step: d.step, reason: d.reason && d.reason.kind },
|
|
397
|
+
})
|
|
398
|
+
default:
|
|
399
|
+
return Object.assign(base, { event: { type: event.type } })
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function parseWireImages(rawImages, requestType) {
|
|
404
|
+
const images = rawImages === undefined ? [] : rawImages
|
|
405
|
+
if (!Array.isArray(images)) {
|
|
406
|
+
return { error: { kind: 'error', code: 'bad-request', message: 'images must be an array', requestType } }
|
|
407
|
+
}
|
|
408
|
+
if (images.length > 20) {
|
|
409
|
+
return { error: { kind: 'error', code: 'bad-request', message: 'a request can contain at most 20 images', requestType } }
|
|
410
|
+
}
|
|
411
|
+
const imageParts = []
|
|
412
|
+
for (let index = 0; index < images.length; index++) {
|
|
413
|
+
const image = images[index]
|
|
414
|
+
if (!image || typeof image !== 'object') {
|
|
415
|
+
return { error: { kind: 'error', code: 'bad-request', message: `images[${index}] must be an object`, requestType } }
|
|
416
|
+
}
|
|
417
|
+
if (!IMAGE_MEDIA_TYPES.has(image.mediaType)) {
|
|
418
|
+
return { error: { kind: 'error', code: 'bad-request', message: `images[${index}].mediaType is unsupported`, requestType } }
|
|
419
|
+
}
|
|
420
|
+
if (typeof image.data !== 'string' || image.data.length === 0) {
|
|
421
|
+
return { error: { kind: 'error', code: 'bad-request', message: `images[${index}].data must be a non-empty base64 string`, requestType } }
|
|
422
|
+
}
|
|
423
|
+
if (image.name !== undefined && (typeof image.name !== 'string' || image.name.length > 255)) {
|
|
424
|
+
return { error: { kind: 'error', code: 'bad-request', message: `images[${index}].name must be a string of at most 255 characters`, requestType } }
|
|
425
|
+
}
|
|
426
|
+
imageParts.push({
|
|
427
|
+
type: 'image',
|
|
428
|
+
mediaType: image.mediaType,
|
|
429
|
+
data: image.data,
|
|
430
|
+
...(image.name ? { name: image.name } : {}),
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
return { value: imageParts }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function commandNameOf(line) {
|
|
437
|
+
const trimmed = line.trim()
|
|
438
|
+
if (!trimmed.startsWith('/')) return null
|
|
439
|
+
const end = trimmed.search(/\s/)
|
|
440
|
+
return (end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)) || null
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function listHostCommands(host, sessionId) {
|
|
444
|
+
const listed = await host.commands.list(sessionId, new AbortController().signal)
|
|
445
|
+
if (!Array.isArray(listed)) throw new Error('commands/list returned an invalid catalog')
|
|
446
|
+
return listed
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function executeHostCommand(host, sessionId, line, images, requestType) {
|
|
450
|
+
try {
|
|
451
|
+
const execution = await host.commands.execute(sessionId, line, images, new AbortController().signal)
|
|
452
|
+
if (execution === undefined || execution === null) {
|
|
453
|
+
return { kind: 'error', code: 'unknown-command', message: `unknown or malformed command: ${line}`, requestType, sessionId }
|
|
454
|
+
}
|
|
455
|
+
log(`command executed: session=${sessionId} line=${JSON.stringify(line)} commandId=${execution.commandId} outcome=${execution.result.kind}`)
|
|
456
|
+
return {
|
|
457
|
+
kind: 'command-executed',
|
|
458
|
+
sessionId,
|
|
459
|
+
line,
|
|
460
|
+
commandId: execution.commandId,
|
|
461
|
+
result: execution.result,
|
|
462
|
+
}
|
|
463
|
+
} catch (error) {
|
|
464
|
+
const code = error && error.code ? error.code : 'internal'
|
|
465
|
+
const message = error && error.message ? error.message : String(error)
|
|
466
|
+
log(`command rejected: session=${sessionId} ${code}: ${message}`)
|
|
467
|
+
return { kind: 'error', code, message, requestType, sessionId }
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function admitCommand(host, msg) {
|
|
472
|
+
const sessionId = requireSessionId(msg)
|
|
473
|
+
if (sessionId.error) return sessionId.error
|
|
474
|
+
const line = typeof msg.line === 'string' ? msg.line.trim() : ''
|
|
475
|
+
const name = commandNameOf(line)
|
|
476
|
+
if (!name) {
|
|
477
|
+
return { kind: 'error', code: 'bad-request', message: 'command-execute requires a slash-prefixed line', requestType: 'command-execute', sessionId: sessionId.value }
|
|
478
|
+
}
|
|
479
|
+
const parsedImages = parseWireImages(msg.images, 'command-execute')
|
|
480
|
+
if (parsedImages.error) return { ...parsedImages.error, sessionId: sessionId.value }
|
|
481
|
+
try {
|
|
482
|
+
const listed = await listHostCommands(host, sessionId.value)
|
|
483
|
+
const descriptor = listed.find((command) => command && command.name === name)
|
|
484
|
+
if (!descriptor) {
|
|
485
|
+
return { kind: 'error', code: 'unknown-command', message: `command not found: /${name}`, requestType: 'command-execute', sessionId: sessionId.value }
|
|
486
|
+
}
|
|
487
|
+
if (parsedImages.value.length > 0 && !commandAcceptsAttachments(descriptor)) {
|
|
488
|
+
return { kind: 'error', code: 'bad-request', message: `/${name} does not accept image attachments`, requestType: 'command-execute', sessionId: sessionId.value }
|
|
489
|
+
}
|
|
490
|
+
return executeHostCommand(host, sessionId.value, line, parsedImages.value, 'command-execute')
|
|
491
|
+
} catch (error) {
|
|
492
|
+
const code = error && error.code ? error.code : 'internal'
|
|
493
|
+
const message = error && error.message ? error.message : String(error)
|
|
494
|
+
return { kind: 'error', code, message, requestType: 'command-execute', sessionId: sessionId.value }
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Handle one mobile -> agent message through the official host API. Returns
|
|
499
|
+
// the wire frame to send back, or null when nothing should be sent.
|
|
500
|
+
async function admitMessage(api, msg) {
|
|
501
|
+
const preset = parseAgentPreset(msg, 'message')
|
|
502
|
+
if (preset.error) return preset.error
|
|
503
|
+
if (preset.value !== undefined && typeof msg.sessionId === 'string' && msg.sessionId.trim()) {
|
|
504
|
+
return { kind: 'error', code: 'bad-request', requestType: 'message',
|
|
505
|
+
message: 'use select-agent-preset before sending a message to an existing session' }
|
|
506
|
+
}
|
|
507
|
+
const text = typeof msg.text === 'string' ? msg.text.trim() : ''
|
|
508
|
+
const parsedImages = parseWireImages(msg.images, 'message')
|
|
509
|
+
if (parsedImages.error) return parsedImages.error
|
|
510
|
+
const imageParts = parsedImages.value
|
|
511
|
+
if (!text && imageParts.length === 0) {
|
|
512
|
+
return { kind: 'error', code: 'bad-request', message: 'message requires non-empty text or at least one image' }
|
|
513
|
+
}
|
|
514
|
+
const mode = msg.mode === 'steer' ? 'steer' : 'queue'
|
|
515
|
+
|
|
516
|
+
let sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : undefined
|
|
517
|
+
try {
|
|
518
|
+
if (sessionId === undefined) {
|
|
519
|
+
// New session: optionally place it in a workspace (at most one of
|
|
520
|
+
// workspaceId / cwd, per the host create contract; workspaceId wins).
|
|
521
|
+
const createPayload = {}
|
|
522
|
+
if (preset.value !== undefined) createPayload.agentPreset = preset.value
|
|
523
|
+
if (typeof msg.workspaceId === 'string' && msg.workspaceId.trim() !== '') {
|
|
524
|
+
createPayload.workspaceId = msg.workspaceId.trim()
|
|
525
|
+
}
|
|
526
|
+
if (typeof msg.cwd === 'string' && msg.cwd.trim() !== '') {
|
|
527
|
+
if (createPayload.workspaceId) log('mobile message: both workspaceId and cwd given, using workspaceId')
|
|
528
|
+
else createPayload.cwd = msg.cwd.trim()
|
|
529
|
+
}
|
|
530
|
+
const created = await api.sessions.create(createPayload)
|
|
531
|
+
sessionId = created.sessionId
|
|
532
|
+
log(`mobile message created new session ${sessionId} (${JSON.stringify(createPayload)})`)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const resp = await api.sessions.prompt({
|
|
536
|
+
sessionId,
|
|
537
|
+
mode,
|
|
538
|
+
// Match the official WebUI ordering: images first, optional text last.
|
|
539
|
+
content: [...imageParts, ...(text ? [{ type: 'text', text }] : [])],
|
|
540
|
+
...(typeof msg.clientTimeZone === 'string' && msg.clientTimeZone.trim() ? { clientTimeZone: msg.clientTimeZone.trim() } : {}),
|
|
541
|
+
})
|
|
542
|
+
log(`mobile message accepted: session=${sessionId} mode=${mode} images=${imageParts.length} text="${text.slice(0, 60)}"`)
|
|
543
|
+
return {
|
|
544
|
+
kind: 'sent',
|
|
545
|
+
sessionId,
|
|
546
|
+
mode,
|
|
547
|
+
...(resp.command ? { command: resp.command } : {}),
|
|
548
|
+
}
|
|
549
|
+
} catch (error) {
|
|
550
|
+
const code = error && error.code ? error.code : 'internal'
|
|
551
|
+
const message = error && error.message ? error.message : String(error)
|
|
552
|
+
log(`mobile message failed: ${message}`)
|
|
553
|
+
return { kind: 'error', code, message, ...(sessionId ? { sessionId } : {}) }
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Proxy one read-only query to the official host API (the same surface the
|
|
558
|
+
// browser uses). Success returns `{ kind: <type>, ...value }`; failure returns
|
|
559
|
+
// the uniform `{ kind: 'error', code, message, requestType }` frame.
|
|
560
|
+
async function proxyQuery(api, type, method, payload, signal) {
|
|
561
|
+
try {
|
|
562
|
+
const value = signal ? await method(payload, signal) : await method(payload)
|
|
563
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
564
|
+
throw new Error(`${type} returned an invalid response`)
|
|
565
|
+
}
|
|
566
|
+
log(`query ok: ${type}`)
|
|
567
|
+
return { kind: type, ...value }
|
|
568
|
+
} catch (error) {
|
|
569
|
+
const code = error && error.code ? error.code : 'internal'
|
|
570
|
+
const message = error && error.message ? error.message : String(error)
|
|
571
|
+
log(`query failed: ${type} -> ${code}: ${message}`)
|
|
572
|
+
return { kind: 'error', code, message, requestType: type }
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Projection queries must keep the caller's request identity on failure.
|
|
577
|
+
// They need the opening cut and projections, not the full conversation body.
|
|
578
|
+
async function querySessionProjection(api, requestType, sessionId) {
|
|
579
|
+
const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId, maxMessages: 1 })
|
|
580
|
+
return history.kind === 'error' ? { ...history, requestType, sessionId } : history
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function fileTransferError(code, message, requestType, sessionId) {
|
|
584
|
+
return {
|
|
585
|
+
kind: 'error',
|
|
586
|
+
code,
|
|
587
|
+
message,
|
|
588
|
+
requestType,
|
|
589
|
+
...(sessionId ? { sessionId } : {}),
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function fileMediaType(filePath) {
|
|
594
|
+
switch (path.extname(filePath).toLowerCase()) {
|
|
595
|
+
case '.apk': return 'application/vnd.android.package-archive'
|
|
596
|
+
case '.doc': return 'application/msword'
|
|
597
|
+
case '.docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
598
|
+
case '.ipa': return 'application/octet-stream'
|
|
599
|
+
case '.json': return 'application/json'
|
|
600
|
+
case '.pdf': return 'application/pdf'
|
|
601
|
+
case '.ppt': return 'application/vnd.ms-powerpoint'
|
|
602
|
+
case '.pptx': return 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
|
603
|
+
case '.txt': return 'text/plain'
|
|
604
|
+
case '.xls': return 'application/vnd.ms-excel'
|
|
605
|
+
case '.xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
606
|
+
case '.zip': return 'application/zip'
|
|
607
|
+
default: return 'application/octet-stream'
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function workspaceRelativePath(root, target) {
|
|
612
|
+
const relative = path.relative(root, target)
|
|
613
|
+
return relative ? relative.split(path.sep).join('/') : '.'
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function isInsideWorkspace(root, target) {
|
|
617
|
+
const relative = path.relative(root, target)
|
|
618
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function normalizedWorkspaceInput(value, { allowEmpty = false } = {}) {
|
|
622
|
+
if (value === undefined && allowEmpty) return '.'
|
|
623
|
+
if (typeof value !== 'string') return null
|
|
624
|
+
const trimmed = value.trim()
|
|
625
|
+
if (!trimmed && allowEmpty) return '.'
|
|
626
|
+
if (!trimmed || trimmed.includes('\0') || path.isAbsolute(trimmed)) return null
|
|
627
|
+
if (trimmed.split(/[\\/]+/).includes('..')) return null
|
|
628
|
+
return trimmed
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Owns only workspace-scoped, regular-file downloads. The host API has an
|
|
632
|
+
// image-only attachment read path, so arbitrary document/archive bytes must be
|
|
633
|
+
// guarded here instead of accepting a host path from the mobile client.
|
|
634
|
+
function createFileTransferManager(api, options) {
|
|
635
|
+
const transfers = new Map()
|
|
636
|
+
|
|
637
|
+
const closeTransfer = async (transferId) => {
|
|
638
|
+
const transfer = transfers.get(transferId)
|
|
639
|
+
if (!transfer) return false
|
|
640
|
+
transfers.delete(transferId)
|
|
641
|
+
try {
|
|
642
|
+
await transfer.handle.close()
|
|
643
|
+
} catch {
|
|
644
|
+
// A failed close cannot make a completed or cancelled transfer usable.
|
|
645
|
+
}
|
|
646
|
+
return true
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const resolveSessionRoot = async (sessionId, requestType) => {
|
|
650
|
+
const sessions = await proxyQuery(api, requestType, api.sessions.list.bind(api.sessions), {})
|
|
651
|
+
if (sessions.kind === 'error') return sessions
|
|
652
|
+
const item = Array.isArray(sessions.items)
|
|
653
|
+
? sessions.items.find((entry) => entry && String(entry.sessionId) === sessionId)
|
|
654
|
+
: null
|
|
655
|
+
if (!item) return fileTransferError('session-not-found', 'no such session', requestType, sessionId)
|
|
656
|
+
if (typeof item.cwd !== 'string' || !item.cwd.trim()) {
|
|
657
|
+
return fileTransferError('file-workspace-unavailable', 'the session has no working directory', requestType, sessionId)
|
|
658
|
+
}
|
|
659
|
+
try {
|
|
660
|
+
const root = await fsp.realpath(item.cwd)
|
|
661
|
+
const stat = await fsp.stat(root)
|
|
662
|
+
if (!stat.isDirectory()) {
|
|
663
|
+
return fileTransferError('file-workspace-unavailable', 'the session working directory is unavailable', requestType, sessionId)
|
|
664
|
+
}
|
|
665
|
+
return { root }
|
|
666
|
+
} catch {
|
|
667
|
+
return fileTransferError('file-workspace-unavailable', 'the session working directory is unavailable', requestType, sessionId)
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const resolveWorkspaceTarget = async (sessionId, rawPath, requestType, inputOptions) => {
|
|
672
|
+
const requested = normalizedWorkspaceInput(rawPath, inputOptions)
|
|
673
|
+
if (!requested) {
|
|
674
|
+
return fileTransferError('bad-request', `${requestType} requires a relative workspace path`, requestType, sessionId)
|
|
675
|
+
}
|
|
676
|
+
const rootResult = await resolveSessionRoot(sessionId, requestType)
|
|
677
|
+
if (rootResult.kind === 'error') return rootResult
|
|
678
|
+
const candidate = path.resolve(rootResult.root, requested)
|
|
679
|
+
if (!isInsideWorkspace(rootResult.root, candidate)) {
|
|
680
|
+
return fileTransferError('file-not-allowed', 'path must stay inside the session working directory', requestType, sessionId)
|
|
681
|
+
}
|
|
682
|
+
try {
|
|
683
|
+
const target = await fsp.realpath(candidate)
|
|
684
|
+
if (!isInsideWorkspace(rootResult.root, target)) {
|
|
685
|
+
return fileTransferError('file-not-allowed', 'path must stay inside the session working directory', requestType, sessionId)
|
|
686
|
+
}
|
|
687
|
+
const stat = await fsp.stat(target)
|
|
688
|
+
return { root: rootResult.root, target, stat }
|
|
689
|
+
} catch (error) {
|
|
690
|
+
if (error && error.code === 'ENOENT') {
|
|
691
|
+
return fileTransferError('file-not-found', 'file does not exist', requestType, sessionId)
|
|
692
|
+
}
|
|
693
|
+
return fileTransferError('file-unreadable', 'file cannot be read', requestType, sessionId)
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const fileList = async (msg) => {
|
|
698
|
+
const session = requireSessionId(msg)
|
|
699
|
+
if (session.error) return session.error
|
|
700
|
+
const resolved = await resolveWorkspaceTarget(session.value, msg.path, 'file-list', { allowEmpty: true })
|
|
701
|
+
if (resolved.kind === 'error') return resolved
|
|
702
|
+
if (!resolved.stat.isDirectory()) {
|
|
703
|
+
return fileTransferError('file-not-directory', 'path is not a directory', 'file-list', session.value)
|
|
704
|
+
}
|
|
705
|
+
try {
|
|
706
|
+
const dirents = await fsp.readdir(resolved.target, { withFileTypes: true })
|
|
707
|
+
const entries = []
|
|
708
|
+
for (const dirent of dirents) {
|
|
709
|
+
if (dirent.isSymbolicLink()) continue
|
|
710
|
+
const child = path.join(resolved.target, dirent.name)
|
|
711
|
+
if (dirent.isDirectory()) {
|
|
712
|
+
entries.push({ name: dirent.name, path: workspaceRelativePath(resolved.root, child), kind: 'directory' })
|
|
713
|
+
} else if (dirent.isFile()) {
|
|
714
|
+
const stat = await fsp.stat(child)
|
|
715
|
+
entries.push({
|
|
716
|
+
name: dirent.name,
|
|
717
|
+
path: workspaceRelativePath(resolved.root, child),
|
|
718
|
+
kind: 'file',
|
|
719
|
+
bytes: stat.size,
|
|
720
|
+
modifiedAt: stat.mtimeMs,
|
|
721
|
+
mediaType: fileMediaType(child),
|
|
722
|
+
})
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
entries.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'directory' ? -1 : 1))
|
|
726
|
+
const requestId = typeof msg.requestId === 'string' && msg.requestId.trim() ? msg.requestId.trim() : null
|
|
727
|
+
return {
|
|
728
|
+
kind: 'file-list',
|
|
729
|
+
...(requestId ? { requestId } : {}),
|
|
730
|
+
sessionId: session.value,
|
|
731
|
+
path: workspaceRelativePath(resolved.root, resolved.target),
|
|
732
|
+
entries,
|
|
733
|
+
}
|
|
734
|
+
} catch {
|
|
735
|
+
return fileTransferError('file-unreadable', 'directory cannot be read', 'file-list', session.value)
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const open = async (client, msg) => {
|
|
740
|
+
const requestId = typeof msg.requestId === 'string' && msg.requestId.trim() ? msg.requestId.trim() : null
|
|
741
|
+
const session = requireSessionId(msg)
|
|
742
|
+
if (!requestId) return fileTransferError('bad-request', 'file-download-open requires a requestId', 'file-download-open')
|
|
743
|
+
if (session.error) return session.error
|
|
744
|
+
if (transfers.size >= options.fileDownloadMaxTransfers) {
|
|
745
|
+
return fileTransferError('file-transfer-limit', 'too many active file downloads', 'file-download-open', session.value)
|
|
746
|
+
}
|
|
747
|
+
const resolved = await resolveWorkspaceTarget(session.value, msg.path, 'file-download-open')
|
|
748
|
+
if (resolved.kind === 'error') return resolved
|
|
749
|
+
if (!resolved.stat.isFile()) {
|
|
750
|
+
return fileTransferError('file-not-regular', 'only regular files can be downloaded', 'file-download-open', session.value)
|
|
751
|
+
}
|
|
752
|
+
if (resolved.stat.size > options.fileDownloadMaxBytes) {
|
|
753
|
+
return fileTransferError('file-too-large', 'file exceeds the configured download limit', 'file-download-open', session.value)
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
let handle
|
|
757
|
+
try {
|
|
758
|
+
// Reject a last-moment leaf symlink replacement after the realpath
|
|
759
|
+
// containment check above. macOS and Linux both support O_NOFOLLOW.
|
|
760
|
+
handle = await fsp.open(resolved.target, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
|
|
761
|
+
const opened = await handle.stat()
|
|
762
|
+
if (!opened.isFile() || opened.size !== resolved.stat.size) {
|
|
763
|
+
await handle.close()
|
|
764
|
+
return fileTransferError('file-changed', 'file changed before download could start', 'file-download-open', session.value)
|
|
765
|
+
}
|
|
766
|
+
} catch {
|
|
767
|
+
return fileTransferError('file-unreadable', 'file cannot be opened', 'file-download-open', session.value)
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const transferId = crypto.randomUUID()
|
|
771
|
+
const transfer = {
|
|
772
|
+
client,
|
|
773
|
+
handle,
|
|
774
|
+
sessionId: session.value,
|
|
775
|
+
path: workspaceRelativePath(resolved.root, resolved.target),
|
|
776
|
+
name: path.basename(resolved.target),
|
|
777
|
+
mediaType: fileMediaType(resolved.target),
|
|
778
|
+
size: resolved.stat.size,
|
|
779
|
+
offset: 0,
|
|
780
|
+
hash: crypto.createHash('sha256'),
|
|
781
|
+
reading: false,
|
|
782
|
+
lastActiveAt: Date.now(),
|
|
783
|
+
}
|
|
784
|
+
transfers.set(transferId, transfer)
|
|
785
|
+
log(`file download opened: transfer=${transferId} session=${transfer.sessionId} bytes=${transfer.size}`)
|
|
786
|
+
return {
|
|
787
|
+
kind: 'file-download-opened',
|
|
788
|
+
requestId,
|
|
789
|
+
transferId,
|
|
790
|
+
sessionId: transfer.sessionId,
|
|
791
|
+
path: transfer.path,
|
|
792
|
+
name: transfer.name,
|
|
793
|
+
mediaType: transfer.mediaType,
|
|
794
|
+
size: transfer.size,
|
|
795
|
+
chunkBytes: options.fileDownloadChunkBytes,
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const read = async (client, msg) => {
|
|
800
|
+
const transferId = typeof msg.transferId === 'string' && msg.transferId.trim() ? msg.transferId.trim() : null
|
|
801
|
+
if (!transferId) return fileTransferError('bad-request', 'file-download-read requires a transferId', 'file-download-read')
|
|
802
|
+
const transfer = transfers.get(transferId)
|
|
803
|
+
if (!transfer || transfer.client !== client) {
|
|
804
|
+
return fileTransferError('file-transfer-not-found', 'file download is no longer active', 'file-download-read')
|
|
805
|
+
}
|
|
806
|
+
if (!Number.isSafeInteger(msg.offset) || msg.offset !== transfer.offset) {
|
|
807
|
+
return fileTransferError('file-transfer-offset', 'offset must match the next unread byte', 'file-download-read', transfer.sessionId)
|
|
808
|
+
}
|
|
809
|
+
if (transfer.reading) {
|
|
810
|
+
return fileTransferError('file-transfer-busy', 'a chunk read is already in progress', 'file-download-read', transfer.sessionId)
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
transfer.reading = true
|
|
814
|
+
try {
|
|
815
|
+
const remaining = transfer.size - transfer.offset
|
|
816
|
+
const bytesToRead = Math.min(options.fileDownloadChunkBytes, remaining)
|
|
817
|
+
const chunk = Buffer.allocUnsafe(bytesToRead)
|
|
818
|
+
const { bytesRead } = bytesToRead === 0
|
|
819
|
+
? { bytesRead: 0 }
|
|
820
|
+
: await transfer.handle.read(chunk, 0, bytesToRead, transfer.offset)
|
|
821
|
+
if (bytesRead !== bytesToRead) {
|
|
822
|
+
await closeTransfer(transferId)
|
|
823
|
+
return fileTransferError('file-changed', 'file changed during download', 'file-download-read', transfer.sessionId)
|
|
824
|
+
}
|
|
825
|
+
const payload = bytesRead === chunk.length ? chunk : chunk.subarray(0, bytesRead)
|
|
826
|
+
transfer.hash.update(payload)
|
|
827
|
+
const offset = transfer.offset
|
|
828
|
+
transfer.offset += bytesRead
|
|
829
|
+
transfer.lastActiveAt = Date.now()
|
|
830
|
+
const eof = transfer.offset === transfer.size
|
|
831
|
+
const frame = {
|
|
832
|
+
kind: 'file-download-chunk',
|
|
833
|
+
transferId,
|
|
834
|
+
offset,
|
|
835
|
+
data: payload.toString('base64'),
|
|
836
|
+
eof,
|
|
837
|
+
}
|
|
838
|
+
if (eof) {
|
|
839
|
+
frame.sha256 = transfer.hash.digest('hex')
|
|
840
|
+
await closeTransfer(transferId)
|
|
841
|
+
log(`file download completed: transfer=${transferId} bytes=${transfer.size}`)
|
|
842
|
+
}
|
|
843
|
+
return frame
|
|
844
|
+
} catch {
|
|
845
|
+
await closeTransfer(transferId)
|
|
846
|
+
return fileTransferError('file-unreadable', 'file cannot be read', 'file-download-read', transfer.sessionId)
|
|
847
|
+
} finally {
|
|
848
|
+
transfer.reading = false
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const cancel = async (client, msg) => {
|
|
853
|
+
const transferId = typeof msg.transferId === 'string' && msg.transferId.trim() ? msg.transferId.trim() : null
|
|
854
|
+
if (!transferId) return fileTransferError('bad-request', 'file-download-cancel requires a transferId', 'file-download-cancel')
|
|
855
|
+
const transfer = transfers.get(transferId)
|
|
856
|
+
if (!transfer || transfer.client !== client) {
|
|
857
|
+
return fileTransferError('file-transfer-not-found', 'file download is no longer active', 'file-download-cancel')
|
|
858
|
+
}
|
|
859
|
+
await closeTransfer(transferId)
|
|
860
|
+
log(`file download cancelled: transfer=${transferId}`)
|
|
861
|
+
return { kind: 'file-download-cancelled', transferId }
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const expire = async () => {
|
|
865
|
+
const cutoff = Date.now() - options.fileDownloadIdleMs
|
|
866
|
+
for (const [transferId, transfer] of transfers) {
|
|
867
|
+
if (transfer.lastActiveAt < cutoff && !transfer.reading) {
|
|
868
|
+
await closeTransfer(transferId)
|
|
869
|
+
log(`file download expired: transfer=${transferId}`)
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
return {
|
|
875
|
+
async handle(client, msg) {
|
|
876
|
+
if (!options.fileDownloadsEnabled) {
|
|
877
|
+
return fileTransferError('file-download-disabled', 'file downloads are disabled by gateway configuration', msg.type)
|
|
878
|
+
}
|
|
879
|
+
if (msg.type === 'file-list') return fileList(msg)
|
|
880
|
+
if (msg.type === 'file-download-open') return open(client, msg)
|
|
881
|
+
if (msg.type === 'file-download-read') return read(client, msg)
|
|
882
|
+
if (msg.type === 'file-download-cancel') return cancel(client, msg)
|
|
883
|
+
return null
|
|
884
|
+
},
|
|
885
|
+
async closeClient(client) {
|
|
886
|
+
for (const [transferId, transfer] of transfers) {
|
|
887
|
+
if (transfer.client === client) await closeTransfer(transferId)
|
|
888
|
+
}
|
|
889
|
+
},
|
|
890
|
+
async dispose() {
|
|
891
|
+
for (const transferId of [...transfers.keys()]) await closeTransfer(transferId)
|
|
892
|
+
},
|
|
893
|
+
expire,
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// List one directory level directly with node:fs, mirroring the official
|
|
898
|
+
// browse backend's semantics (crumbs + name-sorted child directories). Works
|
|
899
|
+
// on every deployment regardless of the composed picker capability, which
|
|
900
|
+
// `host.listDirectory` would otherwise gate behind the `browse` capability.
|
|
901
|
+
async function listServerDirectory(target) {
|
|
902
|
+
try {
|
|
903
|
+
const home = os.homedir()
|
|
904
|
+
const requested = target ? path.resolve(target) : home
|
|
905
|
+
const crumbs = []
|
|
906
|
+
let cur = requested
|
|
907
|
+
for (;;) {
|
|
908
|
+
crumbs.unshift({ name: cur === path.parse(cur).root ? cur : path.basename(cur), path: cur, hidden: false })
|
|
909
|
+
const parent = path.dirname(cur)
|
|
910
|
+
if (parent === cur) break
|
|
911
|
+
cur = parent
|
|
912
|
+
}
|
|
913
|
+
const entries = []
|
|
914
|
+
const dirents = await fsp.readdir(requested, { withFileTypes: true })
|
|
915
|
+
for (const dirent of dirents) {
|
|
916
|
+
if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue
|
|
917
|
+
entries.push({ name: dirent.name, path: path.join(requested, dirent.name), hidden: dirent.name.startsWith('.') })
|
|
918
|
+
}
|
|
919
|
+
entries.sort((a, b) => a.name.localeCompare(b.name))
|
|
920
|
+
log(`query ok: directories (${requested}, ${entries.length} entries)`)
|
|
921
|
+
return { kind: 'directories', path: requested, home, crumbs, entries, truncated: false }
|
|
922
|
+
} catch (error) {
|
|
923
|
+
const message = error && error.message ? error.message : String(error)
|
|
924
|
+
log(`query failed: directories -> ${message}`)
|
|
925
|
+
return { kind: 'error', code: 'directory-unreadable', message, requestType: 'directories' }
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function directoryCreateError(code, message) {
|
|
930
|
+
return { kind: 'error', code, message, requestType: 'directory-create' }
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Create one child directory directly on the host filesystem. This deliberately
|
|
934
|
+
// shares the same backend as `directories`: macOS native Directory Picker only
|
|
935
|
+
// exposes its system dialog and does not implement remote createDirectory RPCs.
|
|
936
|
+
async function createServerDirectory(parentPath, directoryName) {
|
|
937
|
+
if (typeof parentPath !== 'string' || parentPath.trim() === '') {
|
|
938
|
+
return directoryCreateError('bad-request', 'directory-create requires an absolute parent path')
|
|
939
|
+
}
|
|
940
|
+
const parent = path.resolve(parentPath.trim())
|
|
941
|
+
if (!path.isAbsolute(parentPath.trim())) {
|
|
942
|
+
return directoryCreateError('bad-request', 'directory-create path must be absolute')
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
if (typeof directoryName !== 'string') {
|
|
946
|
+
return directoryCreateError('bad-request', 'directory-create requires a folder name')
|
|
947
|
+
}
|
|
948
|
+
const name = directoryName.trim()
|
|
949
|
+
if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
|
|
950
|
+
return directoryCreateError('bad-request', 'directory-create name must be a single non-empty folder name')
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
try {
|
|
954
|
+
const stat = await fsp.stat(parent)
|
|
955
|
+
if (!stat.isDirectory()) {
|
|
956
|
+
return directoryCreateError('directory-create-failed', 'parent path is not a directory')
|
|
957
|
+
}
|
|
958
|
+
} catch (error) {
|
|
959
|
+
const message = error && error.message ? error.message : String(error)
|
|
960
|
+
log(`query failed: directory-create -> ${message}`)
|
|
961
|
+
return directoryCreateError('directory-create-failed', message)
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const target = path.join(parent, name)
|
|
965
|
+
try {
|
|
966
|
+
await fsp.mkdir(target)
|
|
967
|
+
log(`query ok: directory-create (${target})`)
|
|
968
|
+
return { kind: 'directory-create', path: target }
|
|
969
|
+
} catch (error) {
|
|
970
|
+
if (error && error.code === 'EEXIST') {
|
|
971
|
+
return directoryCreateError('directory-exists', 'directory already exists')
|
|
972
|
+
}
|
|
973
|
+
const message = error && error.message ? error.message : String(error)
|
|
974
|
+
log(`query failed: directory-create -> ${message}`)
|
|
975
|
+
return directoryCreateError('directory-create-failed', message)
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Validate one sessionId field; returns { value } or { error }.
|
|
980
|
+
function requireSessionId(msg) {
|
|
981
|
+
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
|
|
982
|
+
if (!sessionId) {
|
|
983
|
+
return { error: { kind: 'error', code: 'bad-request', message: 'this request requires a sessionId', requestType: typeof msg.type === 'string' ? msg.type : 'query' } }
|
|
984
|
+
}
|
|
985
|
+
return { value: sessionId }
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Goal mutations use DSH's compare-and-set reference. Returning a stale
|
|
989
|
+
// revision is intentionally rejected by DSH instead of overwriting a newer
|
|
990
|
+
// WebUI/mobile update.
|
|
991
|
+
function requireGoalRef(msg, sessionId) {
|
|
992
|
+
const ref = msg.ref
|
|
993
|
+
const id = ref && typeof ref.id === 'string' && ref.id.trim() !== '' ? ref.id.trim() : null
|
|
994
|
+
const revision = ref && typeof ref.revision === 'number' && Number.isSafeInteger(ref.revision) && ref.revision > 0
|
|
995
|
+
? ref.revision
|
|
996
|
+
: null
|
|
997
|
+
if (!id || revision === null) {
|
|
998
|
+
return {
|
|
999
|
+
error: {
|
|
1000
|
+
kind: 'error',
|
|
1001
|
+
code: 'bad-request',
|
|
1002
|
+
message: `${typeof msg.type === 'string' ? msg.type : 'goal mutation'} requires ref.id and a positive integer ref.revision`,
|
|
1003
|
+
requestType: typeof msg.type === 'string' ? msg.type : 'goal',
|
|
1004
|
+
sessionId,
|
|
1005
|
+
},
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
return { value: { id, revision } }
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
async function readSessionProjection(api, type, sessionId, key) {
|
|
1012
|
+
const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId })
|
|
1013
|
+
if (history.kind !== 'history') return history
|
|
1014
|
+
const values = (history.projections && history.projections.values) || {}
|
|
1015
|
+
return {
|
|
1016
|
+
kind: type,
|
|
1017
|
+
sessionId,
|
|
1018
|
+
asOfSeq: history.projections ? history.projections.asOfSeq : undefined,
|
|
1019
|
+
[key]: Object.prototype.hasOwnProperty.call(values, key) ? values[key] : null,
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
async function mutateGoal(api, type, method, payload) {
|
|
1024
|
+
if (!api.goals || typeof api.goals[method] !== 'function') {
|
|
1025
|
+
return {
|
|
1026
|
+
kind: 'error',
|
|
1027
|
+
code: 'unsupported',
|
|
1028
|
+
message: 'goal management is unavailable in this DSH host',
|
|
1029
|
+
requestType: type,
|
|
1030
|
+
sessionId: payload.sessionId,
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
return proxyQuery(api, type, api.goals[method].bind(api.goals), payload)
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// ---------------------------------------------------------------------------
|
|
1037
|
+
// History frame sizing: raw session logs can be huge (trajectory, tool output,
|
|
1038
|
+
// context), so a single history response must never blow the client's
|
|
1039
|
+
// WebSocket frame limit. We cap the serialized size per frame, keep the NEWEST
|
|
1040
|
+
// suffix within the budget, and hand the client `hasMore` + `nextBeforeSeq` so
|
|
1041
|
+
// it can page backward. An optional `view: "conversation"` trims events that
|
|
1042
|
+
// are not needed for a chat page (token-level chunks, system-prompt headers)
|
|
1043
|
+
// and truncates oversized tool-result text.
|
|
1044
|
+
// ---------------------------------------------------------------------------
|
|
1045
|
+
const HISTORY_DEFAULT_MAX_BYTES = 4 * 1024 * 1024 // 4 MiB per frame
|
|
1046
|
+
// Opening snapshots should be readable before older history is requested.
|
|
1047
|
+
// Keep the newest indivisible message even when it alone exceeds this budget.
|
|
1048
|
+
const HISTORY_OPENING_MAX_BYTES = 256 * 1024
|
|
1049
|
+
const HISTORY_TOOL_RESULT_MAX_CHARS = 2000 // per text block in conversation view
|
|
1050
|
+
|
|
1051
|
+
function eventBytes(event) {
|
|
1052
|
+
return Buffer.byteLength(JSON.stringify(event), 'utf8')
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Trim one event for the conversation page: null drops it, otherwise a shallow
|
|
1056
|
+
// copy with oversized payloads truncated. The events come from the host RPC as
|
|
1057
|
+
// plain JSON, so copying is safe.
|
|
1058
|
+
function trimConversationEvent(event) {
|
|
1059
|
+
switch (event.type) {
|
|
1060
|
+
case 'assistant/chunk':
|
|
1061
|
+
case 'request/header':
|
|
1062
|
+
case 'request/context':
|
|
1063
|
+
case 'system/message':
|
|
1064
|
+
// token-level replay / system-prompt header: not rendered on the chat page
|
|
1065
|
+
return null
|
|
1066
|
+
case 'assistant/message':
|
|
1067
|
+
case 'assistant/attempt': {
|
|
1068
|
+
const { stream, ...data } = event.data || {}
|
|
1069
|
+
return { ...event, data }
|
|
1070
|
+
}
|
|
1071
|
+
case 'tool/result': {
|
|
1072
|
+
const d = event.data || {}
|
|
1073
|
+
const message = d.message
|
|
1074
|
+
if (!message || !Array.isArray(message.content)) return event
|
|
1075
|
+
let changed = false
|
|
1076
|
+
const truncateBlock = (block) => {
|
|
1077
|
+
if (!block || typeof block !== 'object') return block
|
|
1078
|
+
if (block.type === 'text' && typeof block.text === 'string' && block.text.length > HISTORY_TOOL_RESULT_MAX_CHARS) {
|
|
1079
|
+
changed = true
|
|
1080
|
+
return { ...block, text: block.text.slice(0, HISTORY_TOOL_RESULT_MAX_CHARS) + '…' }
|
|
1081
|
+
}
|
|
1082
|
+
if (Array.isArray(block.content)) {
|
|
1083
|
+
return { ...block, content: block.content.map(truncateBlock) }
|
|
1084
|
+
}
|
|
1085
|
+
return block
|
|
1086
|
+
}
|
|
1087
|
+
const content = message.content.map(truncateBlock)
|
|
1088
|
+
if (!changed) return event
|
|
1089
|
+
return { ...event, data: { ...d, message: { ...message, content } } }
|
|
1090
|
+
}
|
|
1091
|
+
default:
|
|
1092
|
+
return event
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Keep the newest suffix of `events` (ascending seq) within `maxBytes`.
|
|
1097
|
+
// Always keeps the newest event even if it alone exceeds the budget (an event
|
|
1098
|
+
// cannot be split); drops older events beyond the budget. Returns the kept
|
|
1099
|
+
// events, their total serialized bytes, and whether anything was dropped.
|
|
1100
|
+
function capHistoryEvents(events, maxBytes, trim) {
|
|
1101
|
+
const processed = trim ? events.map(trimConversationEvent).filter(Boolean) : events
|
|
1102
|
+
if (processed.length === 0) return { events: [], bytes: 0, dropped: 0 }
|
|
1103
|
+
let total = 0
|
|
1104
|
+
let keptStart = processed.length
|
|
1105
|
+
for (let i = processed.length - 1; i >= 0; i--) {
|
|
1106
|
+
const size = eventBytes(processed[i])
|
|
1107
|
+
if (keptStart === processed.length) {
|
|
1108
|
+
// always keep the newest event
|
|
1109
|
+
total = size
|
|
1110
|
+
keptStart = i
|
|
1111
|
+
continue
|
|
1112
|
+
}
|
|
1113
|
+
if (total + size > maxBytes) break
|
|
1114
|
+
total += size
|
|
1115
|
+
keptStart = i
|
|
1116
|
+
}
|
|
1117
|
+
return { events: processed.slice(keptStart), bytes: total, dropped: keptStart }
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function validateHistoryCursor(msg, sessionId, field) {
|
|
1121
|
+
if (msg[field] === undefined && msg.historyFormatVersion === undefined) return null
|
|
1122
|
+
if (msg.historyFormatVersion !== SESSION_FORMAT_VERSION) {
|
|
1123
|
+
return { kind: 'error', code: 'history-format-mismatch', requestType: msg.type, sessionId,
|
|
1124
|
+
historyFormatVersion: SESSION_FORMAT_VERSION, resetRequired: true,
|
|
1125
|
+
message: 'Reload the Session baseline before using a history or fork cursor; historyFormatVersion must be 3' }
|
|
1126
|
+
}
|
|
1127
|
+
if (msg[field] !== undefined && (!Number.isSafeInteger(msg[field]) || msg[field] < 0)) {
|
|
1128
|
+
return { kind: 'error', code: 'bad-request', requestType: msg.type, sessionId,
|
|
1129
|
+
message: `${field} must be a non-negative safe integer` }
|
|
1130
|
+
}
|
|
1131
|
+
return null
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function historyPage(value, msg) {
|
|
1135
|
+
const rawEvents = value.events.map(entry => entry.event)
|
|
1136
|
+
const maxBytes = Number.isSafeInteger(msg.maxBytes) && msg.maxBytes > 0 ? msg.maxBytes : HISTORY_DEFAULT_MAX_BYTES
|
|
1137
|
+
const trim = msg.view === 'conversation'
|
|
1138
|
+
const capped = capHistoryEvents(rawEvents, maxBytes, trim)
|
|
1139
|
+
const hasMore = value.hasMore === true || capped.dropped > 0
|
|
1140
|
+
// An all-hidden page still needs a cursor so callers can reach older text.
|
|
1141
|
+
const oldest = capped.events[0]?.seq ?? rawEvents[0]?.seq
|
|
1142
|
+
return { ...value, kind: 'history', sessionId: msg.sessionId,
|
|
1143
|
+
events: capped.events, bytes: capped.bytes, hasMore,
|
|
1144
|
+
...(trim ? { view: 'conversation' } : {}),
|
|
1145
|
+
...(hasMore && oldest !== undefined ? { nextBeforeSeq: oldest } : {}),
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function resolveCommandCatalogCopy(locale) {
|
|
1150
|
+
return typeof locale === 'string' && !locale.toLowerCase().startsWith('zh')
|
|
1151
|
+
? COMMAND_CATALOG_COPY.en
|
|
1152
|
+
: COMMAND_CATALOG_COPY.zh
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// DSH 0.1.5-rc.2 command descriptors use input.attachments.
|
|
1156
|
+
function commandAcceptsAttachments(command) {
|
|
1157
|
+
return command?.input?.attachments === true
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function commandUiDescriptor(command, copy) {
|
|
1161
|
+
const override = COMMAND_UI_OVERRIDES[command.name]
|
|
1162
|
+
if (override) return { ...override, insertText: `/${command.name}` }
|
|
1163
|
+
if (command.input && typeof command.input.hint === 'string') {
|
|
1164
|
+
const displayHint = copy.hints[command.name]
|
|
1165
|
+
return {
|
|
1166
|
+
kind: 'input',
|
|
1167
|
+
insertText: `/${command.name} `,
|
|
1168
|
+
hint: command.input.hint,
|
|
1169
|
+
...(displayHint ? { displayHint } : {}),
|
|
1170
|
+
images: commandAcceptsAttachments(command),
|
|
1171
|
+
submitRequest: 'command-execute',
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return { kind: 'immediate', submitRequest: 'command-execute', submitText: `/${command.name}` }
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function skillUiDescriptor(skill) {
|
|
1178
|
+
return {
|
|
1179
|
+
kind: 'input',
|
|
1180
|
+
insertText: `/${skill.name} `,
|
|
1181
|
+
images: true,
|
|
1182
|
+
submitRequest: 'message',
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function modelCommandOptionId(provider, model) {
|
|
1187
|
+
return Buffer.from(JSON.stringify([provider, model]), 'utf8').toString('base64url')
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function decodeModelCommandOptionId(optionId) {
|
|
1191
|
+
try {
|
|
1192
|
+
const value = JSON.parse(Buffer.from(optionId, 'base64url').toString('utf8'))
|
|
1193
|
+
if (!Array.isArray(value) || value.length !== 2 || value.some((part) => typeof part !== 'string' || part === '')) return null
|
|
1194
|
+
return { provider: value[0], model: value[1] }
|
|
1195
|
+
} catch {
|
|
1196
|
+
return null
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
async function loadCommandOptions(api, command, sessionId) {
|
|
1201
|
+
if (command === 'model') {
|
|
1202
|
+
const frame = await proxyQuery(api, 'models', api.sessions.models.bind(api.sessions), { sessionId })
|
|
1203
|
+
if (frame.kind !== 'models') return frame
|
|
1204
|
+
const options = []
|
|
1205
|
+
for (const group of frame.groups || []) {
|
|
1206
|
+
for (const model of group.models || []) {
|
|
1207
|
+
options.push({
|
|
1208
|
+
id: modelCommandOptionId(String(group.id), String(model.id)),
|
|
1209
|
+
label: String(model.name || model.id),
|
|
1210
|
+
detail: String(group.name || group.id),
|
|
1211
|
+
...(model.description ? { description: String(model.description) } : {}),
|
|
1212
|
+
selected: frame.current && frame.current.provider === group.id && frame.current.model === model.id,
|
|
1213
|
+
})
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
return { kind: 'command-options', sessionId, command, options }
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
if (command === 'permission') {
|
|
1220
|
+
const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId })
|
|
1221
|
+
if (history.kind !== 'history') return history
|
|
1222
|
+
const permissions = history.projections && history.projections.values && history.projections.values.permissions
|
|
1223
|
+
if (!permissions || !Array.isArray(permissions.options) || typeof permissions.currentValue !== 'string') {
|
|
1224
|
+
return {
|
|
1225
|
+
kind: 'error',
|
|
1226
|
+
code: 'command-options-unavailable',
|
|
1227
|
+
message: 'permission options are unavailable for this session',
|
|
1228
|
+
requestType: 'command-options',
|
|
1229
|
+
sessionId,
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
return {
|
|
1233
|
+
kind: 'command-options',
|
|
1234
|
+
sessionId,
|
|
1235
|
+
command,
|
|
1236
|
+
options: permissions.options.map((option) => ({
|
|
1237
|
+
id: String(option.value),
|
|
1238
|
+
label: String(option.name || option.value),
|
|
1239
|
+
...(option.description ? { description: String(option.description) } : {}),
|
|
1240
|
+
selected: option.value === permissions.currentValue,
|
|
1241
|
+
})),
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
return {
|
|
1246
|
+
kind: 'error',
|
|
1247
|
+
code: 'bad-request',
|
|
1248
|
+
message: `command does not provide selectable options: ${command}`,
|
|
1249
|
+
requestType: 'command-options',
|
|
1250
|
+
sessionId,
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
async function selectCommandOption(api, host, command, sessionId, optionId) {
|
|
1255
|
+
const catalog = await loadCommandOptions(api, command, sessionId)
|
|
1256
|
+
if (catalog.kind !== 'command-options') return catalog
|
|
1257
|
+
const option = catalog.options.find((candidate) => candidate.id === optionId)
|
|
1258
|
+
if (!option) {
|
|
1259
|
+
return {
|
|
1260
|
+
kind: 'error',
|
|
1261
|
+
code: 'bad-request',
|
|
1262
|
+
message: `unknown option for ${command}: ${optionId}`,
|
|
1263
|
+
requestType: 'command-select',
|
|
1264
|
+
sessionId,
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
if (command === 'permission') {
|
|
1269
|
+
try {
|
|
1270
|
+
const execution = await host.commands.execute(
|
|
1271
|
+
sessionId,
|
|
1272
|
+
'/permission ' + optionId,
|
|
1273
|
+
[],
|
|
1274
|
+
new AbortController().signal,
|
|
1275
|
+
)
|
|
1276
|
+
if (!execution || !execution.result) throw new Error('permission command returned an invalid result')
|
|
1277
|
+
if (execution.result.kind === 'error') {
|
|
1278
|
+
return { kind: 'error', code: 'command-error', message: execution.result.text, requestType: 'command-select', sessionId }
|
|
1279
|
+
}
|
|
1280
|
+
return { kind: 'command-selected', sessionId, command, selected: { ...option, selected: true } }
|
|
1281
|
+
} catch (error) {
|
|
1282
|
+
const code = error && error.code ? error.code : 'internal'
|
|
1283
|
+
const message = error && error.message ? error.message : String(error)
|
|
1284
|
+
return { kind: 'error', code, message, requestType: 'command-select', sessionId }
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
if (command === 'model') {
|
|
1289
|
+
const selection = decodeModelCommandOptionId(optionId)
|
|
1290
|
+
if (!selection) {
|
|
1291
|
+
return { kind: 'error', code: 'bad-request', message: 'invalid model option id', requestType: 'command-select', sessionId }
|
|
1292
|
+
}
|
|
1293
|
+
const models = await proxyQuery(api, 'models', api.sessions.models.bind(api.sessions), { sessionId })
|
|
1294
|
+
if (models.kind !== 'models') return models
|
|
1295
|
+
const group = (models.groups || []).find((candidate) => candidate.id === selection.provider)
|
|
1296
|
+
const model = group && (group.models || []).find((candidate) => candidate.id === selection.model)
|
|
1297
|
+
if (!model) {
|
|
1298
|
+
return { kind: 'error', code: 'bad-request', message: 'model option is no longer available', requestType: 'command-select', sessionId }
|
|
1299
|
+
}
|
|
1300
|
+
const payload = { sessionId, ...selection }
|
|
1301
|
+
const reasoningEffort = models.current && models.current.provider === selection.provider && models.current.model === selection.model
|
|
1302
|
+
? models.current.reasoningEffort
|
|
1303
|
+
: model.reasoning && model.reasoning.defaultEffort
|
|
1304
|
+
if (reasoningEffort) payload.reasoningEffort = reasoningEffort
|
|
1305
|
+
const selected = await proxyQuery(api, 'select-model', api.sessions.selectModel.bind(api.sessions), payload)
|
|
1306
|
+
if (selected.kind !== 'select-model') return selected
|
|
1307
|
+
return {
|
|
1308
|
+
kind: 'command-selected',
|
|
1309
|
+
sessionId,
|
|
1310
|
+
command,
|
|
1311
|
+
selected: { ...option, selected: true },
|
|
1312
|
+
value: selected.selected,
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
return { kind: 'error', code: 'bad-request', message: `command is not selectable: ${command}`, requestType: 'command-select', sessionId }
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function parseAgentPreset(msg, requestType, required = false) {
|
|
1320
|
+
if (msg.agentPreset === undefined && !required) return {}
|
|
1321
|
+
if (typeof msg.agentPreset !== 'string' || !msg.agentPreset.trim()) {
|
|
1322
|
+
return { error: { kind: 'error', code: 'bad-request', requestType,
|
|
1323
|
+
message: 'agentPreset must be a non-empty preset ID' } }
|
|
1324
|
+
}
|
|
1325
|
+
return { value: msg.agentPreset.trim() }
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// Dispatch one mobile query frame; returns the wire frame to send back.
|
|
1329
|
+
async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
1330
|
+
if (msg.type === 'session-agent-preset' || msg.type === 'select-agent-preset') {
|
|
1331
|
+
const session = requireSessionId(msg)
|
|
1332
|
+
const preset = parseAgentPreset(msg, msg.type, msg.type === 'select-agent-preset')
|
|
1333
|
+
const requestId = typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}
|
|
1334
|
+
if (session.error || preset.error) return { ...(session.error || preset.error), ...requestId }
|
|
1335
|
+
const method = msg.type === 'session-agent-preset' ? 'session' : 'select'
|
|
1336
|
+
const frame = await proxyQuery(api, msg.type, api.agentPresets[method].bind(api.agentPresets), {
|
|
1337
|
+
sessionId: session.value, ...(preset.value === undefined ? {} : { agentPreset: preset.value }),
|
|
1338
|
+
})
|
|
1339
|
+
return { ...frame, sessionId: session.value, ...requestId }
|
|
1340
|
+
}
|
|
1341
|
+
if (msg.type === 'session-create') {
|
|
1342
|
+
if (typeof msg.requestId !== 'string' || !msg.requestId.trim()) {
|
|
1343
|
+
return { kind: 'error', code: 'bad-request', message: 'session-create requires a requestId', requestType: 'session-create' }
|
|
1344
|
+
}
|
|
1345
|
+
const payload = {}
|
|
1346
|
+
const preset = parseAgentPreset(msg, msg.type)
|
|
1347
|
+
if (preset.error) return { ...preset.error, requestId: msg.requestId }
|
|
1348
|
+
if (preset.value !== undefined) payload.agentPreset = preset.value
|
|
1349
|
+
if (typeof msg.workspaceId === 'string' && msg.workspaceId.trim()) {
|
|
1350
|
+
payload.workspaceId = msg.workspaceId.trim()
|
|
1351
|
+
} else if (typeof msg.cwd === 'string' && msg.cwd.trim()) {
|
|
1352
|
+
payload.cwd = msg.cwd.trim()
|
|
1353
|
+
}
|
|
1354
|
+
const frame = await proxyQuery(api, 'session-created', api.sessions.create.bind(api.sessions), payload)
|
|
1355
|
+
return { ...frame, ...(frame.kind === 'error' ? { requestType: 'session-create' } : {}), requestId: msg.requestId }
|
|
1356
|
+
}
|
|
1357
|
+
if (msg.type === 'workspaces') {
|
|
1358
|
+
return proxyQuery(api, 'workspaces', api.workspace.list.bind(api.workspace), {})
|
|
1359
|
+
}
|
|
1360
|
+
if (msg.type === 'sessions') {
|
|
1361
|
+
return proxyQuery(api, 'sessions', api.sessions.list.bind(api.sessions), {})
|
|
1362
|
+
}
|
|
1363
|
+
if (msg.type === 'history') {
|
|
1364
|
+
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
|
|
1365
|
+
if (!sessionId) {
|
|
1366
|
+
return { kind: 'error', code: 'bad-request', message: 'history requires a sessionId', requestType: 'history' }
|
|
1367
|
+
}
|
|
1368
|
+
const cursorError = validateHistoryCursor(msg, sessionId, 'beforeSeq')
|
|
1369
|
+
if (cursorError) return cursorError
|
|
1370
|
+
const payload = { sessionId }
|
|
1371
|
+
if (typeof msg.beforeSeq === 'number' && Number.isFinite(msg.beforeSeq)) payload.beforeSeq = msg.beforeSeq
|
|
1372
|
+
if (typeof msg.maxMessages === 'number' && Number.isFinite(msg.maxMessages)) payload.maxMessages = msg.maxMessages
|
|
1373
|
+
const frame = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), payload)
|
|
1374
|
+
if (frame.kind === 'history') return historyPage(frame, { ...msg, sessionId })
|
|
1375
|
+
return frame
|
|
1376
|
+
}
|
|
1377
|
+
if (msg.type === 'attachment') {
|
|
1378
|
+
const sessionId = requireSessionId(msg)
|
|
1379
|
+
if (sessionId.error) return sessionId.error
|
|
1380
|
+
const attachmentId = typeof msg.attachmentId === 'string' && msg.attachmentId.trim() !== '' ? msg.attachmentId.trim() : null
|
|
1381
|
+
if (!attachmentId) {
|
|
1382
|
+
return { kind: 'error', code: 'bad-request', message: 'attachment requires an attachmentId', requestType: 'attachment', sessionId: sessionId.value }
|
|
1383
|
+
}
|
|
1384
|
+
const frame = await proxyQuery(api, 'attachment', api.sessions.attachment.bind(api.sessions), {
|
|
1385
|
+
sessionId: sessionId.value,
|
|
1386
|
+
attachmentId,
|
|
1387
|
+
})
|
|
1388
|
+
if (frame.kind === 'attachment') frame.sessionId = sessionId.value
|
|
1389
|
+
return frame
|
|
1390
|
+
}
|
|
1391
|
+
if (msg.type === 'search') {
|
|
1392
|
+
const query = typeof msg.query === 'string' ? msg.query.trim() : ''
|
|
1393
|
+
if (!query) {
|
|
1394
|
+
return { kind: 'error', code: 'bad-request', message: 'search requires a query', requestType: 'search' }
|
|
1395
|
+
}
|
|
1396
|
+
return proxyQuery(api, 'search', api.sessions.search.bind(api.sessions), { query }, new AbortController().signal)
|
|
1397
|
+
}
|
|
1398
|
+
if (msg.type === 'host') {
|
|
1399
|
+
return proxyQuery(api, 'host', api.host.describe.bind(api.host), {})
|
|
1400
|
+
}
|
|
1401
|
+
if (msg.type === 'default-model') {
|
|
1402
|
+
try {
|
|
1403
|
+
const selection = agentDefaultModel.currentSelection()
|
|
1404
|
+
log(`default-model queried: ${selection.provider}/${selection.model}`)
|
|
1405
|
+
return { kind: 'default-model', selection }
|
|
1406
|
+
} catch (error) {
|
|
1407
|
+
const message = error && error.message ? error.message : String(error)
|
|
1408
|
+
return { kind: 'error', code: 'internal', message, requestType: 'default-model' }
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
if (msg.type === 'save-default-model') {
|
|
1412
|
+
const provider = typeof msg.provider === 'string' && msg.provider.trim() !== '' ? msg.provider.trim() : null
|
|
1413
|
+
const model = typeof msg.model === 'string' && msg.model.trim() !== '' ? msg.model.trim() : null
|
|
1414
|
+
if (!provider || !model) {
|
|
1415
|
+
return { kind: 'error', code: 'bad-request', message: 'save-default-model requires provider and model', requestType: 'save-default-model' }
|
|
1416
|
+
}
|
|
1417
|
+
const selection = { provider, model }
|
|
1418
|
+
if (typeof msg.reasoningEffort === 'string' && msg.reasoningEffort.trim() !== '') selection.reasoningEffort = msg.reasoningEffort.trim()
|
|
1419
|
+
try {
|
|
1420
|
+
await agentDefaultModel.saveSelection(selection)
|
|
1421
|
+
log(`default model saved: ${provider}/${model}${selection.reasoningEffort ? ' (effort=' + selection.reasoningEffort + ')' : ''}`)
|
|
1422
|
+
return { kind: 'save-default-model', saved: selection }
|
|
1423
|
+
} catch (error) {
|
|
1424
|
+
const message = error && error.message ? error.message : String(error)
|
|
1425
|
+
log(`save-default-model failed: ${message}`)
|
|
1426
|
+
return { kind: 'error', code: 'internal', message, requestType: 'save-default-model' }
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
if (msg.type === 'fork') {
|
|
1430
|
+
const sessionId = requireSessionId(msg)
|
|
1431
|
+
if (sessionId.error) return sessionId.error
|
|
1432
|
+
const cursorError = validateHistoryCursor(msg, sessionId.value, 'atSeq')
|
|
1433
|
+
if (cursorError) return cursorError
|
|
1434
|
+
const payload = { sessionId: sessionId.value }
|
|
1435
|
+
if (typeof msg.atSeq === 'number' && Number.isFinite(msg.atSeq)) payload.atSeq = Math.floor(msg.atSeq)
|
|
1436
|
+
const frame = await proxyQuery(api, 'fork', api.sessions.fork.bind(api.sessions), payload)
|
|
1437
|
+
if (frame.kind === 'fork') {
|
|
1438
|
+
log(`forked session ${sessionId.value} -> ${frame.sessionId}${payload.atSeq !== undefined ? ' at seq ' + payload.atSeq : ''}`)
|
|
1439
|
+
}
|
|
1440
|
+
return frame
|
|
1441
|
+
}
|
|
1442
|
+
if (msg.type === 'session-cancel') {
|
|
1443
|
+
const sessionId = requireSessionId(msg)
|
|
1444
|
+
if (sessionId.error) return sessionId.error
|
|
1445
|
+
const frame = await proxyQuery(
|
|
1446
|
+
api,
|
|
1447
|
+
'session-cancel',
|
|
1448
|
+
api.sessions.cancel.bind(api.sessions),
|
|
1449
|
+
{ sessionId: sessionId.value },
|
|
1450
|
+
)
|
|
1451
|
+
if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value }
|
|
1452
|
+
return { ...frame, kind: 'session-cancelled', sessionId: sessionId.value }
|
|
1453
|
+
}
|
|
1454
|
+
if (msg.type === 'queue-update') {
|
|
1455
|
+
const sessionId = requireSessionId(msg)
|
|
1456
|
+
if (sessionId.error) return sessionId.error
|
|
1457
|
+
const itemId = typeof msg.itemId === 'string' && msg.itemId.trim() ? msg.itemId.trim() : null
|
|
1458
|
+
const actionKind = typeof msg.action === 'string' ? msg.action.trim() : ''
|
|
1459
|
+
if (!itemId || !['edit', 'remove', 'steer'].includes(actionKind)) {
|
|
1460
|
+
return {
|
|
1461
|
+
kind: 'error',
|
|
1462
|
+
code: 'bad-request',
|
|
1463
|
+
message: 'queue-update requires itemId and action edit, remove, or steer',
|
|
1464
|
+
requestType: 'queue-update',
|
|
1465
|
+
sessionId: sessionId.value,
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
let action
|
|
1469
|
+
if (actionKind === 'edit') {
|
|
1470
|
+
if (typeof msg.text !== 'string' || !msg.text.trim()) {
|
|
1471
|
+
return {
|
|
1472
|
+
kind: 'error',
|
|
1473
|
+
code: 'bad-request',
|
|
1474
|
+
message: 'queue-update edit requires non-empty text',
|
|
1475
|
+
requestType: 'queue-update',
|
|
1476
|
+
sessionId: sessionId.value,
|
|
1477
|
+
itemId,
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
action = { kind: 'edit', content: [{ type: 'text', text: msg.text }] }
|
|
1481
|
+
} else {
|
|
1482
|
+
action = { kind: actionKind }
|
|
1483
|
+
}
|
|
1484
|
+
const frame = await proxyQuery(
|
|
1485
|
+
api,
|
|
1486
|
+
'queue-update',
|
|
1487
|
+
api.sessions.updateQueue.bind(api.sessions),
|
|
1488
|
+
{ sessionId: sessionId.value, itemId, action },
|
|
1489
|
+
)
|
|
1490
|
+
if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value, itemId }
|
|
1491
|
+
return {
|
|
1492
|
+
...frame,
|
|
1493
|
+
kind: 'queue-item-updated',
|
|
1494
|
+
sessionId: sessionId.value,
|
|
1495
|
+
itemId,
|
|
1496
|
+
action: actionKind,
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
if (msg.type === 'session-archive') {
|
|
1500
|
+
const sessionId = requireSessionId(msg)
|
|
1501
|
+
if (sessionId.error) return sessionId.error
|
|
1502
|
+
const frame = await proxyQuery(
|
|
1503
|
+
api,
|
|
1504
|
+
'session-archive',
|
|
1505
|
+
api.workspace.archiveSession.bind(api.workspace),
|
|
1506
|
+
{ sessionId: sessionId.value },
|
|
1507
|
+
)
|
|
1508
|
+
if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value }
|
|
1509
|
+
return { ...frame, kind: 'session-archived', sessionId: sessionId.value }
|
|
1510
|
+
}
|
|
1511
|
+
if (msg.type === 'session-rename') {
|
|
1512
|
+
const sessionId = requireSessionId(msg)
|
|
1513
|
+
if (sessionId.error) return sessionId.error
|
|
1514
|
+
if (typeof msg.title !== 'string' || !msg.title.trim()) {
|
|
1515
|
+
return {
|
|
1516
|
+
kind: 'error',
|
|
1517
|
+
code: 'bad-request',
|
|
1518
|
+
message: 'session-rename requires a non-empty title',
|
|
1519
|
+
requestType: 'session-rename',
|
|
1520
|
+
sessionId: sessionId.value,
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
const frame = await proxyQuery(
|
|
1524
|
+
api,
|
|
1525
|
+
'session-rename',
|
|
1526
|
+
api.sessions.rename.bind(api.sessions),
|
|
1527
|
+
{ sessionId: sessionId.value, title: msg.title },
|
|
1528
|
+
)
|
|
1529
|
+
if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value }
|
|
1530
|
+
return { ...frame, kind: 'session-renamed', sessionId: sessionId.value }
|
|
1531
|
+
}
|
|
1532
|
+
if (msg.type === 'workspace-create') {
|
|
1533
|
+
const wsPath = typeof msg.path === 'string' && msg.path.trim() !== '' ? msg.path.trim() : null
|
|
1534
|
+
if (!wsPath) {
|
|
1535
|
+
return { kind: 'error', code: 'bad-request', message: 'workspace-create requires a path', requestType: 'workspace-create' }
|
|
1536
|
+
}
|
|
1537
|
+
return proxyQuery(api, 'workspace-create', api.workspace.create.bind(api.workspace), { path: wsPath })
|
|
1538
|
+
}
|
|
1539
|
+
if (msg.type === 'directories') {
|
|
1540
|
+
return listServerDirectory(typeof msg.path === 'string' && msg.path.trim() !== '' ? msg.path.trim() : undefined)
|
|
1541
|
+
}
|
|
1542
|
+
if (msg.type === 'directory-create') {
|
|
1543
|
+
return createServerDirectory(msg.path, msg.name)
|
|
1544
|
+
}
|
|
1545
|
+
if (msg.type === 'models') {
|
|
1546
|
+
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
|
|
1547
|
+
if (sessionId) {
|
|
1548
|
+
// per-session catalog: current selection + routable + full groups
|
|
1549
|
+
return proxyQuery(api, 'models', api.sessions.models.bind(api.sessions), { sessionId })
|
|
1550
|
+
}
|
|
1551
|
+
// global catalog: every provider route, no session needed
|
|
1552
|
+
return proxyQuery(api, 'models', api.llm.models.bind(api.llm), {})
|
|
1553
|
+
}
|
|
1554
|
+
if (msg.type === 'providers') {
|
|
1555
|
+
return proxyQuery(api, 'providers', api.llm.providers.bind(api.llm), {})
|
|
1556
|
+
}
|
|
1557
|
+
if (msg.type === 'command-execute') {
|
|
1558
|
+
return admitCommand(host, msg)
|
|
1559
|
+
}
|
|
1560
|
+
if (msg.type === 'commands') {
|
|
1561
|
+
const sessionId = requireSessionId(msg)
|
|
1562
|
+
if (sessionId.error) return sessionId.error
|
|
1563
|
+
const copy = resolveCommandCatalogCopy(msg.locale)
|
|
1564
|
+
try {
|
|
1565
|
+
// This is the same session-scoped catalog used by the official Web UI.
|
|
1566
|
+
// Copy only the stable descriptor fields so no live Typert object crosses
|
|
1567
|
+
// the mobile wire. The Web UI also contributes /model client-side; expose
|
|
1568
|
+
// the equivalent action here so mobile clients can render the same menu.
|
|
1569
|
+
const [listed, skillFrame] = await Promise.all([
|
|
1570
|
+
listHostCommands(host, sessionId.value),
|
|
1571
|
+
api.skills && typeof api.skills.list === 'function'
|
|
1572
|
+
? proxyQuery(api, 'skills', api.skills.list.bind(api.skills), { sessionId: sessionId.value })
|
|
1573
|
+
: Promise.resolve({ kind: 'error', code: 'unsupported', message: 'skill catalog is unavailable', requestType: 'skills' }),
|
|
1574
|
+
])
|
|
1575
|
+
if (!Array.isArray(listed)) throw new Error('commands/list returned an invalid catalog')
|
|
1576
|
+
const commands = listed
|
|
1577
|
+
.filter((command) => command && typeof command.name === 'string' && typeof command.description === 'string')
|
|
1578
|
+
.map((command) => ({
|
|
1579
|
+
id: `command:${command.name}`,
|
|
1580
|
+
name: command.name,
|
|
1581
|
+
description: command.description,
|
|
1582
|
+
source: 'host',
|
|
1583
|
+
action: 'execute',
|
|
1584
|
+
ui: commandUiDescriptor(command, copy),
|
|
1585
|
+
...(command.input && typeof command.input.hint === 'string'
|
|
1586
|
+
? { input: { hint: command.input.hint, ...(commandAcceptsAttachments(command) ? { images: true } : {}) } }
|
|
1587
|
+
: {}),
|
|
1588
|
+
}))
|
|
1589
|
+
if (!commands.some((command) => command.name === 'model')) {
|
|
1590
|
+
commands.push({
|
|
1591
|
+
id: 'command:model',
|
|
1592
|
+
name: 'model',
|
|
1593
|
+
description: copy.modelDescription,
|
|
1594
|
+
source: 'client',
|
|
1595
|
+
action: 'select-model',
|
|
1596
|
+
ui: commandUiDescriptor({ name: 'model' }, copy),
|
|
1597
|
+
})
|
|
1598
|
+
}
|
|
1599
|
+
const skills = skillFrame.kind === 'skills' && Array.isArray(skillFrame.skills)
|
|
1600
|
+
? skillFrame.skills
|
|
1601
|
+
.filter((skill) => skill && typeof skill.name === 'string' && typeof skill.description === 'string')
|
|
1602
|
+
.map((skill) => ({
|
|
1603
|
+
id: `skill:${skill.name}`,
|
|
1604
|
+
name: skill.name,
|
|
1605
|
+
description: skill.modelInvocable === true ? skill.description : `${copy.userOnly} · ${skill.description}`,
|
|
1606
|
+
source: 'skill',
|
|
1607
|
+
action: 'insert',
|
|
1608
|
+
modelInvocable: skill.modelInvocable === true,
|
|
1609
|
+
...(typeof skill.whenToUse === 'string' && skill.whenToUse !== '' ? { whenToUse: skill.whenToUse } : {}),
|
|
1610
|
+
ui: skillUiDescriptor(skill),
|
|
1611
|
+
}))
|
|
1612
|
+
: []
|
|
1613
|
+
const groups = [
|
|
1614
|
+
{ id: 'commands', title: copy.commandsTitle, items: commands },
|
|
1615
|
+
...(skills.length > 0 ? [{ id: 'skills', title: copy.skillsTitle, items: skills }] : []),
|
|
1616
|
+
]
|
|
1617
|
+
const warnings = skillFrame.kind === 'error'
|
|
1618
|
+
? [{ source: 'skills', code: skillFrame.code, message: skillFrame.message }]
|
|
1619
|
+
: []
|
|
1620
|
+
log(`command catalog queried: session=${sessionId.value} commands=${commands.length} skills=${skills.length}`)
|
|
1621
|
+
return {
|
|
1622
|
+
kind: 'commands',
|
|
1623
|
+
sessionId: sessionId.value,
|
|
1624
|
+
locale: copy.locale,
|
|
1625
|
+
groups,
|
|
1626
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1627
|
+
}
|
|
1628
|
+
} catch (error) {
|
|
1629
|
+
const code = error && error.code ? error.code : 'internal'
|
|
1630
|
+
const message = error && error.message ? error.message : String(error)
|
|
1631
|
+
log(`command catalog failed: session=${sessionId.value} ${code}: ${message}`)
|
|
1632
|
+
return { kind: 'error', code, message, requestType: 'commands', sessionId: sessionId.value }
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
if (msg.type === 'command-options') {
|
|
1636
|
+
const sessionId = requireSessionId(msg)
|
|
1637
|
+
if (sessionId.error) return sessionId.error
|
|
1638
|
+
const command = typeof msg.command === 'string' && msg.command.trim() !== '' ? msg.command.trim() : null
|
|
1639
|
+
if (!command) return { kind: 'error', code: 'bad-request', message: 'command-options requires a command', requestType: 'command-options', sessionId: sessionId.value }
|
|
1640
|
+
return loadCommandOptions(api, command, sessionId.value)
|
|
1641
|
+
}
|
|
1642
|
+
if (msg.type === 'command-select') {
|
|
1643
|
+
const sessionId = requireSessionId(msg)
|
|
1644
|
+
if (sessionId.error) return sessionId.error
|
|
1645
|
+
const command = typeof msg.command === 'string' && msg.command.trim() !== '' ? msg.command.trim() : null
|
|
1646
|
+
const optionId = typeof msg.optionId === 'string' && msg.optionId !== '' ? msg.optionId : null
|
|
1647
|
+
if (!command || !optionId) return { kind: 'error', code: 'bad-request', message: 'command-select requires command and optionId', requestType: 'command-select', sessionId: sessionId.value }
|
|
1648
|
+
return selectCommandOption(api, host, command, sessionId.value, optionId)
|
|
1649
|
+
}
|
|
1650
|
+
if (msg.type === 'select-model') {
|
|
1651
|
+
const sessionId = requireSessionId(msg)
|
|
1652
|
+
if (sessionId.error) return sessionId.error
|
|
1653
|
+
const provider = typeof msg.provider === 'string' && msg.provider.trim() !== '' ? msg.provider.trim() : null
|
|
1654
|
+
const model = typeof msg.model === 'string' && msg.model.trim() !== '' ? msg.model.trim() : null
|
|
1655
|
+
if (!provider || !model) {
|
|
1656
|
+
return { kind: 'error', code: 'bad-request', message: 'select-model requires provider and model', requestType: 'select-model' }
|
|
1657
|
+
}
|
|
1658
|
+
const payload = { sessionId: sessionId.value, provider, model }
|
|
1659
|
+
if (typeof msg.reasoningEffort === 'string' && msg.reasoningEffort.trim() !== '') payload.reasoningEffort = msg.reasoningEffort.trim()
|
|
1660
|
+
return proxyQuery(api, 'select-model', api.sessions.selectModel.bind(api.sessions), payload)
|
|
1661
|
+
}
|
|
1662
|
+
if (msg.type === 'permission-options') {
|
|
1663
|
+
const frame = await proxyQuery(api, 'permission-options', api.settings.describe.bind(api.settings), {})
|
|
1664
|
+
if (frame.kind !== 'permission-options') return frame
|
|
1665
|
+
const out = {
|
|
1666
|
+
kind: 'permission-options',
|
|
1667
|
+
namespace: (frame.namespaces || []).find((n) => n.ns === 'permission') || null,
|
|
1668
|
+
}
|
|
1669
|
+
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
|
|
1670
|
+
if (sessionId) {
|
|
1671
|
+
out.sessionId = sessionId
|
|
1672
|
+
const hist = await querySessionProjection(api, msg.type, sessionId)
|
|
1673
|
+
if (hist.kind === 'error') return hist
|
|
1674
|
+
if (hist.kind === 'history' && hist.projections && hist.projections.values) {
|
|
1675
|
+
out.sessionPermissions = hist.projections.values.permissions || null
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
return out
|
|
1679
|
+
}
|
|
1680
|
+
if (msg.type === 'permission') {
|
|
1681
|
+
const sessionId = requireSessionId(msg)
|
|
1682
|
+
if (sessionId.error) return sessionId.error
|
|
1683
|
+
const name = typeof msg.name === 'string' && msg.name.trim() !== '' ? msg.name.trim() : null
|
|
1684
|
+
if (!name) {
|
|
1685
|
+
return { kind: 'error', code: 'bad-request', message: 'permission requires a name', requestType: 'permission' }
|
|
1686
|
+
}
|
|
1687
|
+
// Execute the /permission command through the Typert Remote gateway — the
|
|
1688
|
+
// same endpoint the browser calls (remote.commands.execute). This runs the
|
|
1689
|
+
// command registry handler WITHOUT sending anything to the model, and the
|
|
1690
|
+
// api-remotes agent lookup resumes cold sessions just like prompt does.
|
|
1691
|
+
try {
|
|
1692
|
+
// commands/execute 的当前 Typert descriptor 要求 images 字段始终存在;
|
|
1693
|
+
// 权限斜杠命令没有附件,因此显式传空数组。
|
|
1694
|
+
const execution = await host.commands.execute(
|
|
1695
|
+
sessionId.value,
|
|
1696
|
+
'/permission ' + name,
|
|
1697
|
+
[],
|
|
1698
|
+
new AbortController().signal,
|
|
1699
|
+
)
|
|
1700
|
+
if (execution === undefined || execution === null) {
|
|
1701
|
+
return { kind: 'error', code: 'unknown-command', message: 'command not found: /permission', requestType: 'permission', sessionId: sessionId.value }
|
|
1702
|
+
}
|
|
1703
|
+
log(`permission switched: session=${sessionId.value} preset=${name} (commandId=${execution.commandId})`)
|
|
1704
|
+
return {
|
|
1705
|
+
kind: 'permission',
|
|
1706
|
+
sessionId: sessionId.value,
|
|
1707
|
+
set: name,
|
|
1708
|
+
commandId: execution.commandId,
|
|
1709
|
+
result: execution.result,
|
|
1710
|
+
}
|
|
1711
|
+
} catch (error) {
|
|
1712
|
+
const code = error && error.code ? error.code : 'internal'
|
|
1713
|
+
const message = error && error.message ? error.message : String(error)
|
|
1714
|
+
log(`permission rejected: ${code}: ${message}`)
|
|
1715
|
+
return { kind: 'error', code, message, requestType: 'permission', sessionId: sessionId.value }
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
if (msg.type === 'context-usage') {
|
|
1719
|
+
const sessionId = requireSessionId(msg)
|
|
1720
|
+
if (sessionId.error) return sessionId.error
|
|
1721
|
+
const hist = await querySessionProjection(api, msg.type, sessionId.value)
|
|
1722
|
+
if (hist.kind !== 'history') return hist
|
|
1723
|
+
const values = (hist.projections && hist.projections.values) || {}
|
|
1724
|
+
return {
|
|
1725
|
+
kind: 'context-usage',
|
|
1726
|
+
sessionId: sessionId.value,
|
|
1727
|
+
asOfSeq: hist.projections ? hist.projections.asOfSeq : undefined,
|
|
1728
|
+
tokenUsage: values.tokenUsage || null,
|
|
1729
|
+
contextPressure: values.contextPressure || null,
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
if (msg.type === 'session-stats') {
|
|
1733
|
+
const sessionId = requireSessionId(msg)
|
|
1734
|
+
if (sessionId.error) return sessionId.error
|
|
1735
|
+
const hist = await querySessionProjection(api, msg.type, sessionId.value)
|
|
1736
|
+
if (hist.kind !== 'history') return hist
|
|
1737
|
+
const values = (hist.projections && hist.projections.values) || {}
|
|
1738
|
+
return {
|
|
1739
|
+
kind: 'session-stats',
|
|
1740
|
+
sessionId: sessionId.value,
|
|
1741
|
+
asOfSeq: hist.projections ? hist.projections.asOfSeq : undefined,
|
|
1742
|
+
sessionStats: values.sessionStats || null,
|
|
1743
|
+
tokenUsage: values.tokenUsage || null,
|
|
1744
|
+
contextPressure: values.contextPressure || null,
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
if (msg.type === 'tasks') {
|
|
1748
|
+
const sessionId = requireSessionId(msg)
|
|
1749
|
+
if (sessionId.error) return sessionId.error
|
|
1750
|
+
return readSessionProjection(api, 'tasks', sessionId.value, 'todos')
|
|
1751
|
+
}
|
|
1752
|
+
if (msg.type === 'goal') {
|
|
1753
|
+
const sessionId = requireSessionId(msg)
|
|
1754
|
+
if (sessionId.error) return sessionId.error
|
|
1755
|
+
return readSessionProjection(api, 'goal', sessionId.value, 'goal')
|
|
1756
|
+
}
|
|
1757
|
+
if (msg.type === 'goal-edit') {
|
|
1758
|
+
const sessionId = requireSessionId(msg)
|
|
1759
|
+
if (sessionId.error) return sessionId.error
|
|
1760
|
+
const ref = requireGoalRef(msg, sessionId.value)
|
|
1761
|
+
if (ref.error) return ref.error
|
|
1762
|
+
const payload = { sessionId: sessionId.value, ref: ref.value }
|
|
1763
|
+
if (typeof msg.objective === 'string' && msg.objective.trim() !== '') payload.objective = msg.objective.trim()
|
|
1764
|
+
if (typeof msg.maxGoalRounds === 'number' && Number.isSafeInteger(msg.maxGoalRounds) && msg.maxGoalRounds > 0) {
|
|
1765
|
+
payload.maxGoalRounds = msg.maxGoalRounds
|
|
1766
|
+
}
|
|
1767
|
+
if (payload.objective === undefined && payload.maxGoalRounds === undefined) {
|
|
1768
|
+
return { kind: 'error', code: 'bad-request', message: 'goal-edit requires a non-empty objective or a positive maxGoalRounds', requestType: 'goal-edit', sessionId: sessionId.value }
|
|
1769
|
+
}
|
|
1770
|
+
return mutateGoal(api, 'goal-edit', 'edit', payload)
|
|
1771
|
+
}
|
|
1772
|
+
if (msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear') {
|
|
1773
|
+
const sessionId = requireSessionId(msg)
|
|
1774
|
+
if (sessionId.error) return sessionId.error
|
|
1775
|
+
const ref = requireGoalRef(msg, sessionId.value)
|
|
1776
|
+
if (ref.error) return ref.error
|
|
1777
|
+
const method = msg.type === 'goal-pause' ? 'pause' : msg.type === 'goal-resume' ? 'resume' : 'clear'
|
|
1778
|
+
return mutateGoal(api, msg.type, method, { sessionId: sessionId.value, ref: ref.value })
|
|
1779
|
+
}
|
|
1780
|
+
if (msg.type === 'agent-presets') {
|
|
1781
|
+
return proxyQuery(api, 'agent-presets', api.agentPresets.list.bind(api.agentPresets), {})
|
|
1782
|
+
}
|
|
1783
|
+
if (msg.type === 'defaults') {
|
|
1784
|
+
const frame = await proxyQuery(api, 'defaults', api.settings.describe.bind(api.settings), {})
|
|
1785
|
+
if (frame.kind !== 'defaults') return frame
|
|
1786
|
+
const agentPresetNs = (frame.namespaces || []).find((n) => n.ns === 'agent-presets')
|
|
1787
|
+
const permissionNs = (frame.namespaces || []).find((n) => n.ns === 'permission')
|
|
1788
|
+
return {
|
|
1789
|
+
kind: 'defaults',
|
|
1790
|
+
agentPresetDefault: agentPresetNs && agentPresetNs.value && agentPresetNs.value.default !== undefined ? agentPresetNs.value.default : null,
|
|
1791
|
+
permissionDefault: permissionNs && permissionNs.value && permissionNs.value.defaultPreset !== undefined ? permissionNs.value.defaultPreset : null,
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
if (msg.type === 'set-default') {
|
|
1795
|
+
const target = typeof msg.target === 'string' && msg.target.trim() !== '' ? msg.target.trim() : null
|
|
1796
|
+
const value = typeof msg.value === 'string' && msg.value.trim() !== '' ? msg.value.trim() : null
|
|
1797
|
+
if (target !== 'agent-preset' && target !== 'permission') {
|
|
1798
|
+
return { kind: 'error', code: 'bad-request', message: 'set-default target must be "agent-preset" or "permission"', requestType: 'set-default' }
|
|
1799
|
+
}
|
|
1800
|
+
if (!value) {
|
|
1801
|
+
return { kind: 'error', code: 'bad-request', message: 'set-default requires a value', requestType: 'set-default' }
|
|
1802
|
+
}
|
|
1803
|
+
const ns = target === 'agent-preset' ? 'agent-presets' : 'permission'
|
|
1804
|
+
const patch = target === 'agent-preset' ? { default: value } : { defaultPreset: value }
|
|
1805
|
+
const updated = await proxyQuery(api, 'set-default', api.settings.update.bind(api.settings), { ns, patch })
|
|
1806
|
+
if (updated.kind !== 'set-default') return updated
|
|
1807
|
+
log(`default updated: ${ns}.${Object.keys(patch)[0]} = ${value}`)
|
|
1808
|
+
return { kind: 'set-default', target, value, applied: true, namespace: updated }
|
|
1809
|
+
}
|
|
1810
|
+
return null
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// ---------------------------------------------------------------------------
|
|
1814
|
+
// Device auth: a QR carries a short-lived one-time pairing code. Claiming it
|
|
1815
|
+
// over WebSocket yields a long-lived bearer token exactly once. The registry
|
|
1816
|
+
// stores only the token digest. Management endpoints are local-machine only by
|
|
1817
|
+
// default and mutating requests also require a same-origin browser context.
|
|
1818
|
+
// ---------------------------------------------------------------------------
|
|
1819
|
+
function isLoopback(req) {
|
|
1820
|
+
const addr = req.socket && req.socket.remoteAddress
|
|
1821
|
+
return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1'
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function parseProtocols(req) {
|
|
1825
|
+
const value = req.headers['sec-websocket-protocol']
|
|
1826
|
+
if (typeof value !== 'string') return []
|
|
1827
|
+
return value.split(',').map((entry) => entry.trim()).filter(Boolean)
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
function extractCredential(req, allowQueryToken) {
|
|
1831
|
+
const authorization = req.headers.authorization
|
|
1832
|
+
if (typeof authorization === 'string') {
|
|
1833
|
+
const match = /^Bearer\s+([A-Za-z0-9_-]{43})$/i.exec(authorization.trim())
|
|
1834
|
+
if (match) return { kind: 'token', value: match[1] }
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
for (const protocol of parseProtocols(req)) {
|
|
1838
|
+
if (protocol.startsWith('dsh-auth.')) return { kind: 'token', value: protocol.slice('dsh-auth.'.length) }
|
|
1839
|
+
if (protocol.startsWith('dsh-pair.')) return { kind: 'pairing', value: protocol.slice('dsh-pair.'.length) }
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
try {
|
|
1843
|
+
const query = new URL(req.url || '/', 'http://localhost').searchParams
|
|
1844
|
+
const pairing = query.get('pairingCode')
|
|
1845
|
+
if (pairing) return { kind: 'pairing', value: pairing }
|
|
1846
|
+
const token = allowQueryToken && query.get('token')
|
|
1847
|
+
if (token) return { kind: 'token', value: token }
|
|
1848
|
+
} catch (error) { /* ignore */ }
|
|
1849
|
+
return undefined
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
function extractClientDeviceId(req) {
|
|
1853
|
+
const value = req.headers['x-dsh-device-id']
|
|
1854
|
+
if (typeof value !== 'string') return undefined
|
|
1855
|
+
const normalized = value.trim()
|
|
1856
|
+
return /^[A-Za-z0-9._:-]{8,128}$/.test(normalized) ? normalized : undefined
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
function rejectUpgrade(socket, status, message) {
|
|
1860
|
+
const reason = status === 401 ? 'Unauthorized' : status === 403 ? 'Forbidden' : status === 404 ? 'Not Found' : status === 503 ? 'Service Unavailable' : 'Bad Request'
|
|
1861
|
+
socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(message)}\r\n\r\n${message}`)
|
|
1862
|
+
socket.destroy()
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
function isLocalHostname(hostname) {
|
|
1866
|
+
const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase()
|
|
1867
|
+
return normalized === 'localhost' || normalized === '::1' || /^127(?:\.\d{1,3}){3}$/.test(normalized)
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
function isPrivateNetworkHostname(hostname) {
|
|
1871
|
+
let normalized = hostname.replace(/^\[|\]$/g, '').split('%')[0].toLowerCase()
|
|
1872
|
+
if (normalized.startsWith('::ffff:')) normalized = normalized.slice('::ffff:'.length)
|
|
1873
|
+
if (isLocalHostname(normalized) || normalized.endsWith('.local')) return true
|
|
1874
|
+
const octets = normalized.split('.').map((part) => Number(part))
|
|
1875
|
+
if (octets.length === 4 && octets.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
|
|
1876
|
+
return octets[0] === 10 ||
|
|
1877
|
+
(octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) ||
|
|
1878
|
+
(octets[0] === 192 && octets[1] === 168) ||
|
|
1879
|
+
(octets[0] === 169 && octets[1] === 254)
|
|
1880
|
+
}
|
|
1881
|
+
return /^(?:f[cd][0-9a-f]{2}:|fe[89ab][0-9a-f]:)/i.test(normalized)
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
function privateLanAddresses() {
|
|
1885
|
+
const physical = []
|
|
1886
|
+
const fallback = []
|
|
1887
|
+
const virtualInterface = /^(?:docker|br-|veth|utun|awdl|llw|vmnet|vbox|virbr|tailscale|wg)/i
|
|
1888
|
+
for (const [name, records] of Object.entries(os.networkInterfaces())) {
|
|
1889
|
+
for (const record of records || []) {
|
|
1890
|
+
if (!record || record.internal || record.family !== 'IPv4' || !isPrivateNetworkHostname(record.address)) continue
|
|
1891
|
+
const target = virtualInterface.test(name) ? fallback : physical
|
|
1892
|
+
if (!target.includes(record.address)) target.push(record.address)
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
return physical.length ? physical : fallback
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
function lanWebSocketUrls(options, wsPath, boundPort) {
|
|
1899
|
+
if (!options.lanEnabled) return []
|
|
1900
|
+
const advertised = typeof options.lanAdvertiseHost === 'string' ? options.lanAdvertiseHost.trim() : ''
|
|
1901
|
+
const hosts = advertised
|
|
1902
|
+
? [advertised]
|
|
1903
|
+
: options.lanHost && options.lanHost !== '0.0.0.0' && options.lanHost !== '::'
|
|
1904
|
+
? [options.lanHost]
|
|
1905
|
+
: privateLanAddresses()
|
|
1906
|
+
return hosts
|
|
1907
|
+
.filter((host) => isPrivateNetworkHostname(host))
|
|
1908
|
+
.map((host) => `ws://${host.includes(':') ? `[${host}]` : host}:${boundPort}${wsPath}`)
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
function badRequest(message) {
|
|
1912
|
+
const error = new Error(message)
|
|
1913
|
+
error.status = 400
|
|
1914
|
+
return error
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
function normalizePublicUrl(value, req, wsPath) {
|
|
1918
|
+
let raw = typeof value === 'string' ? value.trim() : ''
|
|
1919
|
+
if (!raw) {
|
|
1920
|
+
const host = req.headers.host || `127.0.0.1`
|
|
1921
|
+
raw = `${req.socket && req.socket.encrypted ? 'wss' : 'ws'}://${host}${wsPath}`
|
|
1922
|
+
}
|
|
1923
|
+
let url
|
|
1924
|
+
try { url = new URL(raw) } catch { throw badRequest('invalid WebSocket endpoint URL') }
|
|
1925
|
+
if (url.protocol === 'https:') url.protocol = 'wss:'
|
|
1926
|
+
if (url.protocol === 'http:') url.protocol = 'ws:'
|
|
1927
|
+
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') throw badRequest('publicUrl must use wss:// (ws:// is allowed only for localhost and private LAN addresses)')
|
|
1928
|
+
if (url.username || url.password || url.search || url.hash) throw badRequest('publicUrl must not contain credentials, query parameters, or a fragment')
|
|
1929
|
+
if (['0.0.0.0', '[::]', '::'].includes(url.hostname)) throw badRequest('endpoint must not use an unspecified listen address')
|
|
1930
|
+
if (url.protocol === 'ws:' && !isPrivateNetworkHostname(url.hostname)) throw badRequest('publicUrl must use wss:// outside localhost or a private LAN')
|
|
1931
|
+
return url.toString()
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
function isSameOrigin(req) {
|
|
1935
|
+
const origin = req.headers.origin
|
|
1936
|
+
if (origin === undefined) return true
|
|
1937
|
+
if (typeof origin !== 'string' || typeof req.headers.host !== 'string') return false
|
|
1938
|
+
try {
|
|
1939
|
+
return new URL(origin).host === req.headers.host
|
|
1940
|
+
} catch (error) {
|
|
1941
|
+
return false
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
function readBody(req) {
|
|
1946
|
+
return new Promise((resolve, reject) => {
|
|
1947
|
+
let data = ''
|
|
1948
|
+
let bytes = 0
|
|
1949
|
+
let oversized = false
|
|
1950
|
+
req.on('data', (chunk) => {
|
|
1951
|
+
bytes += chunk.length
|
|
1952
|
+
if (bytes > MAX_MANAGEMENT_BODY_BYTES) oversized = true
|
|
1953
|
+
else data += chunk
|
|
1954
|
+
})
|
|
1955
|
+
req.on('end', () => {
|
|
1956
|
+
if (oversized) {
|
|
1957
|
+
const error = new Error('request body is too large')
|
|
1958
|
+
error.status = 413
|
|
1959
|
+
reject(error)
|
|
1960
|
+
return
|
|
1961
|
+
}
|
|
1962
|
+
try {
|
|
1963
|
+
const body = data === '' ? {} : JSON.parse(data)
|
|
1964
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new TypeError('expected an object')
|
|
1965
|
+
resolve(body)
|
|
1966
|
+
} catch (cause) {
|
|
1967
|
+
const error = new Error('request body must be a JSON object', { cause })
|
|
1968
|
+
error.status = 400
|
|
1969
|
+
reject(error)
|
|
1970
|
+
}
|
|
1971
|
+
})
|
|
1972
|
+
req.on('error', reject)
|
|
1973
|
+
})
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
function securityHeaders(contentType) {
|
|
1977
|
+
return {
|
|
1978
|
+
'Content-Type': contentType,
|
|
1979
|
+
'Cache-Control': 'no-store',
|
|
1980
|
+
'X-Content-Type-Options': 'nosniff',
|
|
1981
|
+
'Referrer-Policy': 'no-referrer',
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
function sendJson(res, status, value) {
|
|
1986
|
+
const body = JSON.stringify(value)
|
|
1987
|
+
res.writeHead(status, { ...securityHeaders('application/json; charset=utf-8'), 'Content-Length': Buffer.byteLength(body) })
|
|
1988
|
+
res.end(body)
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
function callSystemHelper(message, timeoutMs = 2_000) {
|
|
1992
|
+
return new Promise((resolve, reject) => {
|
|
1993
|
+
let settled = false
|
|
1994
|
+
let body = ''
|
|
1995
|
+
const finish = (error, value) => {
|
|
1996
|
+
if (settled) return
|
|
1997
|
+
settled = true
|
|
1998
|
+
socket.destroy()
|
|
1999
|
+
if (error) reject(error)
|
|
2000
|
+
else resolve(value)
|
|
2001
|
+
}
|
|
2002
|
+
const socket = net.createConnection(HELPER_SOCKET)
|
|
2003
|
+
socket.setEncoding('utf8')
|
|
2004
|
+
socket.setTimeout(timeoutMs)
|
|
2005
|
+
socket.on('connect', () => socket.write(`${JSON.stringify(message)}\n`))
|
|
2006
|
+
socket.on('data', (chunk) => {
|
|
2007
|
+
body += chunk
|
|
2008
|
+
if (body.length > 1024 * 1024) return finish(new Error('system helper response is too large'))
|
|
2009
|
+
const newline = body.indexOf('\n')
|
|
2010
|
+
if (newline < 0) return
|
|
2011
|
+
try {
|
|
2012
|
+
const response = JSON.parse(body.slice(0, newline))
|
|
2013
|
+
if (!response || response.ok !== true) throw new Error(response && response.error ? response.error : 'system helper request failed')
|
|
2014
|
+
finish(null, response.result)
|
|
2015
|
+
} catch (error) {
|
|
2016
|
+
finish(error)
|
|
2017
|
+
}
|
|
2018
|
+
})
|
|
2019
|
+
socket.on('timeout', () => finish(new Error('system helper request timed out')))
|
|
2020
|
+
socket.on('error', (error) => finish(error))
|
|
2021
|
+
socket.on('end', () => {
|
|
2022
|
+
if (!settled) finish(new Error('system helper closed without a response'))
|
|
2023
|
+
})
|
|
2024
|
+
})
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
async function systemHelperStatus() {
|
|
2028
|
+
try {
|
|
2029
|
+
return await callSystemHelper({ action: 'status' })
|
|
2030
|
+
} catch (error) {
|
|
2031
|
+
return {
|
|
2032
|
+
installed: false,
|
|
2033
|
+
configured: false,
|
|
2034
|
+
error: error && error.message ? error.message : String(error),
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
const plugin = {
|
|
2040
|
+
name: 'mobile-gateway',
|
|
2041
|
+
Config,
|
|
2042
|
+
// Hard dependencies: the gateway registers on the host web server and
|
|
2043
|
+
// admits mobile messages through the Host Remote Gateway. Cordis must not
|
|
2044
|
+
// activate this row before either service exists.
|
|
2045
|
+
inject: ['webServer', 'typertGateway', 'agentDefaultModel'],
|
|
2046
|
+
// exported for local tests; Cordis ignores unknown plugin fields
|
|
2047
|
+
admitMessage,
|
|
2048
|
+
handleQuery,
|
|
2049
|
+
apply(ctx, config) {
|
|
2050
|
+
const webServer = ctx.webServer
|
|
2051
|
+
const api = createDshHostAdapter(ctx.typertGateway)
|
|
2052
|
+
const host = api
|
|
2053
|
+
const agentDefaultModel = ctx.agentDefaultModel
|
|
2054
|
+
const options = {
|
|
2055
|
+
path: DEFAULT_WS_PATH,
|
|
2056
|
+
requireAuth: true,
|
|
2057
|
+
gatewayEnabled: false,
|
|
2058
|
+
gatewayStateFile: '',
|
|
2059
|
+
gatewayName: '',
|
|
2060
|
+
endpoints: [],
|
|
2061
|
+
gatewayWaitTimeoutMs: DEFAULT_GATEWAY_WAIT_TIMEOUT_MS,
|
|
2062
|
+
maxPayloadBytes: DEFAULT_MAX_WS_PAYLOAD_BYTES,
|
|
2063
|
+
fileDownloadsEnabled: true,
|
|
2064
|
+
fileDownloadMaxBytes: DEFAULT_FILE_DOWNLOAD_MAX_BYTES,
|
|
2065
|
+
fileDownloadChunkBytes: DEFAULT_FILE_DOWNLOAD_CHUNK_BYTES,
|
|
2066
|
+
fileDownloadIdleMs: DEFAULT_FILE_DOWNLOAD_IDLE_MS,
|
|
2067
|
+
fileDownloadMaxTransfers: DEFAULT_FILE_DOWNLOAD_MAX_TRANSFERS,
|
|
2068
|
+
adminLoopbackOnly: true,
|
|
2069
|
+
publicUrl: '',
|
|
2070
|
+
deviceFile: '',
|
|
2071
|
+
pairingTtlMs: DEFAULT_PAIRING_TTL_MS,
|
|
2072
|
+
allowQueryToken: false,
|
|
2073
|
+
publicUrlFile: '/etc/dsh-mobile-gateway/public-url',
|
|
2074
|
+
lanEnabled: false,
|
|
2075
|
+
lanHost: '0.0.0.0',
|
|
2076
|
+
lanPort: 3081,
|
|
2077
|
+
lanAdvertiseHost: '',
|
|
2078
|
+
...(config || {}),
|
|
2079
|
+
}
|
|
2080
|
+
const wsPath = options.path
|
|
2081
|
+
if (typeof wsPath !== 'string' || !wsPath.startsWith('/') || wsPath.endsWith('/') || wsPath.includes('?') || wsPath.includes('#')) {
|
|
2082
|
+
throw new Error('mobile-gateway: path must be an absolute pathname without a trailing slash, query, or fragment')
|
|
2083
|
+
}
|
|
2084
|
+
if (options.lanEnabled === true && (typeof options.lanHost !== 'string' || !options.lanHost.trim())) {
|
|
2085
|
+
throw new Error('mobile-gateway: lanHost must be a non-empty listen address')
|
|
2086
|
+
}
|
|
2087
|
+
if (options.lanEnabled === true && options.lanAdvertiseHost && !isPrivateNetworkHostname(options.lanAdvertiseHost)) {
|
|
2088
|
+
throw new Error('mobile-gateway: lanAdvertiseHost must be localhost or a private LAN address')
|
|
2089
|
+
}
|
|
2090
|
+
let requireAuth = options.requireAuth !== false
|
|
2091
|
+
const adminLoopbackOnly = options.adminLoopbackOnly !== false
|
|
2092
|
+
const deviceFile = options.deviceFile || path.join(os.homedir(), '.dsh', 'mobile-gateway-devices.json')
|
|
2093
|
+
if (options.gatewayMode !== undefined && !GATEWAY_MODES.includes(options.gatewayMode)) throw new Error('invalid gatewayMode')
|
|
2094
|
+
if (typeof options.gatewayName !== 'string' || options.gatewayName.trim().length > 80) throw new Error('gatewayName must be at most 80 characters')
|
|
2095
|
+
const normalizeEndpoints = (values) => {
|
|
2096
|
+
if (!Array.isArray(values) || values.length > 16 || values.some((value) => typeof value !== 'string' || !value.trim() || value.length > 2048)) {
|
|
2097
|
+
throw badRequest('endpoints must be an array of at most 16 nonempty URLs (2048 characters each)')
|
|
2098
|
+
}
|
|
2099
|
+
return [...new Set(values.map((value) => normalizePublicUrl(value, { headers: {} }, wsPath)))]
|
|
2100
|
+
}
|
|
2101
|
+
const configuredEndpoints = normalizeEndpoints(options.endpoints)
|
|
2102
|
+
const gatewayState = createGatewayState(options.gatewayStateFile || `${deviceFile}.gateway.json`)
|
|
2103
|
+
const gatewayIdentity = { gatewayId: gatewayState.gatewayId, gatewayName: options.gatewayName.trim() || os.hostname().slice(0, 80) }
|
|
2104
|
+
const registry = createRegistry(deviceFile, { pairingTtlMs: options.pairingTtlMs })
|
|
2105
|
+
const wss = new WebSocketServer({
|
|
2106
|
+
noServer: true,
|
|
2107
|
+
maxPayload: options.maxPayloadBytes,
|
|
2108
|
+
// Authentication data may ride in requested subprotocols, but a secret
|
|
2109
|
+
// must never be echoed as the negotiated protocol.
|
|
2110
|
+
handleProtocols(protocols) {
|
|
2111
|
+
return protocols.has('dsh-mobile-v1') ? 'dsh-mobile-v1' : false
|
|
2112
|
+
},
|
|
2113
|
+
})
|
|
2114
|
+
const clients = new Set()
|
|
2115
|
+
const pendingQuestions = new Map()
|
|
2116
|
+
const pendingApprovals = new Map()
|
|
2117
|
+
const fileTransfers = createFileTransferManager(api, options)
|
|
2118
|
+
const fileTransferExpiryTimer = setInterval(() => {
|
|
2119
|
+
fileTransfers.expire().catch((error) => {
|
|
2120
|
+
log(`file download expiry failed: ${error && error.message ? error.message : String(error)}`)
|
|
2121
|
+
})
|
|
2122
|
+
}, Math.min(options.fileDownloadIdleMs, 30_000))
|
|
2123
|
+
const backgroundStreamAbort = new AbortController()
|
|
2124
|
+
let archivedSessionIds = null
|
|
2125
|
+
let sessionQueues = null
|
|
2126
|
+
let sessionProjectionBaselines = {}
|
|
2127
|
+
let counter = 0
|
|
2128
|
+
let gatewayMode = gatewayState.mode ?? options.gatewayMode ?? (options.gatewayEnabled === true ? 'persistent' : 'disabled')
|
|
2129
|
+
let gatewayEnabled = gatewayMode !== 'disabled'
|
|
2130
|
+
let waitExpiresAt = null
|
|
2131
|
+
let waitTimer = null
|
|
2132
|
+
let connectedSinceEnabled = false
|
|
2133
|
+
let lastAuthRejectLogAt = 0
|
|
2134
|
+
let suppressedAuthRejects = 0
|
|
2135
|
+
let lanServer = null
|
|
2136
|
+
let lanListening = false
|
|
2137
|
+
let lanListenError = null
|
|
2138
|
+
let lanBoundPort = options.lanPort
|
|
2139
|
+
const configuredPublicUrl = () => {
|
|
2140
|
+
if (typeof options.publicUrl === 'string' && options.publicUrl.trim()) return options.publicUrl.trim()
|
|
2141
|
+
if (typeof options.publicUrlFile !== 'string' || !options.publicUrlFile.trim()) return ''
|
|
2142
|
+
try {
|
|
2143
|
+
return fs.readFileSync(options.publicUrlFile, 'utf8')
|
|
2144
|
+
.split(/\r?\n/)
|
|
2145
|
+
.map((line) => line.trim())
|
|
2146
|
+
.find((line) => line && !line.startsWith('#')) || ''
|
|
2147
|
+
} catch (error) {
|
|
2148
|
+
if (!error || error.code !== 'ENOENT') log(`unable to read publicUrlFile: ${error && error.message ? error.message : String(error)}`)
|
|
2149
|
+
return ''
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
const advertisedEndpoints = (primary, extra = []) => {
|
|
2154
|
+
const publicUrl = configuredPublicUrl()
|
|
2155
|
+
return normalizeEndpoints([...new Set([
|
|
2156
|
+
...(primary ? [primary] : []),
|
|
2157
|
+
...extra,
|
|
2158
|
+
...configuredEndpoints,
|
|
2159
|
+
...(publicUrl ? [publicUrl] : []),
|
|
2160
|
+
...(lanListening ? lanWebSocketUrls(options, wsPath, lanBoundPort) : []),
|
|
2161
|
+
])])
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
const setGatewayMode = (mode, reason, persist = true) => {
|
|
2165
|
+
if (!GATEWAY_MODES.includes(mode)) throw badRequest('mode must be disabled, temporary, or persistent')
|
|
2166
|
+
// Commit before changing the live service, so a failed save never reports success.
|
|
2167
|
+
if (persist) gatewayState.setMode(mode)
|
|
2168
|
+
if (waitTimer) clearTimeout(waitTimer)
|
|
2169
|
+
waitTimer = null
|
|
2170
|
+
gatewayMode = mode
|
|
2171
|
+
gatewayEnabled = mode !== 'disabled'
|
|
2172
|
+
waitExpiresAt = null
|
|
2173
|
+
connectedSinceEnabled = [...clients].some((client) => client.readyState === 1)
|
|
2174
|
+
if (mode === 'temporary' && !connectedSinceEnabled) {
|
|
2175
|
+
waitExpiresAt = Date.now() + options.gatewayWaitTimeoutMs
|
|
2176
|
+
waitTimer = setTimeout(() => {
|
|
2177
|
+
waitTimer = null
|
|
2178
|
+
if (!gatewayEnabled || connectedSinceEnabled || [...clients].some((client) => client.readyState === 1)) return
|
|
2179
|
+
gatewayEnabled = false
|
|
2180
|
+
gatewayMode = 'disabled'
|
|
2181
|
+
waitExpiresAt = null
|
|
2182
|
+
try { gatewayState.setMode('disabled') } catch (error) {
|
|
2183
|
+
log(`failed to persist automatic gateway disable: ${error.message}`)
|
|
2184
|
+
}
|
|
2185
|
+
log('mobile gateway automatically disabled: no device connected before timeout')
|
|
2186
|
+
}, options.gatewayWaitTimeoutMs)
|
|
2187
|
+
} else if (mode === 'disabled') {
|
|
2188
|
+
for (const client of clients) client.close(4004, 'mobile gateway disabled')
|
|
2189
|
+
}
|
|
2190
|
+
log(`mobile gateway mode=${mode}${reason ? `: ${reason}` : ''}`)
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
const logAuthRejected = (req) => {
|
|
2194
|
+
const now = Date.now()
|
|
2195
|
+
if (now - lastAuthRejectLogAt >= 30_000) {
|
|
2196
|
+
const suffix = suppressedAuthRejects ? ` (${suppressedAuthRejects} repeated attempts suppressed)` : ''
|
|
2197
|
+
const remote = (req.socket && req.socket.remoteAddress) || 'unknown'
|
|
2198
|
+
const origin = typeof req.headers.origin === 'string' ? req.headers.origin.slice(0, 160) : '-'
|
|
2199
|
+
const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'].slice(0, 160) : '-'
|
|
2200
|
+
log(`auth rejected: missing or invalid device credential; remote=${remote}; origin=${origin}; user-agent=${userAgent}${suffix}`)
|
|
2201
|
+
lastAuthRejectLogAt = now
|
|
2202
|
+
suppressedAuthRejects = 0
|
|
2203
|
+
} else {
|
|
2204
|
+
suppressedAuthRejects += 1
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
const questionFrameFor = (rpcId, payload, replay = false) => ({
|
|
2209
|
+
kind: 'question-requested',
|
|
2210
|
+
rpcId: String(rpcId),
|
|
2211
|
+
sessionId: String(payload.sessionId),
|
|
2212
|
+
questions: payload.questions,
|
|
2213
|
+
...(replay ? { replay: true } : {}),
|
|
2214
|
+
})
|
|
2215
|
+
|
|
2216
|
+
const approvalFrameFor = (rpcId, payload, replay = false) => ({
|
|
2217
|
+
kind: 'approval-requested',
|
|
2218
|
+
rpcId: String(rpcId),
|
|
2219
|
+
sessionId: String(payload.sessionId),
|
|
2220
|
+
approvalId: String(payload.approvalId),
|
|
2221
|
+
toolName: payload.toolName,
|
|
2222
|
+
...(payload.callId !== undefined ? { callId: payload.callId } : {}),
|
|
2223
|
+
...(payload.reason !== undefined ? { reason: payload.reason } : {}),
|
|
2224
|
+
...(replay ? { replay: true } : {}),
|
|
2225
|
+
})
|
|
2226
|
+
|
|
2227
|
+
const broadcastInteractionFrame = (frame) => {
|
|
2228
|
+
const wire = stringifyWireFrame(frame)
|
|
2229
|
+
for (const client of clients) {
|
|
2230
|
+
if (client.mobileChannel === 'conversation') continue
|
|
2231
|
+
if (client.filterSessionId && client.filterSessionId !== frame.sessionId) continue
|
|
2232
|
+
if (client.readyState === 1) client.send(wire)
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
// Session-list metadata must reach every connected client even when it is
|
|
2237
|
+
// subscribed to one conversation. Subscriptions only scope the heavier
|
|
2238
|
+
// conversation and interaction streams.
|
|
2239
|
+
const broadcastSessionMetadataFrame = (frame) => {
|
|
2240
|
+
const wire = stringifyWireFrame(frame)
|
|
2241
|
+
for (const client of clients) {
|
|
2242
|
+
if (client.mobileChannel === 'conversation') continue
|
|
2243
|
+
if (client.readyState === 1) client.send(wire)
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
const replayPendingInteractions = (ws, trigger) => {
|
|
2248
|
+
// In split-channel mode `subscribe` belongs to the conversation socket,
|
|
2249
|
+
// while interaction frames belong to the control socket. Bridge a
|
|
2250
|
+
// conversation subscription to every live control/legacy socket so
|
|
2251
|
+
// reopening a Session restores its still-pending cards. Reducers dedupe
|
|
2252
|
+
// by rpcId, so replaying to another trusted device is harmless and keeps
|
|
2253
|
+
// all active clients in sync.
|
|
2254
|
+
// The companion control socket already receives a full replay when it
|
|
2255
|
+
// connects, so a bare conversation connect must not duplicate it again.
|
|
2256
|
+
if (ws.mobileChannel === 'conversation' && trigger !== 'subscribe') {
|
|
2257
|
+
return { questionCount: 0, approvalCount: 0 }
|
|
2258
|
+
}
|
|
2259
|
+
const recipients = ws.mobileChannel === 'conversation'
|
|
2260
|
+
? [...clients].filter((client) => client.readyState === 1 && client.mobileChannel !== 'conversation')
|
|
2261
|
+
: [ws]
|
|
2262
|
+
if (recipients.length === 0) return { questionCount: 0, approvalCount: 0 }
|
|
2263
|
+
const sessionId = ws.filterSessionId
|
|
2264
|
+
let questionCount = 0
|
|
2265
|
+
let approvalCount = 0
|
|
2266
|
+
for (const pending of pendingQuestions.values()) {
|
|
2267
|
+
if (sessionId && sessionId !== pending.sessionId) continue
|
|
2268
|
+
const wire = stringifyWireFrame(questionFrameFor(pending.rpcId, pending, true))
|
|
2269
|
+
for (const recipient of recipients) {
|
|
2270
|
+
if (recipient.filterSessionId && recipient.filterSessionId !== pending.sessionId) continue
|
|
2271
|
+
recipient.send(wire)
|
|
2272
|
+
}
|
|
2273
|
+
questionCount += 1
|
|
2274
|
+
}
|
|
2275
|
+
for (const pending of pendingApprovals.values()) {
|
|
2276
|
+
if (sessionId && sessionId !== pending.sessionId) continue
|
|
2277
|
+
const wire = stringifyWireFrame(approvalFrameFor(pending.rpcId, pending, true))
|
|
2278
|
+
for (const recipient of recipients) {
|
|
2279
|
+
if (recipient.filterSessionId && recipient.filterSessionId !== pending.sessionId) continue
|
|
2280
|
+
recipient.send(wire)
|
|
2281
|
+
}
|
|
2282
|
+
approvalCount += 1
|
|
2283
|
+
}
|
|
2284
|
+
log(`interaction replay: trigger=${trigger} channel=${ws.mobileChannel} filtered=${Boolean(sessionId)} recipients=${recipients.length} questions=${questionCount} approvals=${approvalCount}`)
|
|
2285
|
+
return { questionCount, approvalCount }
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
const hasInteractionClient = (sessionId) => [...clients].some((client) => (
|
|
2289
|
+
client.readyState === 1
|
|
2290
|
+
&& client.mobileChannel !== 'conversation'
|
|
2291
|
+
&& (!client.filterSessionId || client.filterSessionId === sessionId)
|
|
2292
|
+
))
|
|
2293
|
+
|
|
2294
|
+
const abortDownstreamInteraction = (pending, kind) => {
|
|
2295
|
+
if (pending.downstreamAbort.signal.aborted) return
|
|
2296
|
+
// The Host API gateway fans this abort out to every WebUI delivery of
|
|
2297
|
+
// the same Remote event. Its existing cancel handling removes each
|
|
2298
|
+
// pending BottomSheet; Mobile peers receive the resolved frame below.
|
|
2299
|
+
pending.downstreamAbort.abort(new Error(`${kind} completed outside WebUI`))
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
const completeQuestion = (pending, action, answer, cancellationCode = 'ASK_CANCELLED', cancellationError, source = 'mobile') => {
|
|
2303
|
+
if (pendingQuestions.get(pending.rpcId) !== pending) return false
|
|
2304
|
+
pendingQuestions.delete(pending.rpcId)
|
|
2305
|
+
pending.cleanup()
|
|
2306
|
+
if (source !== 'downstream') abortDownstreamInteraction(pending, 'question')
|
|
2307
|
+
if (action === 'answer') pending.resolve(answer)
|
|
2308
|
+
else {
|
|
2309
|
+
const error = cancellationError ?? new Error('question cancelled by mobile user')
|
|
2310
|
+
if (!error.name || error.name === 'Error') error.name = 'UserQuestionError'
|
|
2311
|
+
if (!error.code) error.code = cancellationCode
|
|
2312
|
+
pending.reject(error)
|
|
2313
|
+
}
|
|
2314
|
+
broadcastInteractionFrame({
|
|
2315
|
+
kind: 'question-resolved',
|
|
2316
|
+
rpcId: pending.rpcId,
|
|
2317
|
+
sessionId: pending.sessionId,
|
|
2318
|
+
outcome: action === 'answer' ? 'answered' : 'cancelled',
|
|
2319
|
+
})
|
|
2320
|
+
return true
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
const normalizeQuestionAnswers = (pending, answers) => {
|
|
2324
|
+
if (answers.length !== pending.questions.length) return { error: 'answers must cover every question exactly once' }
|
|
2325
|
+
const normalized = []
|
|
2326
|
+
for (let index = 0; index < pending.questions.length; index += 1) {
|
|
2327
|
+
const question = pending.questions[index]
|
|
2328
|
+
const answer = answers[index]
|
|
2329
|
+
if (!answer || typeof answer !== 'object' || answer.id !== question.id || !Array.isArray(answer.selected)) {
|
|
2330
|
+
return { error: 'answers must preserve question order, ids, and selected arrays' }
|
|
2331
|
+
}
|
|
2332
|
+
if (answer.selected.some((label) => typeof label !== 'string')) return { error: 'selected values must be strings' }
|
|
2333
|
+
if (new Set(answer.selected).size !== answer.selected.length) return { error: 'selected values must not repeat' }
|
|
2334
|
+
const offered = new Set((question.options || []).map((option) => option.label))
|
|
2335
|
+
if (answer.selected.some((label) => !offered.has(label))) return { error: 'selected values must match offered option labels' }
|
|
2336
|
+
if (question.multiSelect !== true && answer.selected.length > 1) return { error: 'single-select questions accept at most one selection' }
|
|
2337
|
+
let custom
|
|
2338
|
+
if (answer.custom !== undefined) {
|
|
2339
|
+
if (typeof answer.custom !== 'string' || !answer.custom.trim()) return { error: 'custom answers must be non-empty strings' }
|
|
2340
|
+
custom = answer.custom.trim()
|
|
2341
|
+
}
|
|
2342
|
+
if (question.multiSelect !== true && custom !== undefined && answer.selected.length > 0) {
|
|
2343
|
+
return { error: 'single-select custom answers cannot accompany a selection' }
|
|
2344
|
+
}
|
|
2345
|
+
normalized.push({ id: answer.id, selected: [...answer.selected], ...(custom === undefined ? {} : { custom }) })
|
|
2346
|
+
}
|
|
2347
|
+
return { value: { answers: normalized } }
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
const completeApproval = (pending, outcome, source = 'mobile') => {
|
|
2351
|
+
if (pendingApprovals.get(pending.rpcId) !== pending) return false
|
|
2352
|
+
pendingApprovals.delete(pending.rpcId)
|
|
2353
|
+
pending.cleanup()
|
|
2354
|
+
if (source !== 'downstream') abortDownstreamInteraction(pending, 'approval')
|
|
2355
|
+
pending.resolve(outcome)
|
|
2356
|
+
broadcastInteractionFrame({
|
|
2357
|
+
kind: 'approval-resolved',
|
|
2358
|
+
rpcId: pending.rpcId,
|
|
2359
|
+
sessionId: pending.sessionId,
|
|
2360
|
+
approvalId: pending.approvalId,
|
|
2361
|
+
outcome,
|
|
2362
|
+
})
|
|
2363
|
+
return true
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
const startDownstreamQuestion = (pending) => {
|
|
2367
|
+
let downstream
|
|
2368
|
+
try {
|
|
2369
|
+
downstream = pending.next()
|
|
2370
|
+
} catch (error) {
|
|
2371
|
+
pending.downstream = { kind: 'rejected', error }
|
|
2372
|
+
return
|
|
2373
|
+
}
|
|
2374
|
+
pending.downstream = { kind: 'pending' }
|
|
2375
|
+
Promise.resolve(downstream).then((answer) => {
|
|
2376
|
+
if (pendingQuestions.get(pending.rpcId) !== pending) return
|
|
2377
|
+
if (answer && Array.isArray(answer.answers)) {
|
|
2378
|
+
completeQuestion(pending, 'answer', answer, undefined, undefined, 'downstream')
|
|
2379
|
+
log(`question resolved by downstream answerer: rpcId=${pending.rpcId} session=${pending.sessionId}`)
|
|
2380
|
+
} else {
|
|
2381
|
+
// A downstream answerer may report that no WebUI currently owns the
|
|
2382
|
+
// request. Keep Mobile active; the value is used only if every Mobile
|
|
2383
|
+
// client later disconnects.
|
|
2384
|
+
pending.downstream = { kind: 'unavailable', value: answer }
|
|
2385
|
+
if (!hasInteractionClient(pending.sessionId)) fallbackQuestion(pending)
|
|
2386
|
+
}
|
|
2387
|
+
}, (error) => {
|
|
2388
|
+
if (pendingQuestions.get(pending.rpcId) !== pending) return
|
|
2389
|
+
if (error?.code === 'ASK_CANCELLED' || error?.code === 'ASK_ABORTED') {
|
|
2390
|
+
completeQuestion(pending, 'cancel', undefined, error.code, error, 'downstream')
|
|
2391
|
+
log(`question cancelled by downstream answerer: rpcId=${pending.rpcId} session=${pending.sessionId}`)
|
|
2392
|
+
} else {
|
|
2393
|
+
pending.downstream = { kind: 'rejected', error }
|
|
2394
|
+
if (!hasInteractionClient(pending.sessionId)) fallbackQuestion(pending)
|
|
2395
|
+
}
|
|
2396
|
+
})
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
const startDownstreamApproval = (pending) => {
|
|
2400
|
+
let downstream
|
|
2401
|
+
try {
|
|
2402
|
+
downstream = pending.next()
|
|
2403
|
+
} catch (error) {
|
|
2404
|
+
pending.downstream = { kind: 'rejected', error }
|
|
2405
|
+
return
|
|
2406
|
+
}
|
|
2407
|
+
pending.downstream = { kind: 'pending' }
|
|
2408
|
+
Promise.resolve(downstream).then((outcome) => {
|
|
2409
|
+
if (pendingApprovals.get(pending.rpcId) !== pending) return
|
|
2410
|
+
if (outcome === 'allowed-once' || outcome === 'rejected') {
|
|
2411
|
+
completeApproval(pending, outcome, 'downstream')
|
|
2412
|
+
log(`approval resolved by downstream answerer: rpcId=${pending.rpcId} session=${pending.sessionId} outcome=${outcome}`)
|
|
2413
|
+
} else {
|
|
2414
|
+
pending.downstream = { kind: 'unavailable', value: outcome }
|
|
2415
|
+
if (!hasInteractionClient(pending.sessionId)) fallbackApproval(pending)
|
|
2416
|
+
}
|
|
2417
|
+
}, (error) => {
|
|
2418
|
+
if (pendingApprovals.get(pending.rpcId) !== pending) return
|
|
2419
|
+
pending.downstream = { kind: 'rejected', error }
|
|
2420
|
+
if (!hasInteractionClient(pending.sessionId)) fallbackApproval(pending)
|
|
2421
|
+
})
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
const fallbackQuestion = (pending) => {
|
|
2425
|
+
if (pendingQuestions.get(pending.rpcId) !== pending) return
|
|
2426
|
+
if (pending.downstream?.kind === 'pending') return
|
|
2427
|
+
pendingQuestions.delete(pending.rpcId)
|
|
2428
|
+
pending.cleanup()
|
|
2429
|
+
if (pending.downstream?.kind === 'rejected') pending.reject(pending.downstream.error)
|
|
2430
|
+
else pending.resolve(pending.downstream?.value)
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
const fallbackApproval = (pending) => {
|
|
2434
|
+
if (pendingApprovals.get(pending.rpcId) !== pending) return
|
|
2435
|
+
if (pending.downstream?.kind === 'pending') return
|
|
2436
|
+
pendingApprovals.delete(pending.rpcId)
|
|
2437
|
+
pending.cleanup()
|
|
2438
|
+
if (pending.downstream?.kind === 'rejected') pending.reject(pending.downstream.error)
|
|
2439
|
+
else pending.resolve(pending.downstream?.value)
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
const releaseUnclaimedInteractions = () => {
|
|
2443
|
+
for (const pending of [...pendingQuestions.values()]) {
|
|
2444
|
+
if (!hasInteractionClient(pending.sessionId)) fallbackQuestion(pending)
|
|
2445
|
+
}
|
|
2446
|
+
for (const pending of [...pendingApprovals.values()]) {
|
|
2447
|
+
if (!hasInteractionClient(pending.sessionId)) fallbackApproval(pending)
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
const respondToQuestion = async (msg, cancel = false) => {
|
|
2452
|
+
const rpcId = typeof msg.rpcId === 'string' && msg.rpcId.trim() ? msg.rpcId.trim() : null
|
|
2453
|
+
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : null
|
|
2454
|
+
if (!rpcId || !sessionId) {
|
|
2455
|
+
return { kind: 'error', code: 'bad-request', message: `${msg.type} requires rpcId and sessionId`, requestType: msg.type }
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
const pending = pendingQuestions.get(rpcId)
|
|
2459
|
+
if (!pending) {
|
|
2460
|
+
return { kind: 'question-response', rpcId, sessionId, action: cancel ? 'cancel' : 'answer', accepted: false, reason: 'not-pending' }
|
|
2461
|
+
}
|
|
2462
|
+
if (pending.sessionId !== sessionId) {
|
|
2463
|
+
return { kind: 'error', code: 'bad-request', message: 'sessionId does not match the pending question', requestType: msg.type, sessionId }
|
|
2464
|
+
}
|
|
2465
|
+
if (!cancel && !Array.isArray(msg.answers)) {
|
|
2466
|
+
return { kind: 'error', code: 'bad-request', message: 'question-answer requires an answers array', requestType: msg.type, sessionId }
|
|
2467
|
+
}
|
|
2468
|
+
const normalized = cancel ? null : normalizeQuestionAnswers(pending, msg.answers)
|
|
2469
|
+
if (normalized?.error) {
|
|
2470
|
+
return { kind: 'error', code: 'bad-response', message: normalized.error, requestType: msg.type, sessionId }
|
|
2471
|
+
}
|
|
2472
|
+
const answer = normalized?.value
|
|
2473
|
+
const accepted = completeQuestion(pending, cancel ? 'cancel' : 'answer', answer)
|
|
2474
|
+
log(`question response: rpcId=${rpcId} session=${sessionId} action=${cancel ? 'cancel' : 'answer'} accepted=${accepted}`)
|
|
2475
|
+
return {
|
|
2476
|
+
kind: 'question-response',
|
|
2477
|
+
rpcId,
|
|
2478
|
+
sessionId,
|
|
2479
|
+
action: cancel ? 'cancel' : 'answer',
|
|
2480
|
+
accepted,
|
|
2481
|
+
...(!accepted ? { reason: 'not-pending' } : {}),
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
const respondToApproval = async (msg) => {
|
|
2486
|
+
const rpcId = typeof msg.rpcId === 'string' && msg.rpcId.trim() ? msg.rpcId.trim() : null
|
|
2487
|
+
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : null
|
|
2488
|
+
const approvalId = typeof msg.approvalId === 'string' && msg.approvalId.trim() ? msg.approvalId.trim() : null
|
|
2489
|
+
const outcome = msg.outcome
|
|
2490
|
+
if (!rpcId || !sessionId || !approvalId) {
|
|
2491
|
+
return { kind: 'error', code: 'bad-request', message: 'approval-response requires rpcId, sessionId, and approvalId', requestType: msg.type }
|
|
2492
|
+
}
|
|
2493
|
+
if (outcome !== 'allowed-once' && outcome !== 'rejected') {
|
|
2494
|
+
return { kind: 'error', code: 'bad-request', message: 'approval-response outcome must be "allowed-once" or "rejected"', requestType: msg.type, sessionId }
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
const pending = pendingApprovals.get(rpcId)
|
|
2498
|
+
if (!pending) {
|
|
2499
|
+
return { kind: 'approval-response', rpcId, sessionId, approvalId, outcome, accepted: false, reason: 'not-pending' }
|
|
2500
|
+
}
|
|
2501
|
+
if (pending.sessionId !== sessionId || pending.approvalId !== approvalId) {
|
|
2502
|
+
return { kind: 'error', code: 'bad-request', message: 'approval-response does not match the pending approval', requestType: msg.type, sessionId }
|
|
2503
|
+
}
|
|
2504
|
+
const accepted = completeApproval(pending, outcome)
|
|
2505
|
+
log(`approval response: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} outcome=${outcome} accepted=${accepted}`)
|
|
2506
|
+
return {
|
|
2507
|
+
kind: 'approval-response',
|
|
2508
|
+
rpcId,
|
|
2509
|
+
sessionId,
|
|
2510
|
+
approvalId,
|
|
2511
|
+
outcome,
|
|
2512
|
+
accepted,
|
|
2513
|
+
...(!accepted ? { reason: 'not-pending' } : {}),
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
// Saved user choice wins; a temporary startup gets a fresh first-connection window.
|
|
2518
|
+
setGatewayMode(gatewayMode, 'startup', false)
|
|
2519
|
+
|
|
2520
|
+
log(`applying: version=${PLUGIN_VERSION} interactionProtocol=${INTERACTION_PROTOCOL_REVISION} path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
|
|
2521
|
+
|
|
2522
|
+
// ---- device management routes (loopback-gated admin surface) ----
|
|
2523
|
+
const disposeMgmt = webServer.register({
|
|
2524
|
+
kind: 'prefix',
|
|
2525
|
+
path: '/mgw',
|
|
2526
|
+
handler: async (req, res) => {
|
|
2527
|
+
try {
|
|
2528
|
+
if (adminLoopbackOnly && !isLoopback(req)) {
|
|
2529
|
+
sendJson(res, 403, { error: 'forbidden', message: 'management API is loopback-only' })
|
|
2530
|
+
return
|
|
2531
|
+
}
|
|
2532
|
+
if (req.method !== 'GET' && req.method !== 'HEAD' && !isSameOrigin(req)) {
|
|
2533
|
+
sendJson(res, 403, { error: 'forbidden', message: 'cross-origin management request rejected' })
|
|
2534
|
+
return
|
|
2535
|
+
}
|
|
2536
|
+
const url = new URL(req.url || '/', 'http://x')
|
|
2537
|
+
const p = url.pathname
|
|
2538
|
+
if (req.method === 'GET' && p === '/mgw/status') {
|
|
2539
|
+
sendJson(res, 200, {
|
|
2540
|
+
version: PLUGIN_VERSION,
|
|
2541
|
+
requireAuth,
|
|
2542
|
+
gatewayEnabled,
|
|
2543
|
+
gatewayMode,
|
|
2544
|
+
...gatewayIdentity,
|
|
2545
|
+
endpoints: advertisedEndpoints(),
|
|
2546
|
+
waitExpiresAt,
|
|
2547
|
+
connectedClients: clients.size,
|
|
2548
|
+
webPort: webServer.port,
|
|
2549
|
+
wsPath,
|
|
2550
|
+
publicUrl: configuredPublicUrl() || null,
|
|
2551
|
+
lan: {
|
|
2552
|
+
enabled: options.lanEnabled === true,
|
|
2553
|
+
listening: lanListening,
|
|
2554
|
+
host: options.lanHost,
|
|
2555
|
+
port: lanBoundPort,
|
|
2556
|
+
urls: lanWebSocketUrls(options, wsPath, lanBoundPort),
|
|
2557
|
+
requireAuth: true,
|
|
2558
|
+
error: lanListenError,
|
|
2559
|
+
},
|
|
2560
|
+
pairingTtlMs: options.pairingTtlMs,
|
|
2561
|
+
queryTokenAllowed: !!options.allowQueryToken,
|
|
2562
|
+
})
|
|
2563
|
+
} else if (req.method === 'GET' && p === '/mgw/public-setup') {
|
|
2564
|
+
sendJson(res, 200, await systemHelperStatus())
|
|
2565
|
+
} else if (req.method === 'POST' && p === '/mgw/public-setup') {
|
|
2566
|
+
const body = await readBody(req)
|
|
2567
|
+
if (typeof body.publicIp !== 'string' || !body.publicIp.trim()) throw badRequest('publicIp is required')
|
|
2568
|
+
const result = await callSystemHelper({
|
|
2569
|
+
action: 'configure',
|
|
2570
|
+
publicIp: body.publicIp.trim(),
|
|
2571
|
+
backendPort: webServer.port,
|
|
2572
|
+
}, 15 * 60 * 1000)
|
|
2573
|
+
log(`public endpoint configured: ${result.publicUrl} -> 127.0.0.1:${result.backendPort}`)
|
|
2574
|
+
sendJson(res, 200, result)
|
|
2575
|
+
} else if (req.method === 'POST' && p === '/mgw/gateway') {
|
|
2576
|
+
const body = await readBody(req)
|
|
2577
|
+
if (body.mode !== undefined && body.enabled !== undefined) throw badRequest('provide mode or enabled, not both')
|
|
2578
|
+
if (body.mode === undefined && typeof body.enabled !== 'boolean') throw badRequest('mode or boolean enabled is required')
|
|
2579
|
+
setGatewayMode(body.mode !== undefined ? body.mode : (body.enabled ? 'temporary' : 'disabled'), 'changed from management UI')
|
|
2580
|
+
sendJson(res, 200, { gatewayEnabled, gatewayMode, waitExpiresAt, connectedClients: clients.size })
|
|
2581
|
+
} else if (req.method === 'POST' && p === '/mgw/auth') {
|
|
2582
|
+
const body = await readBody(req)
|
|
2583
|
+
if (typeof body.enabled !== 'boolean') throw badRequest('enabled must be a boolean')
|
|
2584
|
+
requireAuth = body.enabled
|
|
2585
|
+
let disconnected = 0
|
|
2586
|
+
if (requireAuth) {
|
|
2587
|
+
for (const client of clients) {
|
|
2588
|
+
if (!client.deviceId) {
|
|
2589
|
+
disconnected += 1
|
|
2590
|
+
client.close(4003, 'authentication enabled')
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
log(`device authentication ${requireAuth ? 'enabled' : 'disabled'} from management UI`)
|
|
2595
|
+
sendJson(res, 200, { requireAuth, disconnected })
|
|
2596
|
+
} else if (req.method === 'GET' && p === '/mgw/devices') {
|
|
2597
|
+
sendJson(res, 200, { devices: registry.list() })
|
|
2598
|
+
} else if (req.method === 'POST' && p === '/mgw/pair') {
|
|
2599
|
+
if (!gatewayEnabled) {
|
|
2600
|
+
const error = new Error('enable the mobile gateway before creating a pairing code')
|
|
2601
|
+
error.status = 409
|
|
2602
|
+
throw error
|
|
2603
|
+
}
|
|
2604
|
+
const body = await readBody(req)
|
|
2605
|
+
const name = typeof body.name === 'string' ? body.name : undefined
|
|
2606
|
+
const publicUrl = normalizePublicUrl(body.publicUrl || configuredPublicUrl(), req, wsPath)
|
|
2607
|
+
const endpoints = advertisedEndpoints(publicUrl, normalizeEndpoints(body.endpoints ?? []))
|
|
2608
|
+
const pairing = registry.createPairing(name)
|
|
2609
|
+
const payload = {
|
|
2610
|
+
version: 2,
|
|
2611
|
+
publicUrl,
|
|
2612
|
+
pairingCode: pairing.code,
|
|
2613
|
+
expiresAt: pairing.expiresAt,
|
|
2614
|
+
...gatewayIdentity,
|
|
2615
|
+
endpoints,
|
|
2616
|
+
}
|
|
2617
|
+
// QR/manual pairing has one canonical wire representation: the
|
|
2618
|
+
// UTF-8 JSON payload encoded as unpadded Base64URL. Base64URL is
|
|
2619
|
+
// copy-safe and QR-safe (`+`, `/`, and `=` never appear), but is
|
|
2620
|
+
// encoding rather than encryption; secrecy still comes from the
|
|
2621
|
+
// short TTL and single-use pairing code.
|
|
2622
|
+
const qrPayload = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
|
|
2623
|
+
const svg = await QRCode.toString(qrPayload, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' })
|
|
2624
|
+
log(`pairing created: ${pairing.name} (${pairing.id}), expires=${new Date(pairing.expiresAt).toISOString()}`)
|
|
2625
|
+
sendJson(res, 201, {
|
|
2626
|
+
pairing: { id: pairing.id, name: pairing.name, expiresAt: pairing.expiresAt },
|
|
2627
|
+
payload,
|
|
2628
|
+
qrPayload,
|
|
2629
|
+
svg,
|
|
2630
|
+
})
|
|
2631
|
+
} else if (req.method === 'POST' && p.startsWith('/mgw/devices/') && p.endsWith('/revoke')) {
|
|
2632
|
+
const id = decodeURIComponent(p.slice('/mgw/devices/'.length, -'/revoke'.length))
|
|
2633
|
+
const revoked = registry.revoke(id)
|
|
2634
|
+
if (revoked) {
|
|
2635
|
+
for (const client of clients) {
|
|
2636
|
+
if (client.deviceId === id) client.close(4003, 'device revoked')
|
|
2637
|
+
}
|
|
2638
|
+
log(`device revoked: ${id}`)
|
|
2639
|
+
}
|
|
2640
|
+
sendJson(res, revoked ? 200 : 404, { revoked })
|
|
2641
|
+
} else if (req.method === 'GET' && p === '/mgw') {
|
|
2642
|
+
res.writeHead(302, { ...securityHeaders('text/plain; charset=utf-8'), Location: '/' })
|
|
2643
|
+
res.end('Open the device manager from the DSH sidebar.')
|
|
2644
|
+
} else {
|
|
2645
|
+
sendJson(res, 404, { error: 'not-found' })
|
|
2646
|
+
}
|
|
2647
|
+
} catch (error) {
|
|
2648
|
+
log(`mgmt route failed: ${error && error.message ? error.message : String(error)}`)
|
|
2649
|
+
const status = Number.isInteger(error && error.status) ? error.status : (error instanceof TypeError ? 400 : 500)
|
|
2650
|
+
sendJson(res, status, { error: status < 500 ? 'bad-request' : 'internal', message: error && error.message ? error.message : String(error) })
|
|
2651
|
+
}
|
|
2652
|
+
},
|
|
2653
|
+
})
|
|
2654
|
+
log(`management routes registered: /mgw (loopbackOnly=${adminLoopbackOnly})`)
|
|
2655
|
+
|
|
2656
|
+
const handleMobileUpgrade = (req, socket, head, transport = {}) => {
|
|
2657
|
+
if (!gatewayEnabled) {
|
|
2658
|
+
rejectUpgrade(socket, 503, 'mobile gateway is disabled')
|
|
2659
|
+
return
|
|
2660
|
+
}
|
|
2661
|
+
let device
|
|
2662
|
+
let paired
|
|
2663
|
+
const credential = extractCredential(req, !!options.allowQueryToken)
|
|
2664
|
+
if (credential && credential.kind === 'pairing') {
|
|
2665
|
+
const clientDeviceId = extractClientDeviceId(req)
|
|
2666
|
+
if (!clientDeviceId) {
|
|
2667
|
+
log('pairing rejected: missing X-DSH-Device-ID (outdated client)')
|
|
2668
|
+
rejectUpgrade(socket, 400, 'pairing requires X-DSH-Device-ID; update the iOS client')
|
|
2669
|
+
return
|
|
2670
|
+
}
|
|
2671
|
+
paired = registry.claimPairing(credential.value, clientDeviceId)
|
|
2672
|
+
device = paired && paired.device
|
|
2673
|
+
if (!device) {
|
|
2674
|
+
log('pairing rejected: invalid, expired, or already-used code')
|
|
2675
|
+
rejectUpgrade(socket, 401, 'invalid or expired pairing code')
|
|
2676
|
+
return
|
|
2677
|
+
}
|
|
2678
|
+
} else if (credential && credential.kind === 'token') {
|
|
2679
|
+
device = registry.authenticate(credential.value, extractClientDeviceId(req))
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
// The separately exposed LAN listener is always authenticated. The
|
|
2683
|
+
// management UI's debug auth switch only affects the loopback DSH
|
|
2684
|
+
// listener and can never turn a LAN endpoint into an open control
|
|
2685
|
+
// plane.
|
|
2686
|
+
if ((requireAuth || transport.lan === true) && !device) {
|
|
2687
|
+
logAuthRejected(req)
|
|
2688
|
+
rejectUpgrade(socket, 401, 'missing or invalid device credential')
|
|
2689
|
+
return
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
2693
|
+
const id = ++counter
|
|
2694
|
+
ws.filterSessionId = undefined
|
|
2695
|
+
const requestedChannel = req.headers['x-dsh-channel']
|
|
2696
|
+
ws.mobileChannel = ['control', 'conversation'].includes(requestedChannel) ? requestedChannel : 'legacy'
|
|
2697
|
+
ws.deviceId = device && device.id
|
|
2698
|
+
clients.add(ws)
|
|
2699
|
+
if (device) registry.connected(device.id)
|
|
2700
|
+
if (!connectedSinceEnabled) {
|
|
2701
|
+
connectedSinceEnabled = true
|
|
2702
|
+
waitExpiresAt = null
|
|
2703
|
+
if (waitTimer) clearTimeout(waitTimer)
|
|
2704
|
+
waitTimer = null
|
|
2705
|
+
log('mobile gateway wait completed: device connected')
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
const sendFrame = frame => {
|
|
2709
|
+
if (ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2710
|
+
}
|
|
2711
|
+
ws.sessionFollower = createSessionFollower(api, {
|
|
2712
|
+
onFrame(frame, context) {
|
|
2713
|
+
if (frame.type === 'snapshot') {
|
|
2714
|
+
sendFrame({ ...historyPage(frame.history, { sessionId: context.sessionId, view: 'conversation', maxBytes: HISTORY_OPENING_MAX_BYTES }),
|
|
2715
|
+
kind: 'session-snapshot', ...context, assistantStream: frame.assistantStream, replace: true })
|
|
2716
|
+
} else if (frame.type === 'event') {
|
|
2717
|
+
sendFrame({ ...buildWireEvent({ id: context.sessionId }, frame.event), ...context })
|
|
2718
|
+
} else {
|
|
2719
|
+
sendFrame({ kind: 'assistant-stream', ...context, frame: frame.frame })
|
|
2720
|
+
}
|
|
2721
|
+
},
|
|
2722
|
+
onError(error, context) {
|
|
2723
|
+
sendFrame({ kind: 'session-stream-reset', ...context,
|
|
2724
|
+
code: error.code || 'stream-interrupted', message: error.message })
|
|
2725
|
+
},
|
|
2726
|
+
})
|
|
2727
|
+
|
|
2728
|
+
ws.on('message', (data) => {
|
|
2729
|
+
let msg
|
|
2730
|
+
try {
|
|
2731
|
+
msg = JSON.parse(data.toString())
|
|
2732
|
+
} catch (error) {
|
|
2733
|
+
ws.send(stringifyWireFrame({ kind: 'error', message: 'invalid json' }))
|
|
2734
|
+
return
|
|
2735
|
+
}
|
|
2736
|
+
if (!msg || typeof msg.type !== 'string') return
|
|
2737
|
+
const conversationRequest = ['message', 'history', 'subscribe', 'unsubscribe'].includes(msg.type)
|
|
2738
|
+
if ((ws.mobileChannel === 'control' && conversationRequest) ||
|
|
2739
|
+
(ws.mobileChannel === 'conversation' && !conversationRequest && msg.type !== 'ping')) {
|
|
2740
|
+
ws.send(stringifyWireFrame({ kind: 'error', code: 'wrong-channel', requestType: msg.type, message: 'Request belongs to the other mobile channel' }))
|
|
2741
|
+
return
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
if (msg.type === 'ping') {
|
|
2745
|
+
ws.send(stringifyWireFrame({ kind: 'pong', at: Date.now() }))
|
|
2746
|
+
} else if (msg.type === 'subscribe') {
|
|
2747
|
+
if (msg.assistantStream !== undefined && typeof msg.assistantStream !== 'boolean') {
|
|
2748
|
+
sendFrame({ kind: 'error', code: 'bad-request', requestType: 'subscribe', message: 'assistantStream must be a boolean' })
|
|
2749
|
+
return
|
|
2750
|
+
}
|
|
2751
|
+
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : undefined
|
|
2752
|
+
if (msg.assistantStream === true && !sessionId) {
|
|
2753
|
+
sendFrame({ kind: 'error', code: 'bad-request', requestType: 'subscribe', message: 'assistantStream requires a sessionId' })
|
|
2754
|
+
return
|
|
2755
|
+
}
|
|
2756
|
+
ws.sessionFollower.stop()
|
|
2757
|
+
ws.filterSessionId = sessionId
|
|
2758
|
+
ws.sessionFollowerActive = msg.assistantStream === true
|
|
2759
|
+
const subscriptionId = crypto.randomUUID()
|
|
2760
|
+
sendFrame({ kind: 'subscribed', sessionId: sessionId || null, subscriptionId, assistantStream: ws.sessionFollowerActive })
|
|
2761
|
+
if (ws.sessionFollowerActive) {
|
|
2762
|
+
void ws.sessionFollower.start(sessionId, subscriptionId).catch(error => {
|
|
2763
|
+
log(`session follower failed: ${error.message}`)
|
|
2764
|
+
if (ws.readyState === 1) ws.close(1011, 'session follower failed')
|
|
2765
|
+
})
|
|
2766
|
+
}
|
|
2767
|
+
replayPendingInteractions(ws, 'subscribe')
|
|
2768
|
+
} else if (msg.type === 'unsubscribe') {
|
|
2769
|
+
ws.sessionFollower.stop()
|
|
2770
|
+
ws.sessionFollowerActive = false
|
|
2771
|
+
ws.filterSessionId = undefined
|
|
2772
|
+
sendFrame({ kind: 'subscribed', sessionId: null, assistantStream: false })
|
|
2773
|
+
} else if (msg.type === 'question-answer' || msg.type === 'question-cancel') {
|
|
2774
|
+
respondToQuestion(msg, msg.type === 'question-cancel').then((frame) => {
|
|
2775
|
+
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2776
|
+
})
|
|
2777
|
+
} else if (msg.type === 'approval-response') {
|
|
2778
|
+
respondToApproval(msg).then((frame) => {
|
|
2779
|
+
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2780
|
+
})
|
|
2781
|
+
} else if (msg.type === 'file-list' || msg.type === 'file-download-open' ||
|
|
2782
|
+
msg.type === 'file-download-read' || msg.type === 'file-download-cancel') {
|
|
2783
|
+
fileTransfers.handle(ws, msg).then((frame) => {
|
|
2784
|
+
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2785
|
+
})
|
|
2786
|
+
} else if (msg.type === 'message') {
|
|
2787
|
+
admitMessage(api, msg).then((frame) => {
|
|
2788
|
+
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2789
|
+
})
|
|
2790
|
+
} else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' || msg.type === 'attachment' ||
|
|
2791
|
+
msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' || msg.type === 'directory-create' ||
|
|
2792
|
+
msg.type === 'workspace-create' || msg.type === 'session-create' || msg.type === 'models' || msg.type === 'commands' || msg.type === 'command-execute' ||
|
|
2793
|
+
msg.type === 'command-options' || msg.type === 'command-select' || msg.type === 'select-model' ||
|
|
2794
|
+
msg.type === 'permission-options' || msg.type === 'permission' || msg.type === 'context-usage' ||
|
|
2795
|
+
msg.type === 'agent-presets' || msg.type === 'session-agent-preset' || msg.type === 'select-agent-preset' ||
|
|
2796
|
+
msg.type === 'defaults' || msg.type === 'set-default' ||
|
|
2797
|
+
msg.type === 'session-stats' || msg.type === 'default-model' ||
|
|
2798
|
+
msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'session-cancel' || msg.type === 'queue-update' ||
|
|
2799
|
+
msg.type === 'session-archive' || msg.type === 'session-rename' || msg.type === 'providers' ||
|
|
2800
|
+
msg.type === 'tasks' || msg.type === 'goal' || msg.type === 'goal-edit' ||
|
|
2801
|
+
msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear') {
|
|
2802
|
+
handleQuery(api, host, agentDefaultModel, msg).then((frame) => {
|
|
2803
|
+
if (frame && ws.readyState === 1) ws.send(stringifyWireFrame(frame))
|
|
2804
|
+
})
|
|
2805
|
+
} else {
|
|
2806
|
+
ws.send(stringifyWireFrame({ kind: 'error', message: 'unknown message type: ' + msg.type }))
|
|
2807
|
+
}
|
|
2808
|
+
})
|
|
2809
|
+
|
|
2810
|
+
ws.on('close', () => {
|
|
2811
|
+
ws.sessionFollower.stop()
|
|
2812
|
+
clients.delete(ws)
|
|
2813
|
+
if (ws.deviceId) registry.disconnected(ws.deviceId)
|
|
2814
|
+
releaseUnclaimedInteractions()
|
|
2815
|
+
fileTransfers.closeClient(ws).catch((error) => {
|
|
2816
|
+
log(`file download cleanup failed: ${error && error.message ? error.message : String(error)}`)
|
|
2817
|
+
})
|
|
2818
|
+
log(`client disconnected (id=${id}, remaining=${clients.size})`)
|
|
2819
|
+
})
|
|
2820
|
+
|
|
2821
|
+
log(`client connected (id=${id}, total=${clients.size})${device ? ' device=' + device.name : ''}`)
|
|
2822
|
+
if (paired) {
|
|
2823
|
+
ws.send(stringifyWireFrame({
|
|
2824
|
+
kind: 'paired',
|
|
2825
|
+
token: paired.token,
|
|
2826
|
+
device: paired.device,
|
|
2827
|
+
...gatewayIdentity,
|
|
2828
|
+
}))
|
|
2829
|
+
}
|
|
2830
|
+
ws.send(stringifyWireFrame({
|
|
2831
|
+
kind: 'hello',
|
|
2832
|
+
...gatewayIdentity,
|
|
2833
|
+
protocol: 3,
|
|
2834
|
+
dshVersion: DSH_VERSION,
|
|
2835
|
+
historyFormatVersion: SESSION_FORMAT_VERSION,
|
|
2836
|
+
capabilities: [
|
|
2837
|
+
'split-channels',
|
|
2838
|
+
'assistant-stream-v1',
|
|
2839
|
+
'history-format-version',
|
|
2840
|
+
'projection-baseline',
|
|
2841
|
+
'images',
|
|
2842
|
+
'session-create',
|
|
2843
|
+
'session-agent-preset',
|
|
2844
|
+
'commands',
|
|
2845
|
+
'tasks',
|
|
2846
|
+
'goals',
|
|
2847
|
+
'session-cancel',
|
|
2848
|
+
'queue-control',
|
|
2849
|
+
'session-archive',
|
|
2850
|
+
'session-rename',
|
|
2851
|
+
...(options.fileDownloadsEnabled ? ['file-downloads'] : []),
|
|
2852
|
+
],
|
|
2853
|
+
port: transport.port || webServer.port,
|
|
2854
|
+
clients: clients.size,
|
|
2855
|
+
authenticated: !!device,
|
|
2856
|
+
...(device ? { device: { id: device.id, name: device.name } } : {}),
|
|
2857
|
+
}))
|
|
2858
|
+
if (ws.mobileChannel !== 'conversation' && archivedSessionIds !== null) {
|
|
2859
|
+
ws.send(stringifyWireFrame({ kind: 'session-archives', archivedSessionIds }))
|
|
2860
|
+
}
|
|
2861
|
+
if (ws.mobileChannel !== 'conversation' && sessionQueues !== null) {
|
|
2862
|
+
ws.send(stringifyWireFrame({ kind: 'session-queues', queues: Object.fromEntries(sessionQueues) }))
|
|
2863
|
+
}
|
|
2864
|
+
if (ws.mobileChannel !== 'conversation') {
|
|
2865
|
+
sendFrame({ kind: 'projection-baseline', projections: sessionProjectionBaselines })
|
|
2866
|
+
}
|
|
2867
|
+
replayPendingInteractions(ws, 'connect')
|
|
2868
|
+
})
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
const disposeUpgrade = webServer.registerUpgrade({
|
|
2872
|
+
path: wsPath,
|
|
2873
|
+
handler(req, socket, head) {
|
|
2874
|
+
handleMobileUpgrade(req, socket, head, { port: webServer.port })
|
|
2875
|
+
},
|
|
2876
|
+
})
|
|
2877
|
+
log(`upgrade route registered: ${wsPath}`)
|
|
2878
|
+
|
|
2879
|
+
// DSH intentionally keeps its own WebUI listener on loopback. When LAN
|
|
2880
|
+
// mode is enabled, expose a second, narrowly scoped listener that accepts
|
|
2881
|
+
// only the authenticated mobile WebSocket path. It does not serve the
|
|
2882
|
+
// WebUI or any /mgw management route.
|
|
2883
|
+
if (options.lanEnabled === true) {
|
|
2884
|
+
lanServer = http.createServer((req, res) => {
|
|
2885
|
+
sendJson(res, 404, { error: 'not-found' })
|
|
2886
|
+
})
|
|
2887
|
+
lanServer.on('upgrade', (req, socket, head) => {
|
|
2888
|
+
let pathname
|
|
2889
|
+
try {
|
|
2890
|
+
pathname = new URL(req.url || '/', 'http://lan.invalid').pathname
|
|
2891
|
+
} catch (error) {
|
|
2892
|
+
rejectUpgrade(socket, 400, 'invalid WebSocket path')
|
|
2893
|
+
return
|
|
2894
|
+
}
|
|
2895
|
+
if (pathname !== wsPath) {
|
|
2896
|
+
rejectUpgrade(socket, 404, 'not found')
|
|
2897
|
+
return
|
|
2898
|
+
}
|
|
2899
|
+
if (!req.socket || !req.socket.remoteAddress || !isPrivateNetworkHostname(req.socket.remoteAddress)) {
|
|
2900
|
+
rejectUpgrade(socket, 403, 'LAN listener accepts private-network clients only')
|
|
2901
|
+
return
|
|
2902
|
+
}
|
|
2903
|
+
handleMobileUpgrade(req, socket, head, { lan: true, port: lanBoundPort })
|
|
2904
|
+
})
|
|
2905
|
+
lanServer.on('error', (error) => {
|
|
2906
|
+
lanListening = false
|
|
2907
|
+
lanListenError = error && error.message ? error.message : String(error)
|
|
2908
|
+
log(`LAN listener failed: ${lanListenError}`)
|
|
2909
|
+
})
|
|
2910
|
+
lanServer.listen(options.lanPort, options.lanHost, () => {
|
|
2911
|
+
lanListening = true
|
|
2912
|
+
lanListenError = null
|
|
2913
|
+
const address = lanServer.address()
|
|
2914
|
+
if (address && typeof address === 'object') lanBoundPort = address.port
|
|
2915
|
+
const urls = lanWebSocketUrls(options, wsPath, lanBoundPort)
|
|
2916
|
+
log(`LAN listener ready: ${options.lanHost}:${lanBoundPort}${wsPath}${urls.length ? ` (${urls.join(', ')})` : ''}; authentication forced`)
|
|
2917
|
+
})
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
const disposeEvents = ctx.on('session/event', (session, event) => {
|
|
2921
|
+
api.observeSessionEvent(String(session.id), event)
|
|
2922
|
+
if (clients.size === 0) return
|
|
2923
|
+
const wire = buildWireEvent(session, event)
|
|
2924
|
+
if (!wire) return
|
|
2925
|
+
if (event.type === 'session/title' && typeof event.data?.title === 'string') {
|
|
2926
|
+
broadcastSessionMetadataFrame({
|
|
2927
|
+
kind: 'session-title-changed',
|
|
2928
|
+
sessionId: String(session.id),
|
|
2929
|
+
title: event.data.title,
|
|
2930
|
+
seq: event.seq,
|
|
2931
|
+
time: event.time,
|
|
2932
|
+
...(event.data.source ? { source: event.data.source } : {}),
|
|
2933
|
+
})
|
|
2934
|
+
}
|
|
2935
|
+
if (event.type === 'agent-preset/selected' || event.type === 'turn/start') {
|
|
2936
|
+
broadcastSessionMetadataFrame({
|
|
2937
|
+
kind: 'session-agent-preset-updated', sessionId: String(session.id), seq: event.seq, time: event.time,
|
|
2938
|
+
...(event.type === 'turn/start' ? { locked: true } : { agentPreset: event.data.agentPreset }),
|
|
2939
|
+
})
|
|
2940
|
+
}
|
|
2941
|
+
const payload = stringifyWireFrame(wire)
|
|
2942
|
+
for (const client of clients) {
|
|
2943
|
+
if (client.mobileChannel === 'control' || client.sessionFollowerActive) continue
|
|
2944
|
+
if (client.filterSessionId && client.filterSessionId !== String(session.id)) continue
|
|
2945
|
+
if (client.readyState === 1) client.send(payload)
|
|
2946
|
+
}
|
|
2947
|
+
})
|
|
2948
|
+
log('session/event listener attached')
|
|
2949
|
+
|
|
2950
|
+
const disposeQuestions = ctx.on('user-questions/request', (request, next) => {
|
|
2951
|
+
const sessionId = request?.agent?.id === undefined ? null : String(request.agent.id)
|
|
2952
|
+
if (!sessionId || !Array.isArray(request.questions) || !hasInteractionClient(sessionId)) return next()
|
|
2953
|
+
const rpcId = crypto.randomUUID()
|
|
2954
|
+
return new Promise((resolve, reject) => {
|
|
2955
|
+
const requestSignal = request.signal
|
|
2956
|
+
const downstreamAbort = new AbortController()
|
|
2957
|
+
request.signal = requestSignal
|
|
2958
|
+
? AbortSignal.any([requestSignal, downstreamAbort.signal])
|
|
2959
|
+
: downstreamAbort.signal
|
|
2960
|
+
const onAbort = () => {
|
|
2961
|
+
const pending = pendingQuestions.get(rpcId)
|
|
2962
|
+
if (pending) completeQuestion(pending, 'cancel', undefined, 'ASK_ABORTED')
|
|
2963
|
+
}
|
|
2964
|
+
requestSignal?.addEventListener('abort', onAbort, { once: true })
|
|
2965
|
+
const pending = {
|
|
2966
|
+
rpcId,
|
|
2967
|
+
sessionId,
|
|
2968
|
+
questions: request.questions,
|
|
2969
|
+
next,
|
|
2970
|
+
downstreamAbort,
|
|
2971
|
+
resolve,
|
|
2972
|
+
reject,
|
|
2973
|
+
cleanup: () => requestSignal?.removeEventListener('abort', onAbort),
|
|
2974
|
+
}
|
|
2975
|
+
pendingQuestions.set(rpcId, pending)
|
|
2976
|
+
broadcastInteractionFrame(questionFrameFor(rpcId, pending))
|
|
2977
|
+
startDownstreamQuestion(pending)
|
|
2978
|
+
log(`question requested: rpcId=${rpcId} session=${sessionId} questions=${request.questions.length}`)
|
|
2979
|
+
})
|
|
2980
|
+
}, { prepend: true })
|
|
2981
|
+
|
|
2982
|
+
const disposeApprovals = ctx.on('approval/request', (request, next) => {
|
|
2983
|
+
const sessionId = request?.agent?.id === undefined ? null : String(request.agent.id)
|
|
2984
|
+
if (!sessionId || typeof request.toolName !== 'string' || !hasInteractionClient(sessionId)) return next()
|
|
2985
|
+
const rpcId = crypto.randomUUID()
|
|
2986
|
+
const approvalId = crypto.randomUUID()
|
|
2987
|
+
return new Promise((resolve, reject) => {
|
|
2988
|
+
const requestSignal = request.signal
|
|
2989
|
+
const downstreamAbort = new AbortController()
|
|
2990
|
+
request.signal = requestSignal
|
|
2991
|
+
? AbortSignal.any([requestSignal, downstreamAbort.signal])
|
|
2992
|
+
: downstreamAbort.signal
|
|
2993
|
+
const onAbort = () => {
|
|
2994
|
+
const pending = pendingApprovals.get(rpcId)
|
|
2995
|
+
if (pending) completeApproval(pending, 'cancelled')
|
|
2996
|
+
}
|
|
2997
|
+
requestSignal?.addEventListener('abort', onAbort, { once: true })
|
|
2998
|
+
const pending = {
|
|
2999
|
+
rpcId,
|
|
3000
|
+
sessionId,
|
|
3001
|
+
approvalId,
|
|
3002
|
+
toolName: request.toolName,
|
|
3003
|
+
...(request.callId !== undefined ? { callId: request.callId } : {}),
|
|
3004
|
+
...(request.reason !== undefined ? { reason: request.reason } : {}),
|
|
3005
|
+
next,
|
|
3006
|
+
downstreamAbort,
|
|
3007
|
+
resolve,
|
|
3008
|
+
reject,
|
|
3009
|
+
cleanup: () => requestSignal?.removeEventListener('abort', onAbort),
|
|
3010
|
+
}
|
|
3011
|
+
pendingApprovals.set(rpcId, pending)
|
|
3012
|
+
broadcastInteractionFrame(approvalFrameFor(rpcId, pending))
|
|
3013
|
+
startDownstreamApproval(pending)
|
|
3014
|
+
log(`approval requested: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} tool=${request.toolName}`)
|
|
3015
|
+
})
|
|
3016
|
+
}, { prepend: true })
|
|
3017
|
+
|
|
3018
|
+
const waitForBackgroundRetry = () => new Promise((resolve) => {
|
|
3019
|
+
if (backgroundStreamAbort.signal.aborted) {
|
|
3020
|
+
resolve()
|
|
3021
|
+
return
|
|
3022
|
+
}
|
|
3023
|
+
const finish = () => {
|
|
3024
|
+
clearTimeout(timer)
|
|
3025
|
+
backgroundStreamAbort.signal.removeEventListener('abort', finish)
|
|
3026
|
+
resolve()
|
|
3027
|
+
}
|
|
3028
|
+
const timer = setTimeout(finish, 1_000)
|
|
3029
|
+
backgroundStreamAbort.signal.addEventListener('abort', finish, { once: true })
|
|
3030
|
+
})
|
|
3031
|
+
|
|
3032
|
+
const installArchivedSessionIds = (value) => {
|
|
3033
|
+
if (!Array.isArray(value)) throw new Error('workspace/follow returned invalid archivedSessionIds')
|
|
3034
|
+
const next = value.map((sessionId) => String(sessionId))
|
|
3035
|
+
if (archivedSessionIds !== null
|
|
3036
|
+
&& archivedSessionIds.length === next.length
|
|
3037
|
+
&& archivedSessionIds.every((sessionId, index) => sessionId === next[index])) return
|
|
3038
|
+
archivedSessionIds = next
|
|
3039
|
+
broadcastSessionMetadataFrame({ kind: 'session-archives', archivedSessionIds: next })
|
|
3040
|
+
log(`session archive state forwarded: count=${next.length}`)
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
const normalizeQueueItems = (value, endpoint) => {
|
|
3044
|
+
if (!Array.isArray(value)) throw new Error(`${endpoint} returned invalid queue items`)
|
|
3045
|
+
return value.map((candidate) => {
|
|
3046
|
+
const message = candidate && typeof candidate === 'object' && !Array.isArray(candidate)
|
|
3047
|
+
? candidate.message
|
|
3048
|
+
: null
|
|
3049
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)
|
|
3050
|
+
|| typeof candidate.id !== 'string' || !candidate.id
|
|
3051
|
+
|| !['queued', 'steering', 'context'].includes(candidate.placement)
|
|
3052
|
+
|| !message || typeof message !== 'object' || Array.isArray(message)
|
|
3053
|
+
|| typeof message.id !== 'string' || !message.id
|
|
3054
|
+
|| !Array.isArray(message.content)
|
|
3055
|
+
|| (candidate.rpcId !== undefined && typeof candidate.rpcId !== 'string')) {
|
|
3056
|
+
throw new Error(`${endpoint} returned an invalid queue item`)
|
|
3057
|
+
}
|
|
3058
|
+
return {
|
|
3059
|
+
id: candidate.id,
|
|
3060
|
+
placement: candidate.placement,
|
|
3061
|
+
...(candidate.rpcId === undefined ? {} : { rpcId: candidate.rpcId }),
|
|
3062
|
+
message: { id: message.id, content: message.content },
|
|
3063
|
+
}
|
|
3064
|
+
})
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
const installSessionQueueBaseline = (value) => {
|
|
3068
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
3069
|
+
throw new Error('session/control baseline returned invalid queues')
|
|
3070
|
+
}
|
|
3071
|
+
const next = new Map()
|
|
3072
|
+
for (const [sessionId, items] of Object.entries(value)) {
|
|
3073
|
+
next.set(sessionId, normalizeQueueItems(items, 'session/control baseline'))
|
|
3074
|
+
}
|
|
3075
|
+
sessionQueues = next
|
|
3076
|
+
broadcastSessionMetadataFrame({ kind: 'session-queues', queues: Object.fromEntries(next) })
|
|
3077
|
+
log(`session queue baseline forwarded: sessions=${next.size}`)
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
const installSessionQueue = (sessionIdValue, itemsValue) => {
|
|
3081
|
+
const sessionId = typeof sessionIdValue === 'string' && sessionIdValue ? sessionIdValue : null
|
|
3082
|
+
if (!sessionId) throw new Error('session/control returned an invalid queue sessionId')
|
|
3083
|
+
const items = normalizeQueueItems(itemsValue, 'session/control queue')
|
|
3084
|
+
if (sessionQueues === null) sessionQueues = new Map()
|
|
3085
|
+
sessionQueues.set(sessionId, items)
|
|
3086
|
+
broadcastSessionMetadataFrame({ kind: 'session-queue', sessionId, items })
|
|
3087
|
+
log(`session queue forwarded: session=${sessionId} items=${items.length}`)
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
const projectionFrame = (sessionId, key, value, seq) => ({
|
|
3091
|
+
kind: key === 'todos' ? 'tasks-updated' : 'goal-updated', sessionId, asOfSeq: seq,
|
|
3092
|
+
[key]: value,
|
|
3093
|
+
})
|
|
3094
|
+
|
|
3095
|
+
const installProjectionBaseline = value => {
|
|
3096
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid projection baseline')
|
|
3097
|
+
const next = {}
|
|
3098
|
+
for (const [sessionId, baseline] of Object.entries(value)) {
|
|
3099
|
+
if (!Number.isSafeInteger(baseline?.asOfSeq) || !baseline.values || typeof baseline.values !== 'object' || Array.isArray(baseline.values)) {
|
|
3100
|
+
throw new Error('invalid session projection baseline')
|
|
3101
|
+
}
|
|
3102
|
+
Object.defineProperty(next, sessionId, { enumerable: true, configurable: true, writable: true,
|
|
3103
|
+
value: { asOfSeq: baseline.asOfSeq, values: { todos: baseline.values.todos ?? null, goal: baseline.values.goal ?? null } } })
|
|
3104
|
+
}
|
|
3105
|
+
const previous = sessionProjectionBaselines
|
|
3106
|
+
sessionProjectionBaselines = next
|
|
3107
|
+
broadcastSessionMetadataFrame({ kind: 'projection-baseline', projections: next })
|
|
3108
|
+
for (const sessionId of new Set([...Object.keys(previous), ...Object.keys(next)])) {
|
|
3109
|
+
const baseline = Object.hasOwn(next, sessionId) ? next[sessionId] : null
|
|
3110
|
+
for (const key of ['todos', 'goal']) {
|
|
3111
|
+
broadcastInteractionFrame(projectionFrame(sessionId, key, baseline?.values[key] ?? null,
|
|
3112
|
+
baseline?.asOfSeq ?? previous[sessionId].asOfSeq))
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
const workspaceTask = (async () => {
|
|
3118
|
+
while (!backgroundStreamAbort.signal.aborted) {
|
|
3119
|
+
try {
|
|
3120
|
+
const stream = await api.openWorkspaceStream(backgroundStreamAbort.signal)
|
|
3121
|
+
for await (const frame of stream) {
|
|
3122
|
+
if (frame?.type === 'baseline') {
|
|
3123
|
+
installArchivedSessionIds(frame.value?.archivedSessionIds)
|
|
3124
|
+
} else if (frame?.type === 'archived') {
|
|
3125
|
+
installArchivedSessionIds(frame.archivedSessionIds)
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
if (!backgroundStreamAbort.signal.aborted) log('workspace stream ended; retrying')
|
|
3129
|
+
} catch (error) {
|
|
3130
|
+
if (!backgroundStreamAbort.signal.aborted) {
|
|
3131
|
+
log(`workspace stream failed; retrying: ${error && error.message ? error.message : String(error)}`)
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
if (!backgroundStreamAbort.signal.aborted) await waitForBackgroundRetry()
|
|
3135
|
+
}
|
|
3136
|
+
})()
|
|
3137
|
+
void workspaceTask
|
|
3138
|
+
|
|
3139
|
+
const controlTask = (async () => {
|
|
3140
|
+
while (!backgroundStreamAbort.signal.aborted) {
|
|
3141
|
+
try {
|
|
3142
|
+
const stream = await api.openControlStream(backgroundStreamAbort.signal)
|
|
3143
|
+
for await (const frame of stream) {
|
|
3144
|
+
if (frame?.type === 'baseline') {
|
|
3145
|
+
installProjectionBaseline(frame.value?.projections)
|
|
3146
|
+
installSessionQueueBaseline(frame.value?.queues)
|
|
3147
|
+
} else if (frame?.type === 'queue') {
|
|
3148
|
+
installSessionQueue(frame.sessionId, frame.items)
|
|
3149
|
+
} else if (frame?.type === 'projection' && (frame.key === 'todos' || frame.key === 'goal')) {
|
|
3150
|
+
const sessionId = String(frame.sessionId)
|
|
3151
|
+
const previous = Object.hasOwn(sessionProjectionBaselines, sessionId) ? sessionProjectionBaselines[sessionId] : null
|
|
3152
|
+
Object.defineProperty(sessionProjectionBaselines, sessionId, { enumerable: true, configurable: true, writable: true,
|
|
3153
|
+
value: { asOfSeq: frame.seq, values: { ...previous?.values, [frame.key]: frame.value } } })
|
|
3154
|
+
broadcastInteractionFrame(projectionFrame(sessionId, frame.key, frame.value, frame.seq))
|
|
3155
|
+
log(`projection forwarded: key=${frame.key} session=${sessionId} seq=${frame.seq}`)
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
if (!backgroundStreamAbort.signal.aborted) log('session control stream ended; retrying')
|
|
3159
|
+
} catch (error) {
|
|
3160
|
+
if (!backgroundStreamAbort.signal.aborted) {
|
|
3161
|
+
log(`session control stream failed; retrying: ${error && error.message ? error.message : String(error)}`)
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
if (!backgroundStreamAbort.signal.aborted) await waitForBackgroundRetry()
|
|
3165
|
+
}
|
|
3166
|
+
})()
|
|
3167
|
+
void controlTask
|
|
3168
|
+
log('Host waterfall interaction listeners, workspace stream, and session control stream attached')
|
|
3169
|
+
|
|
3170
|
+
ctx.effect(() => () => {
|
|
3171
|
+
if (waitTimer) clearTimeout(waitTimer)
|
|
3172
|
+
clearInterval(fileTransferExpiryTimer)
|
|
3173
|
+
backgroundStreamAbort.abort()
|
|
3174
|
+
for (const pending of [...pendingQuestions.values()]) fallbackQuestion(pending)
|
|
3175
|
+
for (const pending of [...pendingApprovals.values()]) fallbackApproval(pending)
|
|
3176
|
+
fileTransfers.dispose().catch((error) => {
|
|
3177
|
+
log(`file download disposal failed: ${error && error.message ? error.message : String(error)}`)
|
|
3178
|
+
})
|
|
3179
|
+
disposeUpgrade()
|
|
3180
|
+
disposeMgmt()
|
|
3181
|
+
disposeEvents()
|
|
3182
|
+
disposeQuestions()
|
|
3183
|
+
disposeApprovals()
|
|
3184
|
+
if (lanServer) lanServer.close()
|
|
3185
|
+
for (const client of clients) {
|
|
3186
|
+
client.sessionFollower?.stop()
|
|
3187
|
+
client.terminate()
|
|
3188
|
+
}
|
|
3189
|
+
clients.clear()
|
|
3190
|
+
wss.close()
|
|
3191
|
+
log('plugin stopped, all sockets closed')
|
|
3192
|
+
})
|
|
3193
|
+
},
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
export { Config, admitMessage, handleQuery }
|
|
3197
|
+
export default plugin
|