dsh-mizar 0.8.7 → 0.8.8
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/README.md +16 -0
- package/lib/bridge/index.d.ts +29 -0
- package/lib/bridge/index.js +58 -3
- package/lib/bridge/private-directory.d.ts +12 -0
- package/lib/bridge/private-directory.js +97 -0
- package/lib/bridge/sessions.d.ts +5 -1
- package/lib/bridge/sessions.js +28 -8
- package/lib/bridge/uploads.d.ts +88 -0
- package/lib/bridge/uploads.js +299 -18
- package/lib/config.d.ts +10 -0
- package/lib/config.js +13 -0
- package/lib/index.js +122 -15
- package/lib/protocol/frames.d.ts +1 -1
- package/lib/protocol/frames.js +26 -0
- package/lib/relay/client.js +13 -0
- package/lib/relay/selection.d.ts +53 -0
- package/lib/relay/selection.js +128 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -143,6 +143,22 @@ DSH 自己的 Web 接口暴露出去(那是 DSH 的安全边界,插件不碰
|
|
|
143
143
|
日志里对应 `relay: the tunnel has been silent for …s — redialling it`、
|
|
144
144
|
`relay: the network changed — redialling the tunnel` 和 `relay: the listener moved to port …`。
|
|
145
145
|
|
|
146
|
+
### 付费账户的高带宽中继(0.8.8)
|
|
147
|
+
|
|
148
|
+
付费账户的隧道现在会跟着订阅走:App 知道账户是否付费,插件不知道,所以由 App 调
|
|
149
|
+
`relay.set` 请求电脑把隧道移到账号中继(`dshmizar-1.shirazu-nagisa.com`);免费账户
|
|
150
|
+
仍走 `dshmizar.shirazu-nagisa.com`,登录/登出或订阅到期时会自动切回。
|
|
151
|
+
|
|
152
|
+
- 隧道令牌不变 ⇒ 房间不变,**已配对关系不受影响**,只是断开重拨。
|
|
153
|
+
- 选择持久化在插件状态目录(`relay-override.json`,600),重启后继续生效。
|
|
154
|
+
- 插件只接受两个公共中继,自建 `relay.url` 时该能力不出现;旧宿主回 `NOT_IMPLEMENTED`,
|
|
155
|
+
旧客户端不会调用,行为完全不变。
|
|
156
|
+
- 切换前插件广播 `relay.changed`,其它已配对设备会更新保存的中继地址;离线的手机下次
|
|
157
|
+
连接时会用同一房间尝试另一个公共中继自愈。
|
|
158
|
+
- 连接面板里的「高带宽服务器 / 免费版服务器」现在按**实际所在中继**显示,不再只看账户等级。
|
|
159
|
+
|
|
160
|
+
协议细节(`relay.set`、`host.info.relay`、`relay.changed`)见 `docs/protocol-v1.md`。
|
|
161
|
+
|
|
146
162
|
### 对话只传总结
|
|
147
163
|
|
|
148
164
|
一页历史里只保留这些:你的提问、每一轮的 `turn/start` 和 `turn/end`、每个 `step/start`
|
package/lib/bridge/index.d.ts
CHANGED
|
@@ -14,6 +14,27 @@ export interface MethodContext {
|
|
|
14
14
|
}
|
|
15
15
|
/** A wire method implementation. */
|
|
16
16
|
export type MethodHandler = (payload: unknown, context: MethodContext) => Promise<unknown>;
|
|
17
|
+
/**
|
|
18
|
+
* Where this computer's tunnel currently points, as a phone reads it.
|
|
19
|
+
*
|
|
20
|
+
* `baseURL` is the address a phone should dial for this room (the relay origin
|
|
21
|
+
* plus `/r/<room>`), and is `null` until the relay has assigned a room.
|
|
22
|
+
*/
|
|
23
|
+
export interface HostRelayState {
|
|
24
|
+
/** The canonical relay origin the tunnel dials. */
|
|
25
|
+
readonly url: string;
|
|
26
|
+
/** The room the relay assigned, once connected. */
|
|
27
|
+
readonly room: string | null;
|
|
28
|
+
/** The base URL a phone should dial, or `null` before the first welcome. */
|
|
29
|
+
readonly baseURL: string | null;
|
|
30
|
+
}
|
|
31
|
+
/** Move this computer's tunnel between the public relays, or report where it is. */
|
|
32
|
+
export interface HostRelayControl {
|
|
33
|
+
/** Read the current relay facts. */
|
|
34
|
+
current(): HostRelayState;
|
|
35
|
+
/** Remember and apply a new relay; the caller has already checked the allowlist. */
|
|
36
|
+
set(url: string): Promise<HostRelayState>;
|
|
37
|
+
}
|
|
17
38
|
/** What the bridge needs from its host to answer `host.info`. */
|
|
18
39
|
export interface HostFacts {
|
|
19
40
|
readonly hostId: string;
|
|
@@ -35,6 +56,14 @@ export interface HostFacts {
|
|
|
35
56
|
* the second justifies a phone giving up on the direct route.
|
|
36
57
|
*/
|
|
37
58
|
readonly authorities?: () => readonly string[];
|
|
59
|
+
/**
|
|
60
|
+
* Relay control, when this host may be moved between the two public relays.
|
|
61
|
+
*
|
|
62
|
+
* Absent for a self-hosted relay: there is no account tier to move between,
|
|
63
|
+
* so `relay.set` is not in the method table and an old-style host answers
|
|
64
|
+
* `NOT_IMPLEMENTED` exactly as it would for any other method it does not know.
|
|
65
|
+
*/
|
|
66
|
+
readonly relay?: HostRelayControl;
|
|
38
67
|
}
|
|
39
68
|
/**
|
|
40
69
|
* Build the method table for one plugin mount.
|
package/lib/bridge/index.js
CHANGED
|
@@ -21,7 +21,8 @@ import { modelCatalog, selectModel } from './models.js';
|
|
|
21
21
|
import { permissionCatalog, setPermissionPreset } from './permissions.js';
|
|
22
22
|
import { archiveSession, listWorkspaces } from './workspaces.js';
|
|
23
23
|
import { cancelSession, createSession, listSessions, promptSession, renameSession, sessionActivity, sessionMessages, sessionStats, } from './sessions.js';
|
|
24
|
-
import { parseUploadBegin, parseUploadChunk, UploadStore } from './uploads.js';
|
|
24
|
+
import { parseUploadBegin, parseUploadChunk, PromptReplayGuard, UploadStore } from './uploads.js';
|
|
25
|
+
import { canonicalRelayURL, isSelectableRelayURL } from '../relay/selection.js';
|
|
25
26
|
/**
|
|
26
27
|
* Translate any thrown value into a wire-coded failure.
|
|
27
28
|
* @param handler - the raw implementation.
|
|
@@ -288,6 +289,13 @@ export function createMethodTable(ctx, facts, broker, push, delivery, uploads) {
|
|
|
288
289
|
root: join(facts.home, 'uploads'),
|
|
289
290
|
attachmentsRoot: join(facts.home, 'attachments'),
|
|
290
291
|
});
|
|
292
|
+
// A prompt that carries attachments is retried with the same requestId when
|
|
293
|
+
// its answer is lost. That is safe only while the request is identical: DSH
|
|
294
|
+
// deduplicates by (session, requestId), so the same key with different
|
|
295
|
+
// content must be refused before it can borrow the first answer. Ported
|
|
296
|
+
// from XEPX2's PR #2.
|
|
297
|
+
const replayGuard = new PromptReplayGuard();
|
|
298
|
+
const relay = facts.relay;
|
|
291
299
|
const table = {
|
|
292
300
|
/**
|
|
293
301
|
* Host identity and protocol facts.
|
|
@@ -306,7 +314,32 @@ export function createMethodTable(ctx, facts, broker, push, delivery, uploads) {
|
|
|
306
314
|
home: facts.home,
|
|
307
315
|
protocolVersion: facts.protocolVersion,
|
|
308
316
|
...(facts.authorities === undefined ? {} : { addresses: facts.authorities() }),
|
|
317
|
+
...(relay === undefined ? {} : { relay: relay.current() }),
|
|
309
318
|
}),
|
|
319
|
+
/**
|
|
320
|
+
* Move this computer's tunnel to the free or the paid relay.
|
|
321
|
+
*
|
|
322
|
+
* The phone asks because only the phone knows the account tier; the host
|
|
323
|
+
* owns the allowlist and the persistence so a paired device cannot point
|
|
324
|
+
* this computer's tunnel at an arbitrary server. The tunnel credential is
|
|
325
|
+
* unchanged, so the room is unchanged and every existing pairing survives;
|
|
326
|
+
* what changes is the address a phone dials, and the answer carries it.
|
|
327
|
+
*
|
|
328
|
+
* Additive, so no protocol version bump: an old host answers
|
|
329
|
+
* `NOT_IMPLEMENTED`, which is how the app knows to keep its current relay.
|
|
330
|
+
*/
|
|
331
|
+
...(relay === undefined
|
|
332
|
+
? {}
|
|
333
|
+
: {
|
|
334
|
+
'relay.set': async (payload) => {
|
|
335
|
+
const requested = requireString(payload, 'url', 'relay.set');
|
|
336
|
+
const canonical = canonicalRelayURL(requested);
|
|
337
|
+
if (canonical === null || !isSelectableRelayURL(canonical)) {
|
|
338
|
+
throw new MethodError('MALFORMED_FRAME', 'relay.set: payload."url" must be the free or the paid relay');
|
|
339
|
+
}
|
|
340
|
+
return relay.set(canonical);
|
|
341
|
+
},
|
|
342
|
+
}),
|
|
310
343
|
/** Every session DSH would list, newest activity first. */
|
|
311
344
|
'session.list': async () => listSessions(ctx),
|
|
312
345
|
/**
|
|
@@ -347,6 +380,11 @@ export function createMethodTable(ctx, facts, broker, push, delivery, uploads) {
|
|
|
347
380
|
const before = fields?.['beforeSeq'];
|
|
348
381
|
const after = fields?.['afterSeq'];
|
|
349
382
|
const sessionId = requireString(payload, 'sessionId', 'session.messages');
|
|
383
|
+
// A delegated child is not addressable as a session: the host refuses the
|
|
384
|
+
// read with `session/agent-busy` unless the child is named through its
|
|
385
|
+
// parent. The phone knows the lineage from `session.list` and sends it
|
|
386
|
+
// back here, so the address can be rebuilt on the way in.
|
|
387
|
+
const parentSessionId = optionalString(payload, 'parentSessionId');
|
|
350
388
|
const explicit = typeof after === 'number' && Number.isFinite(after) ? after : undefined;
|
|
351
389
|
// `null` is the phone saying it holds nothing -- a reinstall, a cleared
|
|
352
390
|
// cache. Silence is not that claim: it is a request with no cursor, which
|
|
@@ -355,7 +393,7 @@ export function createMethodTable(ctx, facts, broker, push, delivery, uploads) {
|
|
|
355
393
|
const remembered = holdsNothing || explicit !== undefined
|
|
356
394
|
? undefined
|
|
357
395
|
: delivery?.deliveredThrough(context.deviceId, sessionId);
|
|
358
|
-
return sessionMessages(ctx, sessionId, typeof limit === 'number' ? limit : undefined, typeof before === 'number' ? before : undefined, explicit ?? remembered);
|
|
396
|
+
return sessionMessages(ctx, sessionId, typeof limit === 'number' ? limit : undefined, typeof before === 'number' ? before : undefined, explicit ?? remembered, parentSessionId);
|
|
359
397
|
},
|
|
360
398
|
/**
|
|
361
399
|
* The running turn's compact process detail.
|
|
@@ -414,6 +452,15 @@ export function createMethodTable(ctx, facts, broker, push, delivery, uploads) {
|
|
|
414
452
|
if (text === undefined && attachmentIds.length === 0) {
|
|
415
453
|
throw new MethodError('MALFORMED_FRAME', 'session.prompt: payload."text" must be a non-empty string when no attachments are given');
|
|
416
454
|
}
|
|
455
|
+
const requestId = optionalString(payload, 'requestId');
|
|
456
|
+
if (attachmentIds.length > 0 && requestId !== undefined) {
|
|
457
|
+
replayGuard.check(context.deviceId, sessionId, requestId, {
|
|
458
|
+
text,
|
|
459
|
+
mode,
|
|
460
|
+
clientTimeZone: optionalString(payload, 'clientTimeZone'),
|
|
461
|
+
attachmentIds,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
417
464
|
const attachments = attachmentIds.length === 0
|
|
418
465
|
? []
|
|
419
466
|
: await uploadStore.resolveForPrompt(context.deviceId, sessionId, attachmentIds);
|
|
@@ -422,7 +469,7 @@ export function createMethodTable(ctx, facts, broker, push, delivery, uploads) {
|
|
|
422
469
|
...(text === undefined ? {} : { text }),
|
|
423
470
|
...(attachments.length === 0 ? {} : { attachments }),
|
|
424
471
|
mode,
|
|
425
|
-
requestId
|
|
472
|
+
requestId,
|
|
426
473
|
clientTimeZone: optionalString(payload, 'clientTimeZone'),
|
|
427
474
|
});
|
|
428
475
|
},
|
|
@@ -445,6 +492,14 @@ export function createMethodTable(ctx, facts, broker, push, delivery, uploads) {
|
|
|
445
492
|
'upload.finish': async (payload, context) => uploadStore.finish(context.deviceId, requireString(payload, 'uploadId', 'upload.finish')),
|
|
446
493
|
/** Discard a half-sent upload and its bytes. */
|
|
447
494
|
'upload.abort': async (payload, context) => uploadStore.abort(context.deviceId, requireString(payload, 'uploadId', 'upload.abort')),
|
|
495
|
+
/**
|
|
496
|
+
* The attachment limits as this host enforces them.
|
|
497
|
+
*
|
|
498
|
+
* Additive and optional: a client may hardcode the documented numbers and
|
|
499
|
+
* never call this, but one that asks first survives a host that tunes
|
|
500
|
+
* them. Ported from XEPX2's PR #2.
|
|
501
|
+
*/
|
|
502
|
+
'upload.capabilities': async () => uploadStore.capabilities(),
|
|
448
503
|
/** Interrupt the running turn. */
|
|
449
504
|
'session.cancel': async (payload) => cancelSession(ctx, requireString(payload, 'sessionId', 'session.cancel')),
|
|
450
505
|
/** Rename a session. */
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create (if needed) and secure one staging directory for the current user.
|
|
3
|
+
*
|
|
4
|
+
* The directory is created first when absent, because the ACL reset below is
|
|
5
|
+
* what makes it private — creating it already-secured avoids a window in which
|
|
6
|
+
* an inherited-ACL directory exists.
|
|
7
|
+
*
|
|
8
|
+
* @param path - the directory to create and secure.
|
|
9
|
+
* @throws when the directory cannot be created or its ACL cannot be secured
|
|
10
|
+
* and verified; the caller must not write bytes into it afterwards.
|
|
11
|
+
*/
|
|
12
|
+
export declare function makePrivateDirectory(path: string): Promise<void>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure a staging directory so only the current user can reach it.
|
|
3
|
+
*
|
|
4
|
+
* Ported from XEPX2's PR #2 (`src/attachments/private-directory.ts`), which
|
|
5
|
+
* guarded its attachment staging area; this module uses it for the same job on
|
|
6
|
+
* the `upload.*` staging roots. `mode: 0o700` is enough on POSIX, but Windows
|
|
7
|
+
* ignores it: a fresh directory under the user profile inherits grants
|
|
8
|
+
* (SYSTEM, Administrators, the user) that `chmod` cannot remove. The OS answer
|
|
9
|
+
* is to reset the ACL to exactly one rule for the current SID — and to verify
|
|
10
|
+
* the reset afterwards rather than trust it.
|
|
11
|
+
*
|
|
12
|
+
* The verification is the point. Writing bytes into a directory whose ACL did
|
|
13
|
+
* not actually tighten would be silent exposure, so the script re-reads the
|
|
14
|
+
* ACL and fails unless it sees the protection flag, exactly one rule, and that
|
|
15
|
+
* rule naming the current user. Every failure path throws before the caller
|
|
16
|
+
* writes a byte.
|
|
17
|
+
*
|
|
18
|
+
* The parent directory (the plugin's state home, never TEMP itself) is left
|
|
19
|
+
* untouched, and no elevation is requested: if the current user cannot secure
|
|
20
|
+
* its own directory, staging is refused, not de-scoped.
|
|
21
|
+
*
|
|
22
|
+
* @module dsh-mizar/bridge/private-directory
|
|
23
|
+
*/
|
|
24
|
+
import { spawn } from 'node:child_process';
|
|
25
|
+
import { chmod, mkdir } from 'node:fs/promises';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
// Current-user SID, not localized account-name parsing. The path is passed as
|
|
28
|
+
// data in the child environment, not interpolated into PowerShell source.
|
|
29
|
+
const WINDOWS_ACL = `
|
|
30
|
+
$ErrorActionPreference = 'Stop'
|
|
31
|
+
$ProgressPreference = 'SilentlyContinue'
|
|
32
|
+
$path = $env:MIZAR_PRIVATE_UPLOAD_DIRECTORY
|
|
33
|
+
$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
34
|
+
$acl = New-Object System.Security.AccessControl.DirectorySecurity
|
|
35
|
+
$acl.SetOwner($sid)
|
|
36
|
+
$acl.SetAccessRuleProtection($true, $false)
|
|
37
|
+
$inheritance = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
|
|
38
|
+
$propagation = [System.Security.AccessControl.PropagationFlags]::None
|
|
39
|
+
$allow = [System.Security.AccessControl.AccessControlType]::Allow
|
|
40
|
+
$rights = [System.Security.AccessControl.FileSystemRights]::FullControl
|
|
41
|
+
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($sid, $rights, $inheritance, $propagation, $allow)
|
|
42
|
+
$acl.AddAccessRule($rule)
|
|
43
|
+
$directory = [System.IO.DirectoryInfo]::new($path)
|
|
44
|
+
$directory.SetAccessControl($acl)
|
|
45
|
+
$check = $directory.GetAccessControl()
|
|
46
|
+
if (-not $check.AreAccessRulesProtected) { throw 'Private upload ACL was not protected' }
|
|
47
|
+
$rules = $check.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
|
|
48
|
+
if ($rules.Count -ne 1) { throw 'Private upload ACL has an unexpected rule count' }
|
|
49
|
+
foreach ($entry in $rules) {
|
|
50
|
+
if ($entry.IdentityReference.Value -ne $sid.Value) {
|
|
51
|
+
throw 'Private upload ACL contains an unexpected principal'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
`;
|
|
55
|
+
/**
|
|
56
|
+
* Create (if needed) and secure one staging directory for the current user.
|
|
57
|
+
*
|
|
58
|
+
* The directory is created first when absent, because the ACL reset below is
|
|
59
|
+
* what makes it private — creating it already-secured avoids a window in which
|
|
60
|
+
* an inherited-ACL directory exists.
|
|
61
|
+
*
|
|
62
|
+
* @param path - the directory to create and secure.
|
|
63
|
+
* @throws when the directory cannot be created or its ACL cannot be secured
|
|
64
|
+
* and verified; the caller must not write bytes into it afterwards.
|
|
65
|
+
*/
|
|
66
|
+
export async function makePrivateDirectory(path) {
|
|
67
|
+
await mkdir(path, { recursive: true });
|
|
68
|
+
if (process.platform !== 'win32') {
|
|
69
|
+
await chmod(path, 0o700);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// Windows chmod cannot remove inherited ACL grants. Use the OS-supplied
|
|
73
|
+
// Windows PowerShell, with no profile, no shell interpolation and no pipes.
|
|
74
|
+
const executable = join(process.env['SystemRoot'] ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
75
|
+
await new Promise((resolve, reject) => {
|
|
76
|
+
const child = spawn(executable, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(WINDOWS_ACL, 'utf16le').toString('base64')], {
|
|
77
|
+
windowsHide: true,
|
|
78
|
+
stdio: 'ignore',
|
|
79
|
+
env: { ...process.env, MIZAR_PRIVATE_UPLOAD_DIRECTORY: path },
|
|
80
|
+
});
|
|
81
|
+
const timer = setTimeout(() => {
|
|
82
|
+
child.kill();
|
|
83
|
+
reject(new Error('Private upload ACL setup timed out'));
|
|
84
|
+
}, 15_000);
|
|
85
|
+
child.once('error', (error) => {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
reject(error);
|
|
88
|
+
});
|
|
89
|
+
child.once('exit', (code) => {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
if (code === 0)
|
|
92
|
+
resolve();
|
|
93
|
+
else
|
|
94
|
+
reject(new Error('Cannot secure private upload directory ACL'));
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
package/lib/bridge/sessions.d.ts
CHANGED
|
@@ -72,9 +72,13 @@ export declare function sessionStats(ctx: Context, sessionId: string): Promise<S
|
|
|
72
72
|
* @param afterSeq - the log position the caller already holds; only records
|
|
73
73
|
* after it travel. Ignored together with `beforeSeq`, because a page paged
|
|
74
74
|
* backwards is not a page of new records.
|
|
75
|
+
* @param parentSessionId - the parent of a delegated child. Supplying it is what
|
|
76
|
+
* makes a child's history readable at all: without it the host refuses the
|
|
77
|
+
* read outright (`session/agent-busy`), because a child is not addressable as
|
|
78
|
+
* a session. See {@link logAddress}.
|
|
75
79
|
* @returns the page, or no records when the session is unknown.
|
|
76
80
|
*/
|
|
77
|
-
export declare function sessionMessages(ctx: Context, sessionId: string, maxMessages?: number, beforeSeq?: number, afterSeq?: number): Promise<SessionMessagesResult>;
|
|
81
|
+
export declare function sessionMessages(ctx: Context, sessionId: string, maxMessages?: number, beforeSeq?: number, afterSeq?: number, parentSessionId?: string): Promise<SessionMessagesResult>;
|
|
78
82
|
/** One page of a session's log. */
|
|
79
83
|
export interface SessionMessagesResult {
|
|
80
84
|
readonly sessionId: string;
|
package/lib/bridge/sessions.js
CHANGED
|
@@ -457,6 +457,19 @@ function turnOf(event) {
|
|
|
457
457
|
* answer this way, not for one that is merely slow.
|
|
458
458
|
*/
|
|
459
459
|
const FOLLOW_TIMEOUT_MS = 30_000;
|
|
460
|
+
/**
|
|
461
|
+
* Address one session's log, as a child when its parent is known.
|
|
462
|
+
*
|
|
463
|
+
* @param sessionId - the session whose log is being read.
|
|
464
|
+
* @param parentSessionId - the parent of a delegated child, or absent for an
|
|
465
|
+
* ordinary conversation (and for a fork, which is a session of its own).
|
|
466
|
+
* @returns the address to send.
|
|
467
|
+
*/
|
|
468
|
+
function logAddress(sessionId, parentSessionId) {
|
|
469
|
+
return parentSessionId === undefined
|
|
470
|
+
? { kind: 'session', sessionId }
|
|
471
|
+
: { kind: 'subagent', parentSessionId, childSessionId: sessionId, mode: 'unknown' };
|
|
472
|
+
}
|
|
460
473
|
/**
|
|
461
474
|
* The log cut, from the opening frame of `session/follow`.
|
|
462
475
|
*
|
|
@@ -485,17 +498,17 @@ const FOLLOW_TIMEOUT_MS = 30_000;
|
|
|
485
498
|
* leak that only shows up after an hour of use.
|
|
486
499
|
*
|
|
487
500
|
* @param ctx - a context observing `ctx.typertGateway`.
|
|
488
|
-
* @param
|
|
501
|
+
* @param address - the durable address to open; see {@link logAddress}.
|
|
489
502
|
* @returns the cursor, or `null` when the host would not answer this way.
|
|
490
503
|
*/
|
|
491
|
-
async function followCursor(ctx,
|
|
504
|
+
async function followCursor(ctx, address) {
|
|
492
505
|
const controller = new AbortController();
|
|
493
506
|
const timer = setTimeout(() => controller.abort(), FOLLOW_TIMEOUT_MS);
|
|
494
507
|
try {
|
|
495
508
|
const iterable = await streamRemote(ctx, {
|
|
496
509
|
namespace: 'session',
|
|
497
510
|
method: 'follow',
|
|
498
|
-
args: { request: { address
|
|
511
|
+
args: { request: { address } },
|
|
499
512
|
signal: controller.signal,
|
|
500
513
|
});
|
|
501
514
|
const iterator = iterable[Symbol.asyncIterator]();
|
|
@@ -584,10 +597,13 @@ async function projectionsCut(ctx, sessionId) {
|
|
|
584
597
|
* @param sessionId - the session to read.
|
|
585
598
|
* @param maxMessages - how many messages to ask for; absent uses the host default.
|
|
586
599
|
* @param beforeSeq - the exclusive upper bound, for paging backwards.
|
|
600
|
+
* @param parentSessionId - the parent of a delegated child; absent reads the
|
|
601
|
+
* session as a session. See {@link logAddress}.
|
|
587
602
|
* @returns the page, or `null` when the session has no log to read.
|
|
588
603
|
*/
|
|
589
|
-
async function readLogPage(ctx, sessionId, maxMessages, beforeSeq) {
|
|
590
|
-
const
|
|
604
|
+
async function readLogPage(ctx, sessionId, maxMessages, beforeSeq, parentSessionId) {
|
|
605
|
+
const address = logAddress(sessionId, parentSessionId);
|
|
606
|
+
const cut = (await followCursor(ctx, address)) ?? (await projectionsCut(ctx, sessionId));
|
|
591
607
|
if (cut === null) {
|
|
592
608
|
// No cut, no page: the session has no log to read.
|
|
593
609
|
return null;
|
|
@@ -597,7 +613,7 @@ async function readLogPage(ctx, sessionId, maxMessages, beforeSeq) {
|
|
|
597
613
|
method: 'page',
|
|
598
614
|
args: {
|
|
599
615
|
request: {
|
|
600
|
-
address
|
|
616
|
+
address,
|
|
601
617
|
throughSeq: cut,
|
|
602
618
|
// The lower bound. Without it a page starts at the beginning of the log,
|
|
603
619
|
// which for a session with any history means a conversation view made
|
|
@@ -631,10 +647,14 @@ async function readLogPage(ctx, sessionId, maxMessages, beforeSeq) {
|
|
|
631
647
|
* @param afterSeq - the log position the caller already holds; only records
|
|
632
648
|
* after it travel. Ignored together with `beforeSeq`, because a page paged
|
|
633
649
|
* backwards is not a page of new records.
|
|
650
|
+
* @param parentSessionId - the parent of a delegated child. Supplying it is what
|
|
651
|
+
* makes a child's history readable at all: without it the host refuses the
|
|
652
|
+
* read outright (`session/agent-busy`), because a child is not addressable as
|
|
653
|
+
* a session. See {@link logAddress}.
|
|
634
654
|
* @returns the page, or no records when the session is unknown.
|
|
635
655
|
*/
|
|
636
|
-
export async function sessionMessages(ctx, sessionId, maxMessages, beforeSeq, afterSeq) {
|
|
637
|
-
const page = await readLogPage(ctx, sessionId, maxMessages, beforeSeq);
|
|
656
|
+
export async function sessionMessages(ctx, sessionId, maxMessages, beforeSeq, afterSeq, parentSessionId) {
|
|
657
|
+
const page = await readLogPage(ctx, sessionId, maxMessages, beforeSeq, parentSessionId);
|
|
638
658
|
if (page === null) {
|
|
639
659
|
return { sessionId, asOfSeq: null, records: [], hasMore: false };
|
|
640
660
|
}
|
package/lib/bridge/uploads.d.ts
CHANGED
|
@@ -15,6 +15,22 @@ export declare const UPLOAD_LIMITS: {
|
|
|
15
15
|
/** Most images one prompt may carry. */
|
|
16
16
|
readonly images: 4;
|
|
17
17
|
};
|
|
18
|
+
/**
|
|
19
|
+
* Resource quotas the staging area enforces on top of the documented limits.
|
|
20
|
+
*
|
|
21
|
+
* The per-prompt limits bound one message; these bound the desktop. Ported
|
|
22
|
+
* from XEPX2's PR #2. `reservedBytes` is the sum of what live uploads have
|
|
23
|
+
* declared; the reservation ends when the upload completes (its bytes then
|
|
24
|
+
* become an attachment, bounded at prompt time) or expires.
|
|
25
|
+
*/
|
|
26
|
+
export declare const UPLOAD_QUOTAS: {
|
|
27
|
+
/** Largest total of declared-but-unreceived bytes across all live uploads. */
|
|
28
|
+
readonly reservedBytes: number;
|
|
29
|
+
/** Most uploads in flight, across every device. */
|
|
30
|
+
readonly active: 100;
|
|
31
|
+
/** Most uploads in flight for one device. */
|
|
32
|
+
readonly perDevice: 10;
|
|
33
|
+
};
|
|
18
34
|
/**
|
|
19
35
|
* Media types DSH accepts as image content.
|
|
20
36
|
*
|
|
@@ -33,6 +49,8 @@ export interface UploadBeginRequest {
|
|
|
33
49
|
readonly name: string;
|
|
34
50
|
readonly mediaType: string;
|
|
35
51
|
readonly byteSize: number;
|
|
52
|
+
/** Lowercase hex SHA-256 of the full payload, when the client declares it. */
|
|
53
|
+
readonly sha256?: string;
|
|
36
54
|
}
|
|
37
55
|
/** `upload.chunk` arguments after validation. */
|
|
38
56
|
export interface UploadChunkRequest {
|
|
@@ -54,6 +72,19 @@ export interface UploadFinishResult {
|
|
|
54
72
|
readonly mediaType: string;
|
|
55
73
|
readonly byteSize: number;
|
|
56
74
|
}
|
|
75
|
+
/** What `upload.capabilities` answers. */
|
|
76
|
+
export interface UploadCapabilities {
|
|
77
|
+
readonly chunkBytes: number;
|
|
78
|
+
readonly imageBytes: number;
|
|
79
|
+
readonly fileBytes: number;
|
|
80
|
+
readonly promptBytes: number;
|
|
81
|
+
readonly attachments: number;
|
|
82
|
+
readonly images: number;
|
|
83
|
+
readonly expiresSeconds: number;
|
|
84
|
+
readonly imageMediaTypes: readonly string[];
|
|
85
|
+
/** Whether the host verifies a client-declared SHA-256. */
|
|
86
|
+
readonly sha256: boolean;
|
|
87
|
+
}
|
|
57
88
|
/**
|
|
58
89
|
* One finished upload, as the session bridge consumes it.
|
|
59
90
|
*
|
|
@@ -90,6 +121,18 @@ export interface UploadStoreOptions {
|
|
|
90
121
|
readonly chunkBytes?: number;
|
|
91
122
|
/** Clock override, used by tests to age an upload without sleeping. */
|
|
92
123
|
readonly now?: () => number;
|
|
124
|
+
/** Largest total of declared bytes across live uploads (tests shrink this). */
|
|
125
|
+
readonly maxReservedBytes?: number;
|
|
126
|
+
/** Most uploads in flight across every device (tests shrink this). */
|
|
127
|
+
readonly maxActiveUploads?: number;
|
|
128
|
+
/** Most uploads in flight for one device (tests shrink this). */
|
|
129
|
+
readonly maxDeviceUploads?: number;
|
|
130
|
+
/**
|
|
131
|
+
* Whether `begin` secures the staging roots before writing (tests pass
|
|
132
|
+
* `false` so a hundred cases do not each spawn the ACL tooling; the real
|
|
133
|
+
* behavior has its own test).
|
|
134
|
+
*/
|
|
135
|
+
readonly secureRoots?: boolean;
|
|
93
136
|
}
|
|
94
137
|
/**
|
|
95
138
|
* Read `upload.begin`'s payload.
|
|
@@ -160,6 +203,11 @@ export declare class UploadStore {
|
|
|
160
203
|
* @param options - where uploads live and how long they last.
|
|
161
204
|
*/
|
|
162
205
|
constructor(options: UploadStoreOptions);
|
|
206
|
+
/**
|
|
207
|
+
* The limits a client should obey, as this store enforces them.
|
|
208
|
+
* @returns the capability record `upload.capabilities` answers with.
|
|
209
|
+
*/
|
|
210
|
+
capabilities(): UploadCapabilities;
|
|
163
211
|
/**
|
|
164
212
|
* Begin one upload.
|
|
165
213
|
*
|
|
@@ -234,3 +282,43 @@ export declare class UploadStore {
|
|
|
234
282
|
*/
|
|
235
283
|
resolveForPrompt(deviceId: string, sessionId: string, attachmentIds: readonly string[]): Promise<PromptAttachment[]>;
|
|
236
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* Refuses a `requestId` reused for a different attachment prompt.
|
|
287
|
+
*
|
|
288
|
+
* DSH deduplicates prompts by session and request id: the same key always
|
|
289
|
+
* returns the first answer. For a text prompt that is the client's own
|
|
290
|
+
* idempotency choice; for a prompt that carries attachments, the key plus the
|
|
291
|
+
* content is the contract — the same retry must carry the same text, the same
|
|
292
|
+
* attachments in the same order, the same mode and timezone, from the same
|
|
293
|
+
* device. Anything else is either a client bug or an attempt to borrow another
|
|
294
|
+
* request's acceptance, and it is refused rather than second-guessed.
|
|
295
|
+
*
|
|
296
|
+
* The ledger is memory-only and bounded: at most 1000 keys globally, 100 per
|
|
297
|
+
* device, each forgotten 30 minutes after it was last seen — the same bounds
|
|
298
|
+
* XEPX2's PR #2 chose, for the same reason: a restart is allowed to forget,
|
|
299
|
+
* because DSH's own persistence is the final guard against duplicates.
|
|
300
|
+
*/
|
|
301
|
+
export declare class PromptReplayGuard {
|
|
302
|
+
#private;
|
|
303
|
+
/**
|
|
304
|
+
* @param now - the clock, overridable for tests.
|
|
305
|
+
*/
|
|
306
|
+
constructor(now?: () => number);
|
|
307
|
+
/**
|
|
308
|
+
* Record one attachment prompt, refusing a key reused with different content.
|
|
309
|
+
*
|
|
310
|
+
* @param deviceId - the authenticated device.
|
|
311
|
+
* @param sessionId - the target session.
|
|
312
|
+
* @param requestId - the client's idempotency key.
|
|
313
|
+
* @param content - everything that makes this prompt this prompt.
|
|
314
|
+
* @throws MethodError `ATTACHMENT_INVALID` when the key was seen with a
|
|
315
|
+
* different device or content.
|
|
316
|
+
* @throws MethodError `UPLOAD_LIMIT_REACHED` when the ledger is full.
|
|
317
|
+
*/
|
|
318
|
+
check(deviceId: string, sessionId: string, requestId: string, content: {
|
|
319
|
+
readonly text?: string;
|
|
320
|
+
readonly mode: 'queue' | 'steer';
|
|
321
|
+
readonly clientTimeZone?: string;
|
|
322
|
+
readonly attachmentIds: readonly string[];
|
|
323
|
+
}): void;
|
|
324
|
+
}
|