lark-remote 0.5.4 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/dist/bridge/approval-coordinator.d.ts +18 -1
- package/dist/bridge/approval-coordinator.js +57 -10
- package/dist/bridge/index.js +90 -17
- package/dist/card/approval-render.js +18 -0
- package/dist/card/run-state.js +10 -5
- package/dist/config/index.d.ts +16 -0
- package/dist/config/index.js +46 -6
- package/dist/config/opencode-config.d.ts +3 -3
- package/dist/config/opencode-config.js +38 -22
- package/dist/inbound/authorize.d.ts +34 -0
- package/dist/inbound/authorize.js +31 -0
- package/dist/index.js +116 -63
- package/dist/instance-lock.d.ts +9 -0
- package/dist/instance-lock.js +113 -42
- package/dist/router/config/dsh.d.ts +51 -21
- package/dist/router/config/dsh.js +255 -66
- package/dist/router/config/opencode.js +16 -6
- package/dist/router/config/types.d.ts +6 -3
- package/dist/router/index.d.ts +1 -0
- package/dist/router/index.js +13 -5
- package/dist/runner/common/acp/base-acp-runner.d.ts +12 -0
- package/dist/runner/common/acp/base-acp-runner.js +83 -8
- package/dist/runner/common/acp/protocol-types.d.ts +10 -2
- package/dist/runner/common/acp/protocol-types.js +3 -1
- package/dist/runner/common/connection-based-runner.d.ts +15 -0
- package/dist/runner/common/connection-based-runner.js +40 -5
- package/dist/runner/common/jsonrpc/connection-manager.d.ts +15 -0
- package/dist/runner/common/jsonrpc/connection-manager.js +83 -50
- package/dist/runner/dsh/approval.d.ts +53 -0
- package/dist/runner/dsh/approval.js +161 -0
- package/dist/runner/dsh/auth.d.ts +36 -0
- package/dist/runner/dsh/auth.js +118 -0
- package/dist/runner/dsh/client.d.ts +161 -42
- package/dist/runner/dsh/client.js +551 -244
- package/dist/runner/dsh/daemon.d.ts +85 -0
- package/dist/runner/dsh/daemon.js +272 -0
- package/dist/runner/dsh/merge.d.ts +9 -0
- package/dist/runner/dsh/merge.js +51 -0
- package/dist/runner/dsh/runner.d.ts +94 -11
- package/dist/runner/dsh/runner.js +553 -67
- package/dist/runner/dsh/translator.d.ts +43 -3
- package/dist/runner/dsh/translator.js +115 -38
- package/dist/runner/dsh/types.d.ts +189 -27
- package/dist/runner/dsh/types.js +11 -5
- package/dist/runner/kimi/acp/runner.js +15 -3
- package/dist/runner/opencode/acp/error-monitor.d.ts +2 -2
- package/dist/runner/opencode/acp/error-monitor.js +2 -2
- package/dist/runner/opencode/acp/runner.d.ts +9 -0
- package/dist/runner/opencode/acp/runner.js +40 -1
- package/dist/runner/opencode/acp/translator.d.ts +4 -2
- package/dist/runner/opencode/acp/translator.js +12 -3
- package/dist/runner/pi/rpc/translator.d.ts +2 -0
- package/dist/runner/pi/rpc/translator.js +27 -1
- package/dist/runner/types.d.ts +34 -0
- package/dist/runner/types.js +24 -0
- package/dist/session/dsh/sessions.d.ts +45 -6
- package/dist/session/dsh/sessions.js +171 -27
- package/dist/session/opencode/sessions.d.ts +18 -7
- package/dist/session/opencode/sessions.js +294 -15
- package/dist/session/pi/sessions.js +6 -2
- package/package.json +3 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Correlate DSH's durable approval audit with its live answer waterfall.
|
|
3
|
+
*
|
|
4
|
+
* `session/follow` carries `approval/asked` / `approval/decided`; `$events`
|
|
5
|
+
* carries the answerable `approval/request` waterfall. The audit id is the
|
|
6
|
+
* stable identity shown on the Feishu card; the waterfall contributes the
|
|
7
|
+
* ephemeral `eventId` + `clientId` needed to answer it. Both are correlated by
|
|
8
|
+
* `callId`, with an order-independent pairing so either transport may arrive
|
|
9
|
+
* first.
|
|
10
|
+
*/
|
|
11
|
+
export class DshApprovalTracker {
|
|
12
|
+
audits = new Map();
|
|
13
|
+
waterfallByCallId = new Map();
|
|
14
|
+
bindings = new Map();
|
|
15
|
+
approvalIdByEventId = new Map();
|
|
16
|
+
waiters = new Map();
|
|
17
|
+
/** Record `approval/asked`; pair immediately when its waterfall is known. */
|
|
18
|
+
onAuditAsked(audit) {
|
|
19
|
+
const existing = this.bindings.get(audit.approvalId);
|
|
20
|
+
if (existing)
|
|
21
|
+
return existing;
|
|
22
|
+
this.audits.set(audit.approvalId, audit);
|
|
23
|
+
if (audit.callId) {
|
|
24
|
+
const waterfall = this.waterfallByCallId.get(audit.callId);
|
|
25
|
+
if (waterfall)
|
|
26
|
+
return this.bind(audit, waterfall);
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
/** Record an `approval/request` waterfall; pair when its audit is known. */
|
|
31
|
+
onWaterfall(waterfall) {
|
|
32
|
+
const existingApprovalId = this.approvalIdByEventId.get(waterfall.eventId);
|
|
33
|
+
if (existingApprovalId) {
|
|
34
|
+
// A reconnect re-delivers the same pending event with a new Client
|
|
35
|
+
// identity. Keep the card binding but refresh the answer channel.
|
|
36
|
+
const binding = this.bindings.get(existingApprovalId);
|
|
37
|
+
if (binding) {
|
|
38
|
+
binding.waterfall.clientId = waterfall.clientId;
|
|
39
|
+
if (waterfall.reason)
|
|
40
|
+
binding.waterfall.reason = waterfall.reason;
|
|
41
|
+
if (waterfall.callId) {
|
|
42
|
+
this.waterfallByCallId.set(waterfall.callId, binding.waterfall);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
if (waterfall.callId)
|
|
48
|
+
this.waterfallByCallId.set(waterfall.callId, waterfall);
|
|
49
|
+
if (waterfall.callId) {
|
|
50
|
+
for (const audit of this.audits.values()) {
|
|
51
|
+
if (audit.callId === waterfall.callId && !this.bindings.has(audit.approvalId)) {
|
|
52
|
+
return this.bind(audit, waterfall);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Some approval types omit callId on the durable audit. Pair with the
|
|
56
|
+
// oldest unbound audit for the same tool before falling back to Web UI.
|
|
57
|
+
for (const audit of this.audits.values()) {
|
|
58
|
+
if (!audit.callId &&
|
|
59
|
+
!this.bindings.has(audit.approvalId) &&
|
|
60
|
+
audit.toolName === waterfall.toolName) {
|
|
61
|
+
return this.bind(audit, waterfall);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// A missing callId is unusual but not fatal: pair with the oldest unbound
|
|
66
|
+
// audit for the same tool, preserving the audit id on the card.
|
|
67
|
+
if (!waterfall.callId) {
|
|
68
|
+
for (const audit of this.audits.values()) {
|
|
69
|
+
if (!this.bindings.has(audit.approvalId) && audit.toolName === waterfall.toolName) {
|
|
70
|
+
return this.bind(audit, waterfall);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
/** Mark an audit resolved (local response or an external decision). */
|
|
77
|
+
onDecided(approvalId) {
|
|
78
|
+
const binding = this.bindings.get(approvalId);
|
|
79
|
+
this.audits.delete(approvalId);
|
|
80
|
+
this.bindings.delete(approvalId);
|
|
81
|
+
if (binding) {
|
|
82
|
+
this.approvalIdByEventId.delete(binding.waterfall.eventId);
|
|
83
|
+
if (binding.waterfall.callId) {
|
|
84
|
+
const stored = this.waterfallByCallId.get(binding.waterfall.callId);
|
|
85
|
+
if (stored?.eventId === binding.waterfall.eventId) {
|
|
86
|
+
this.waterfallByCallId.delete(binding.waterfall.callId);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
this.resolveWaiters(approvalId, undefined);
|
|
90
|
+
}
|
|
91
|
+
return binding;
|
|
92
|
+
}
|
|
93
|
+
/** Mark a waterfall cancelled by the Host (another client answered it). */
|
|
94
|
+
onCancelled(eventId) {
|
|
95
|
+
const approvalId = this.approvalIdByEventId.get(eventId);
|
|
96
|
+
if (!approvalId)
|
|
97
|
+
return undefined;
|
|
98
|
+
return this.onDecided(approvalId);
|
|
99
|
+
}
|
|
100
|
+
getBinding(approvalId) {
|
|
101
|
+
return this.bindings.get(approvalId);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Wait for a waterfall to arrive after the audit card was rendered. The
|
|
105
|
+
* audit is appended before the waterfall is dispatched, so this window is
|
|
106
|
+
* normally tiny; the bound keeps a fast button click from failing.
|
|
107
|
+
*/
|
|
108
|
+
async waitForBinding(approvalId, timeoutMs, signal) {
|
|
109
|
+
const current = this.bindings.get(approvalId);
|
|
110
|
+
if (current)
|
|
111
|
+
return current;
|
|
112
|
+
if (signal?.aborted)
|
|
113
|
+
return undefined;
|
|
114
|
+
return new Promise((resolve) => {
|
|
115
|
+
let settled = false;
|
|
116
|
+
const finish = (binding) => {
|
|
117
|
+
if (settled)
|
|
118
|
+
return;
|
|
119
|
+
settled = true;
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
signal?.removeEventListener('abort', onAbort);
|
|
122
|
+
const set = this.waiters.get(approvalId);
|
|
123
|
+
set?.delete(finish);
|
|
124
|
+
if (set && set.size === 0)
|
|
125
|
+
this.waiters.delete(approvalId);
|
|
126
|
+
resolve(binding);
|
|
127
|
+
};
|
|
128
|
+
const onAbort = () => finish(undefined);
|
|
129
|
+
const timer = setTimeout(() => finish(undefined), timeoutMs);
|
|
130
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
131
|
+
const set = this.waiters.get(approvalId) ?? new Set();
|
|
132
|
+
set.add(finish);
|
|
133
|
+
this.waiters.set(approvalId, set);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
/** Drop all in-flight state when a run ends. */
|
|
137
|
+
clear() {
|
|
138
|
+
for (const approvalId of [...this.waiters.keys()])
|
|
139
|
+
this.resolveWaiters(approvalId, undefined);
|
|
140
|
+
this.audits.clear();
|
|
141
|
+
this.waterfallByCallId.clear();
|
|
142
|
+
this.bindings.clear();
|
|
143
|
+
this.approvalIdByEventId.clear();
|
|
144
|
+
}
|
|
145
|
+
bind(audit, waterfall) {
|
|
146
|
+
const binding = { audit, waterfall };
|
|
147
|
+
this.bindings.set(audit.approvalId, binding);
|
|
148
|
+
this.approvalIdByEventId.set(waterfall.eventId, audit.approvalId);
|
|
149
|
+
this.resolveWaiters(audit.approvalId, binding);
|
|
150
|
+
return binding;
|
|
151
|
+
}
|
|
152
|
+
resolveWaiters(approvalId, binding) {
|
|
153
|
+
const set = this.waiters.get(approvalId);
|
|
154
|
+
if (!set)
|
|
155
|
+
return;
|
|
156
|
+
this.waiters.delete(approvalId);
|
|
157
|
+
for (const waiter of [...set])
|
|
158
|
+
waiter(binding);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
//# sourceMappingURL=approval.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent DSH browser authentication.
|
|
3
|
+
*
|
|
4
|
+
* `dsh web` rotates its process launch token on every restart. The signed
|
|
5
|
+
* browser cookie is different: it is verified with the durable
|
|
6
|
+
* `client-connection/browser-session` secret in `$DSH_HOME/.credentials.yaml`
|
|
7
|
+
* and does not identify a process-local operator peer. DSH accepts that cookie
|
|
8
|
+
* for both `/api/*` and `/api/remote.mux`, so minting a fresh v1 cookie from
|
|
9
|
+
* the persisted secret survives a DSH restart without copying a new token.
|
|
10
|
+
*/
|
|
11
|
+
/** Credential record that owns the browser-cookie signing secret. */
|
|
12
|
+
export declare const DSH_BROWSER_CREDENTIAL_KEY = "client-connection/browser-session";
|
|
13
|
+
/** Short lifetime keeps every request safely inside DSH's configurable max age. */
|
|
14
|
+
export declare const DSH_COOKIE_LIFETIME_MS: number;
|
|
15
|
+
/**
|
|
16
|
+
* Resolve DSH's credentials file using the same `$DSH_HOME` convention.
|
|
17
|
+
* A configured home (agents.dsh.dshHome) wins over the environment so the
|
|
18
|
+
* file matches the `DSH_HOME` lark-remote passes to a process it starts.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveDshCredentialsFile(env?: NodeJS.ProcessEnv, configuredHome?: string): string;
|
|
21
|
+
/** Whether a filesystem error means the credentials file is absent. */
|
|
22
|
+
export declare function isDshCredentialsNotFound(error: unknown): boolean;
|
|
23
|
+
/** Read and validate the browser-cookie signing secret synchronously. */
|
|
24
|
+
export declare function readDshBrowserSecretSync(file: string): Buffer;
|
|
25
|
+
/** Read and validate the browser-cookie signing secret asynchronously. */
|
|
26
|
+
export declare function readDshBrowserSecret(file: string): Promise<Buffer>;
|
|
27
|
+
/** Cookie name binds the signed value to one host:port authority. */
|
|
28
|
+
export declare function dshCookieName(authority: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* Mint a v1 browser cookie accepted by DSH's `/api` admission path.
|
|
31
|
+
*
|
|
32
|
+
* DSH verifies `v1.<base64url(payload)>.<base64url(hmac-sha256(body))>` with
|
|
33
|
+
* the durable browser-session secret. The short default lifetime is refreshed
|
|
34
|
+
* on the next request, so no cookie is persisted by lark-remote.
|
|
35
|
+
*/
|
|
36
|
+
export declare function mintDshBrowserCookie(baseUrl: string, secret: Buffer, issuedAt?: number, lifetimeMs?: number): string;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent DSH browser authentication.
|
|
3
|
+
*
|
|
4
|
+
* `dsh web` rotates its process launch token on every restart. The signed
|
|
5
|
+
* browser cookie is different: it is verified with the durable
|
|
6
|
+
* `client-connection/browser-session` secret in `$DSH_HOME/.credentials.yaml`
|
|
7
|
+
* and does not identify a process-local operator peer. DSH accepts that cookie
|
|
8
|
+
* for both `/api/*` and `/api/remote.mux`, so minting a fresh v1 cookie from
|
|
9
|
+
* the persisted secret survives a DSH restart without copying a new token.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import YAML from 'yaml';
|
|
16
|
+
/** Credential record that owns the browser-cookie signing secret. */
|
|
17
|
+
export const DSH_BROWSER_CREDENTIAL_KEY = 'client-connection/browser-session';
|
|
18
|
+
/** Short lifetime keeps every request safely inside DSH's configurable max age. */
|
|
19
|
+
export const DSH_COOKIE_LIFETIME_MS = 60 * 60 * 1000;
|
|
20
|
+
const SECRET_BYTES = 32;
|
|
21
|
+
const COOKIE_PREFIX = 'dsh-auth-';
|
|
22
|
+
function isRecord(value) {
|
|
23
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Resolve DSH's credentials file using the same `$DSH_HOME` convention.
|
|
27
|
+
* A configured home (agents.dsh.dshHome) wins over the environment so the
|
|
28
|
+
* file matches the `DSH_HOME` lark-remote passes to a process it starts.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveDshCredentialsFile(env = process.env, configuredHome) {
|
|
31
|
+
const home = configuredHome?.trim() || env.DSH_HOME?.trim();
|
|
32
|
+
return path.join(home && home.length > 0 ? home : path.join(os.homedir(), '.dsh'), '.credentials.yaml');
|
|
33
|
+
}
|
|
34
|
+
/** Whether a filesystem error means the credentials file is absent. */
|
|
35
|
+
export function isDshCredentialsNotFound(error) {
|
|
36
|
+
return error?.code === 'ENOENT';
|
|
37
|
+
}
|
|
38
|
+
function assertOwnerOnly(file, stats) {
|
|
39
|
+
if (!stats.isFile()) {
|
|
40
|
+
throw new Error(`DSH credentials file ${file} is not a regular file`);
|
|
41
|
+
}
|
|
42
|
+
// Windows has no POSIX mode bits; DSH itself skips the same check there.
|
|
43
|
+
if (process.platform === 'win32')
|
|
44
|
+
return;
|
|
45
|
+
if ((stats.mode & 0o077) !== 0) {
|
|
46
|
+
throw new Error(`DSH credentials file ${file} is readable beyond its owner ` +
|
|
47
|
+
`(mode ${(stats.mode & 0o777).toString(8)}); run "chmod 600 ${file}"`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function parseBrowserSecret(text, file) {
|
|
51
|
+
let document;
|
|
52
|
+
try {
|
|
53
|
+
document = YAML.parse(text);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// The parser's message quotes the offending source line, which is secret.
|
|
57
|
+
throw new Error(`DSH credentials file ${file} is not valid YAML`);
|
|
58
|
+
}
|
|
59
|
+
if (!isRecord(document) || document.version !== 1 || !isRecord(document.records)) {
|
|
60
|
+
throw new Error(`DSH credentials file ${file} has an unsupported format`);
|
|
61
|
+
}
|
|
62
|
+
const record = document.records[DSH_BROWSER_CREDENTIAL_KEY];
|
|
63
|
+
if (!isRecord(record) ||
|
|
64
|
+
record.kind !== 'grant' ||
|
|
65
|
+
!isRecord(record.payload) ||
|
|
66
|
+
record.payload.version !== 1 ||
|
|
67
|
+
typeof record.payload.secret !== 'string') {
|
|
68
|
+
throw new Error(`DSH credentials file ${file} does not contain ${DSH_BROWSER_CREDENTIAL_KEY}`);
|
|
69
|
+
}
|
|
70
|
+
const secret = Buffer.from(record.payload.secret, 'base64url');
|
|
71
|
+
if (secret.byteLength !== SECRET_BYTES ||
|
|
72
|
+
secret.toString('base64url') !== record.payload.secret) {
|
|
73
|
+
throw new Error(`DSH credentials file ${file} contains an invalid ${DSH_BROWSER_CREDENTIAL_KEY} secret`);
|
|
74
|
+
}
|
|
75
|
+
return secret;
|
|
76
|
+
}
|
|
77
|
+
/** Read and validate the browser-cookie signing secret synchronously. */
|
|
78
|
+
export function readDshBrowserSecretSync(file) {
|
|
79
|
+
assertOwnerOnly(file, fs.statSync(file));
|
|
80
|
+
return parseBrowserSecret(fs.readFileSync(file, 'utf-8'), file);
|
|
81
|
+
}
|
|
82
|
+
/** Read and validate the browser-cookie signing secret asynchronously. */
|
|
83
|
+
export async function readDshBrowserSecret(file) {
|
|
84
|
+
const stats = await fs.promises.stat(file);
|
|
85
|
+
assertOwnerOnly(file, stats);
|
|
86
|
+
const text = await fs.promises.readFile(file, 'utf-8');
|
|
87
|
+
return parseBrowserSecret(text, file);
|
|
88
|
+
}
|
|
89
|
+
/** Cookie name binds the signed value to one host:port authority. */
|
|
90
|
+
export function dshCookieName(authority) {
|
|
91
|
+
return COOKIE_PREFIX + createHash('sha256').update(authority).digest('base64url');
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Mint a v1 browser cookie accepted by DSH's `/api` admission path.
|
|
95
|
+
*
|
|
96
|
+
* DSH verifies `v1.<base64url(payload)>.<base64url(hmac-sha256(body))>` with
|
|
97
|
+
* the durable browser-session secret. The short default lifetime is refreshed
|
|
98
|
+
* on the next request, so no cookie is persisted by lark-remote.
|
|
99
|
+
*/
|
|
100
|
+
export function mintDshBrowserCookie(baseUrl, secret, issuedAt = Date.now(), lifetimeMs = DSH_COOKIE_LIFETIME_MS) {
|
|
101
|
+
if (secret.byteLength !== SECRET_BYTES) {
|
|
102
|
+
throw new Error('DSH browser-session secret must be 32 bytes');
|
|
103
|
+
}
|
|
104
|
+
if (!Number.isSafeInteger(issuedAt) || !Number.isSafeInteger(lifetimeMs) || lifetimeMs <= 0) {
|
|
105
|
+
throw new Error('DSH browser cookie lifetime must be a positive safe integer');
|
|
106
|
+
}
|
|
107
|
+
const authority = new URL(baseUrl).host;
|
|
108
|
+
if (!authority)
|
|
109
|
+
throw new Error(`DSH host ${baseUrl} has no authority`);
|
|
110
|
+
const expiresAt = issuedAt + lifetimeMs;
|
|
111
|
+
if (!Number.isSafeInteger(expiresAt)) {
|
|
112
|
+
throw new Error('DSH browser cookie expiry exceeds the safe integer range');
|
|
113
|
+
}
|
|
114
|
+
const body = Buffer.from(JSON.stringify({ version: 1, authority, issuedAt, expiresAt }), 'utf-8').toString('base64url');
|
|
115
|
+
const signature = createHmac('sha256', secret).update(body).digest('base64url');
|
|
116
|
+
return `${dshCookieName(authority)}=v1.${body}.${signature}`;
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=auth.js.map
|
|
@@ -1,45 +1,126 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* DshClient — HTTP client for the DSH Web Host
|
|
2
|
+
* DshClient — HTTP/WebSocket client for the DSH Web Host.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* 1.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* DSH 0.2.x guardrails:
|
|
5
|
+
* 1. `/api/*` and `/api/remote.mux` require a signed `dsh-auth-*` cookie.
|
|
6
|
+
* We mint the v1 cookie from DSH's durable browser-session secret; the
|
|
7
|
+
* launch token remains a fallback via `GET /?token=<launch token>`.
|
|
8
|
+
* 2. Unary calls are POST `/api/<namespace>/<method>` envelopes whose
|
|
9
|
+
* `payload` is `{args:<request>}`.
|
|
10
|
+
* 3. Live session data is a logical stream over `/api/remote.mux`:
|
|
11
|
+
* `{type:'open',streamId,endpoint:'session/follow',payload}` →
|
|
12
|
+
* `{type:'item',streamId,value}` / `{type:'end',streamId}`.
|
|
13
|
+
* The Gateway-owned `$events` stream carries answerable
|
|
14
|
+
* `approval/request` waterfalls; answers go back through
|
|
15
|
+
* `$events/result`.
|
|
8
16
|
*
|
|
9
|
-
* The client is stateless apart from the base URL
|
|
10
|
-
* mux subscription and closes it on terminal.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* history, dedup by event seq).
|
|
17
|
+
* The client is stateless apart from the base URL and auth cookie: each
|
|
18
|
+
* `run()` opens its own remote mux subscription and closes it on terminal.
|
|
19
|
+
* `sessionEvents()` adds per-session filtering, reconnect + follow-snapshot
|
|
20
|
+
* replay, and seq-based dedup.
|
|
14
21
|
*/
|
|
15
|
-
import type { DshModelCatalogValue, DshModelSelection, DshPresetListValue,
|
|
22
|
+
import type { DshApprovalStreamItem, DshModelCatalogValue, DshModelSelection, DshPermissionCatalogValue, DshPresetListValue, DshRemoteEventOutcome, DshSessionPageValue, DshStreamItem, DshTokenUsage, DshCommandResult } from './types.js';
|
|
16
23
|
interface WebSocketMessageEvent {
|
|
17
24
|
data: unknown;
|
|
18
25
|
}
|
|
19
|
-
interface
|
|
26
|
+
export interface WebSocketOptions {
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
export interface WebSocketLike {
|
|
30
|
+
onopen: ((event: unknown) => void) | null;
|
|
20
31
|
onmessage: ((event: WebSocketMessageEvent) => void) | null;
|
|
21
32
|
onerror: ((event: unknown) => void) | null;
|
|
22
33
|
onclose: ((event: unknown) => void) | null;
|
|
34
|
+
/** Set by transports that expose an HTTP rejection before the handshake. */
|
|
35
|
+
onunexpectedresponse?: ((event: {
|
|
36
|
+
statusCode?: number;
|
|
37
|
+
}) => void) | null;
|
|
38
|
+
send(data: string): void;
|
|
23
39
|
close(): void;
|
|
24
40
|
}
|
|
25
|
-
export type WebSocketFactory = (url: string) => WebSocketLike;
|
|
41
|
+
export type WebSocketFactory = (url: string, options?: WebSocketOptions) => WebSocketLike;
|
|
26
42
|
export declare class DshError extends Error {
|
|
27
43
|
readonly code: string;
|
|
28
44
|
readonly details: Record<string, unknown>;
|
|
29
45
|
constructor(code: string, message: string, details?: Record<string, unknown>);
|
|
30
46
|
}
|
|
47
|
+
export interface DshClientOptions {
|
|
48
|
+
/** Configured DSH home; selects `.credentials.yaml` when no explicit file is given. */
|
|
49
|
+
dshHome?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Optional DSH Web launch token. Accepts either the bare token or the full
|
|
52
|
+
* URL printed by `dsh web` (the `token` query parameter is extracted).
|
|
53
|
+
*/
|
|
54
|
+
token?: string;
|
|
55
|
+
/**
|
|
56
|
+
* DSH credentials file containing the durable browser-session signing
|
|
57
|
+
* secret. Defaults to `$DSH_HOME/.credentials.yaml` or
|
|
58
|
+
* `~/.dsh/.credentials.yaml`. When readable, this is preferred over the
|
|
59
|
+
* per-process launch token and survives `dsh web` restarts.
|
|
60
|
+
*/
|
|
61
|
+
credentialsFile?: string;
|
|
62
|
+
/** Injectable credentials reader (tests). */
|
|
63
|
+
readCredentials?: (file: string) => Promise<Buffer>;
|
|
64
|
+
/** Injectable clock (tests). */
|
|
65
|
+
now?: () => number;
|
|
66
|
+
/** Lifetime of minted browser cookies. */
|
|
67
|
+
cookieLifetimeMs?: number;
|
|
68
|
+
fetchImpl?: typeof fetch;
|
|
69
|
+
webSocketImpl?: WebSocketFactory;
|
|
70
|
+
/**
|
|
71
|
+
* Unary request timeout. DSH accepts the HTTP connection but may never answer
|
|
72
|
+
* (e.g. a stuck `session/prompt`); without a timeout the run generator stays
|
|
73
|
+
* parked in `await unary()` forever, so /stop and the idle watchdog cannot
|
|
74
|
+
* release the bridge's workspace queue.
|
|
75
|
+
*/
|
|
76
|
+
requestTimeoutMs?: number;
|
|
77
|
+
}
|
|
78
|
+
/** Extract the `token` query parameter from a pasted `dsh web` URL. */
|
|
79
|
+
export declare function extractDshToken(raw: string | undefined): string;
|
|
80
|
+
export declare function dshArgs(method: string, payload: unknown): Record<string, unknown>;
|
|
31
81
|
export declare class DshClient {
|
|
32
82
|
readonly baseUrl: string;
|
|
83
|
+
private readonly token;
|
|
84
|
+
private readonly credentialsFile;
|
|
85
|
+
private readonly readCredentials;
|
|
86
|
+
private readonly now;
|
|
87
|
+
private readonly cookieLifetimeMs;
|
|
33
88
|
private readonly fetchImpl;
|
|
34
89
|
private readonly webSocketImpl;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
90
|
+
private readonly requestTimeoutMs;
|
|
91
|
+
private cookiePromise?;
|
|
92
|
+
constructor(baseUrl: string, options?: DshClientOptions);
|
|
93
|
+
/**
|
|
94
|
+
* Resolve a DSH browser cookie. The durable credentials file is read on every
|
|
95
|
+
* call so a rotated signing secret is picked up without restarting
|
|
96
|
+
* lark-remote. Token bootstrap remains a fallback for hosts without that
|
|
97
|
+
* file; unlike `/connect`, the index handshake mints a v1 cookie that DSH
|
|
98
|
+
* accepts for API and WebSocket requests across its own restarts.
|
|
99
|
+
*/
|
|
100
|
+
private authCookie;
|
|
101
|
+
/** Mint a v1 browser cookie from DSH's durable signing secret. */
|
|
102
|
+
private cookieFromCredentials;
|
|
103
|
+
/**
|
|
104
|
+
* Perform a unary POST and return the ok value; throws DshError on failure.
|
|
105
|
+
*
|
|
106
|
+
* `signal` aborts the request when the run is stopped; a timeout (default
|
|
107
|
+
* `requestTimeoutMs`) guards against a server that accepted the connection
|
|
108
|
+
* but never answers. Both are composed into the request's AbortSignal.
|
|
109
|
+
*/
|
|
110
|
+
unary(method: string, payload: unknown, opts?: {
|
|
111
|
+
signal?: AbortSignal;
|
|
112
|
+
timeoutMs?: number;
|
|
113
|
+
}): Promise<unknown>;
|
|
114
|
+
/** Distinguish "run stopped" (abort) from "server did not answer" (timeout). */
|
|
115
|
+
private abortError;
|
|
38
116
|
/** Create a session. Pass `agentPreset` to pin the session's preset (fixed at
|
|
39
117
|
* creation). */
|
|
40
118
|
createSession(payload: {
|
|
41
119
|
cwd: string;
|
|
42
120
|
agentPreset?: string;
|
|
121
|
+
}, opts?: {
|
|
122
|
+
signal?: AbortSignal;
|
|
123
|
+
timeoutMs?: number;
|
|
43
124
|
}): Promise<{
|
|
44
125
|
sessionId: string;
|
|
45
126
|
agentPreset?: string;
|
|
@@ -48,46 +129,84 @@ export declare class DshClient {
|
|
|
48
129
|
* server-side). Failures are surfaced as DshError for the caller to downgrade. */
|
|
49
130
|
selectModel(payload: DshModelSelection & {
|
|
50
131
|
sessionId: string;
|
|
132
|
+
}, opts?: {
|
|
133
|
+
signal?: AbortSignal;
|
|
134
|
+
timeoutMs?: number;
|
|
51
135
|
}): Promise<DshModelSelection>;
|
|
52
|
-
/** Read the model catalog (session
|
|
53
|
-
listModels(
|
|
54
|
-
|
|
55
|
-
|
|
136
|
+
/** Read the model catalog (session/modelCatalog). */
|
|
137
|
+
listModels(opts?: {
|
|
138
|
+
signal?: AbortSignal;
|
|
139
|
+
timeoutMs?: number;
|
|
140
|
+
}): Promise<DshModelCatalogValue>;
|
|
56
141
|
/**
|
|
57
|
-
*
|
|
142
|
+
* Execute one slash command against a session (commands/execute).
|
|
143
|
+
*
|
|
144
|
+
* This is the only way to run a DSH command: `session/prompt` turns the text
|
|
145
|
+
* into an ordinary user message and never routes it through the command
|
|
146
|
+
* registry, so `/compact` sent as a prompt would just be model input.
|
|
58
147
|
*
|
|
59
|
-
* The
|
|
60
|
-
*
|
|
61
|
-
*
|
|
148
|
+
* The RPC settles when the handler settles, not when it is accepted: DSH's
|
|
149
|
+
* `/compact` waits for the compaction to finish. Callers therefore must pass
|
|
150
|
+
* an explicit `timeoutMs` (0 = no unary budget) and rely on their own
|
|
151
|
+
* cancellation/watchdog instead of the 60s default.
|
|
62
152
|
*/
|
|
63
|
-
|
|
153
|
+
executeCommand(sessionId: string, line: string, opts?: {
|
|
154
|
+
signal?: AbortSignal;
|
|
155
|
+
timeoutMs?: number;
|
|
156
|
+
}): Promise<DshCommandResult | undefined>;
|
|
157
|
+
/** Read the preset catalog (agentPresets/list). */
|
|
158
|
+
listPresets(): Promise<DshPresetListValue>;
|
|
159
|
+
/** Read the permission preset catalog (permissionPresets/catalog). */
|
|
160
|
+
listPermissionPresets(): Promise<DshPermissionCatalogValue>;
|
|
161
|
+
/** Answer one forwarded approval waterfall ($events/result). */
|
|
162
|
+
answerRemoteEvent(clientId: string, eventId: string, outcome: DshRemoteEventOutcome, opts?: {
|
|
163
|
+
signal?: AbortSignal;
|
|
164
|
+
timeoutMs?: number;
|
|
165
|
+
}): Promise<void>;
|
|
64
166
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
167
|
+
* Return the highest event `seq` currently persisted for the session.
|
|
168
|
+
*
|
|
169
|
+
* `session/projections` is the unary watermark (the old `session.history`
|
|
170
|
+
* high-water scan is gone in 0.2.x). The caller snapshots this before
|
|
171
|
+
* `session/prompt` so a follow snapshot can skip already-completed turns.
|
|
67
172
|
*/
|
|
68
|
-
|
|
173
|
+
latestEventSeq(sessionId: string, opts?: {
|
|
174
|
+
signal?: AbortSignal;
|
|
175
|
+
timeoutMs?: number;
|
|
176
|
+
}): Promise<number>;
|
|
177
|
+
/** Read one cold history page. `throughSeq` normally comes from
|
|
178
|
+
* `latestEventSeq()`; `session/page` needs an inclusive cursor. */
|
|
179
|
+
page(sessionId: string, throughSeq: number, maxMessages: number, opts?: {
|
|
180
|
+
signal?: AbortSignal;
|
|
181
|
+
timeoutMs?: number;
|
|
182
|
+
}): Promise<DshSessionPageValue>;
|
|
69
183
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* `
|
|
73
|
-
* close/abort, letting `sessionEvents()` reconnect through the same
|
|
74
|
-
* history-replay loop.
|
|
184
|
+
* One logical stream over `/api/remote.mux`. The WebSocket must carry the
|
|
185
|
+
* same auth cookie as unary calls. Ends on the server's `end` frame, socket
|
|
186
|
+
* close, error, or `signal` abort.
|
|
75
187
|
*/
|
|
76
|
-
|
|
188
|
+
private muxStream;
|
|
189
|
+
/** One logical `session/follow` stream. */
|
|
190
|
+
private followStream;
|
|
77
191
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
192
|
+
* Consume the Gateway-owned `$events` stream. The stream is owner-only and
|
|
193
|
+
* answer-capable; each waterfall for another session is delegated with
|
|
194
|
+
* `next` so a different lark-remote workspace cannot park it.
|
|
81
195
|
*/
|
|
82
|
-
|
|
196
|
+
approvalEvents(sessionId: string, signal: AbortSignal): AsyncGenerator<DshApprovalStreamItem>;
|
|
197
|
+
/** Translate one durable event into a stream item (approvals are surfaced
|
|
198
|
+
* separately; everything else is forwarded to the runner). */
|
|
199
|
+
private eventItem;
|
|
83
200
|
/**
|
|
84
|
-
* Per-session subscription: yields session events
|
|
85
|
-
* `sessionId`, auto-reconnecting on stream
|
|
86
|
-
*
|
|
87
|
-
* or `signal` abort.
|
|
201
|
+
* Per-session subscription: yields session events, assistant stream chunks
|
|
202
|
+
* and approval notices for `sessionId`, auto-reconnecting on stream
|
|
203
|
+
* drop/error and replaying the follow snapshot (dedup by seq). Terminates on
|
|
204
|
+
* turn/end or `signal` abort.
|
|
88
205
|
*/
|
|
89
206
|
sessionEvents(sessionId: string, signal: AbortSignal, initialSeq?: number): AsyncGenerator<DshStreamItem>;
|
|
90
207
|
/** Extract TokenUsage from an assistant/message data payload (absent → undefined). */
|
|
91
208
|
static usageOf(data: Record<string, unknown>): DshTokenUsage | undefined;
|
|
92
209
|
}
|
|
210
|
+
/** Stable client-side prompt identity persisted on the accepted user message. */
|
|
211
|
+
export declare function newPromptRequestId(): string;
|
|
93
212
|
export {};
|