super-dsh 0.3.0-e → 0.3.0-g
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 +13 -55
- package/client/index.js +141 -10
- package/cordis.patch.yml +6 -3
- package/package.json +12 -5
- package/remote/file-worker.js +341 -0
- package/remote/plugin.d.ts +523 -0
- package/remote/plugin.js +4979 -0
- package/remote/provenance.json +64 -0
- package/superd-hub/dist/a2a-row.d.ts +18 -2
- package/superd-hub/dist/a2a-row.js +11 -69
- package/superd-hub/dist/bundle-setup.d.ts +16 -6
- package/superd-hub/dist/bundle-setup.js +84 -148
- package/superd-hub/dist/client/companion-api.d.ts +5 -0
- package/superd-hub/dist/client/companion-api.js +30 -0
- package/superd-hub/dist/companion-contract.d.ts +9 -0
- package/superd-hub/dist/companion-contract.js +1 -0
- package/superd-hub/dist/companions.d.ts +30 -0
- package/superd-hub/dist/companions.js +209 -0
- package/superd-hub/dist/exec-pty-framing.d.ts +3 -1
- package/superd-hub/dist/exec-pty-framing.js +12 -4
- package/superd-hub/dist/exec-pty-session.d.ts +2 -0
- package/superd-hub/dist/exec-pty-session.js +7 -13
- package/superd-hub/dist/exec.d.ts +5 -2
- package/superd-hub/dist/exec.js +34 -54
- package/superd-hub/dist/fusion-transport.js +1 -0
- package/superd-hub/dist/gateway.js +24 -11
- package/superd-hub/dist/hub-tool.d.ts +3 -0
- package/superd-hub/dist/hub-tool.js +13 -5
- package/superd-hub/dist/remote-file-rpc.d.ts +20 -0
- package/superd-hub/dist/remote-file-rpc.js +574 -0
- package/superd-hub/dist/remote-job-client.d.ts +3 -0
- package/superd-hub/dist/remote-job-client.js +197 -0
- package/superd-hub/dist/remote-job-rpc.d.ts +21 -0
- package/superd-hub/dist/remote-job-rpc.js +155 -0
- package/superd-hub/dist/remote-provider.d.ts +13 -0
- package/superd-hub/dist/remote-provider.js +151 -0
- package/superd-hub/dist/remote-row.d.ts +6 -0
- package/superd-hub/dist/remote-row.js +13 -0
- package/superd-hub/lib/client/index.js +141 -10
- package/superd-hub/package.json +1 -2
- package/superd-hub/scripts/register-companion.mjs +0 -100
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/** Companion intent and the single native installation operation. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readFile, mkdir, unlink, rmdir } from 'node:fs/promises';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
|
|
6
|
+
// done marks a CONFIRMED first use (Better observed installed). A receipt with attempted
|
|
7
|
+
// but neither done nor error is an interrupted first use — e.g. killed by the restart
|
|
8
|
+
// that installing Super itself triggers — and must re-arm, not suppress, the retry.
|
|
9
|
+
const message = (error) => error instanceof Error ? error.message : String(error);
|
|
10
|
+
async function optionalJson(path) {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error.code === 'ENOENT')
|
|
16
|
+
return undefined;
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function nativeFailure(result) {
|
|
21
|
+
if ((result.application === 'applied' || result.application === 'restart-required') && result.error === undefined
|
|
22
|
+
&& (result.packageResult === undefined || result.packageResult.exitCode === 0))
|
|
23
|
+
return undefined;
|
|
24
|
+
return [result.application, result.error?.code, result.error?.diagnostic,
|
|
25
|
+
result.packageResult?.kind, result.packageResult?.logPath].filter(Boolean).join(': ');
|
|
26
|
+
}
|
|
27
|
+
/** One controller per native profile; no package, registry, HMR or selection writer of its own. */
|
|
28
|
+
export class BetterDashCompanion {
|
|
29
|
+
version;
|
|
30
|
+
manager;
|
|
31
|
+
directory;
|
|
32
|
+
receipt;
|
|
33
|
+
legacy;
|
|
34
|
+
initialized;
|
|
35
|
+
intent = { attempted: false };
|
|
36
|
+
active;
|
|
37
|
+
stopped = false;
|
|
38
|
+
restartNotice;
|
|
39
|
+
constructor(profile, version, manager) {
|
|
40
|
+
this.version = version;
|
|
41
|
+
this.manager = manager;
|
|
42
|
+
const key = createHash('sha256').update(profile.dir).digest('hex') + '.json';
|
|
43
|
+
this.directory = join(profile.home, 'superd', 'companions');
|
|
44
|
+
this.receipt = join(this.directory, key);
|
|
45
|
+
this.legacy = join(profile.home, 'companion-registration', key);
|
|
46
|
+
}
|
|
47
|
+
initialize() {
|
|
48
|
+
return this.initialized ??= (async () => {
|
|
49
|
+
const saved = await optionalJson(this.receipt);
|
|
50
|
+
if (saved !== undefined) {
|
|
51
|
+
// Never-resurrect applies only to a CONFIRMED first use or a recorded
|
|
52
|
+
// failure; an interrupted first use (no done, no error) re-arms.
|
|
53
|
+
this.intent = saved.attempted && saved.done !== true && saved.error === undefined
|
|
54
|
+
? { attempted: false } : saved;
|
|
55
|
+
await this.cleanLegacy();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const old = await optionalJson(this.legacy);
|
|
59
|
+
// Any old registration was first use. An absent Better now means removal,
|
|
60
|
+
// including when Super has since changed version; never resurrect it.
|
|
61
|
+
if (old?.['super-dsh:better-dsh'] !== undefined) {
|
|
62
|
+
this.intent = { attempted: true };
|
|
63
|
+
await this.save();
|
|
64
|
+
await this.cleanLegacy();
|
|
65
|
+
}
|
|
66
|
+
})();
|
|
67
|
+
}
|
|
68
|
+
async cleanLegacy() {
|
|
69
|
+
try {
|
|
70
|
+
await unlink(this.legacy);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error.code !== 'ENOENT')
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
await rmdir(dirname(this.legacy));
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (!['ENOENT', 'ENOTEMPTY'].includes(error.code ?? ''))
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async save() {
|
|
85
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
86
|
+
await writeFileAtomic(this.receipt, JSON.stringify(this.intent) + '\n', { mode: 0o600 });
|
|
87
|
+
}
|
|
88
|
+
async getBetterDash() {
|
|
89
|
+
const status = { installed: false, installing: this.active !== undefined, requestedVersion: this.version };
|
|
90
|
+
try {
|
|
91
|
+
await this.initialize();
|
|
92
|
+
const manager = this.manager();
|
|
93
|
+
if (manager === undefined)
|
|
94
|
+
throw new Error('Native Plugin Manager is unavailable; enable it and retry Install in Settings.');
|
|
95
|
+
const better = (await manager.listBundles()).find(bundle => bundle.name === 'better-dsh' && bundle.installed);
|
|
96
|
+
if (better !== undefined) {
|
|
97
|
+
status.installed = true;
|
|
98
|
+
if (better.version !== undefined)
|
|
99
|
+
status.version = better.version;
|
|
100
|
+
status.enabled = better.enabled;
|
|
101
|
+
// An installed dependency alone does not prove application succeeded:
|
|
102
|
+
// native configure can fail after writing that dependency. Only a
|
|
103
|
+
// missing -> installed recovery clears a previous failure here.
|
|
104
|
+
if (this.active === undefined && this.intent.attempted
|
|
105
|
+
&& (this.intent.done !== true || (this.intent.error !== undefined && this.intent.missingAfterFailure === true))) {
|
|
106
|
+
this.intent.done = true;
|
|
107
|
+
delete this.intent.error;
|
|
108
|
+
delete this.intent.missingAfterFailure;
|
|
109
|
+
await this.save();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
this.restartNotice = undefined;
|
|
114
|
+
if (this.active === undefined && this.intent.error !== undefined && this.intent.missingAfterFailure !== true) {
|
|
115
|
+
this.intent.missingAfterFailure = true;
|
|
116
|
+
await this.save();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const error = this.intent.error ?? this.restartNotice;
|
|
120
|
+
if (error !== undefined)
|
|
121
|
+
status.error = error;
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
status.error = message(error);
|
|
125
|
+
}
|
|
126
|
+
return status;
|
|
127
|
+
}
|
|
128
|
+
ensureAutomatic() { return this.run(false); }
|
|
129
|
+
installBetterDash() { return this.run(true); }
|
|
130
|
+
run(manual) {
|
|
131
|
+
if (this.active !== undefined)
|
|
132
|
+
return this.active;
|
|
133
|
+
const task = this.install(manual).catch(async (error) => {
|
|
134
|
+
this.intent.error = `Better Dash installation failed: ${message(error)}. Check Better Dash in Plugin Manager; if missing, retry Install in Settings.`;
|
|
135
|
+
const status = await this.getBetterDash();
|
|
136
|
+
this.intent.missingAfterFailure = !status.installed;
|
|
137
|
+
try {
|
|
138
|
+
await this.save();
|
|
139
|
+
}
|
|
140
|
+
catch { /* Report the operation error even when persistence is unavailable. */ }
|
|
141
|
+
return status;
|
|
142
|
+
}).then(status => ({ ...status, installing: false })).finally(() => { this.active = undefined; });
|
|
143
|
+
this.active = task;
|
|
144
|
+
return task;
|
|
145
|
+
}
|
|
146
|
+
async install(manual) {
|
|
147
|
+
await this.initialize();
|
|
148
|
+
const before = await this.getBetterDash();
|
|
149
|
+
if (this.stopped)
|
|
150
|
+
return before;
|
|
151
|
+
if (before.installed) {
|
|
152
|
+
this.intent.attempted = true;
|
|
153
|
+
this.intent.done = true;
|
|
154
|
+
if (this.intent.missingAfterFailure === true) {
|
|
155
|
+
delete this.intent.error;
|
|
156
|
+
delete this.intent.missingAfterFailure;
|
|
157
|
+
}
|
|
158
|
+
await this.save();
|
|
159
|
+
return this.getBetterDash(); // Native disablement and installed versions belong to the user.
|
|
160
|
+
}
|
|
161
|
+
// Native changed notifications enter through this operation, so its
|
|
162
|
+
// read is active and deliberately cannot update the receipt itself.
|
|
163
|
+
// Record removal here even when first-use installation is already done.
|
|
164
|
+
if (this.intent.error !== undefined && this.intent.missingAfterFailure !== true) {
|
|
165
|
+
this.intent.missingAfterFailure = true;
|
|
166
|
+
await this.save();
|
|
167
|
+
}
|
|
168
|
+
if (!manual && this.intent.attempted)
|
|
169
|
+
return before;
|
|
170
|
+
this.intent = { attempted: true };
|
|
171
|
+
await this.save(); // First use is durable before native HMR can unload this row.
|
|
172
|
+
const manager = this.manager();
|
|
173
|
+
if (manager === undefined)
|
|
174
|
+
throw new Error('Native Plugin Manager is unavailable');
|
|
175
|
+
if (this.version === 'unknown' || this.version.trim() === '')
|
|
176
|
+
throw new Error('The installed Super Dash package version could not be read');
|
|
177
|
+
const failures = [];
|
|
178
|
+
for (const spec of [`better-dsh@${this.version}`, 'better-dsh@latest', 'better-dsh']) {
|
|
179
|
+
if (this.stopped)
|
|
180
|
+
break;
|
|
181
|
+
try {
|
|
182
|
+
const result = await manager.installBundle(spec);
|
|
183
|
+
const failure = nativeFailure(result);
|
|
184
|
+
if (failure === undefined) {
|
|
185
|
+
const after = await this.getBetterDash();
|
|
186
|
+
if (after.installed) {
|
|
187
|
+
this.intent.done = true;
|
|
188
|
+
delete this.intent.error;
|
|
189
|
+
delete this.intent.missingAfterFailure;
|
|
190
|
+
this.restartNotice = result.application === 'restart-required'
|
|
191
|
+
? 'Better Dash is installed; restart DSH to apply the native change.' : undefined;
|
|
192
|
+
await this.save();
|
|
193
|
+
return this.getBetterDash();
|
|
194
|
+
}
|
|
195
|
+
failures.push(`${spec}: native manager reported ${result.application}, but Better Dash is not installed`);
|
|
196
|
+
}
|
|
197
|
+
else
|
|
198
|
+
failures.push(`${spec}: ${failure}`);
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
failures.push(`${spec}: ${message(error)}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
throw new Error(failures.join('; ') || 'Super Dash companion component was unloaded');
|
|
205
|
+
}
|
|
206
|
+
/** Read-only observation: an intermediate native event must never trigger reinstatement. */
|
|
207
|
+
async observeNativeChange() { return this.getBetterDash(); }
|
|
208
|
+
dispose() { this.stopped = true; }
|
|
209
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OutputStats } from '../../remote/plugin.js';
|
|
1
2
|
/** 收帧帧体:退出码 + 只读回传的 cwd(Non-Goal ②:报告不重建)。 */
|
|
2
3
|
export interface PtyFrame {
|
|
3
4
|
exit: number;
|
|
@@ -11,9 +12,10 @@ export declare function wrapPtyCommand(cmd: string, nonce: string): string;
|
|
|
11
12
|
export declare function buildInitCommand(nonce: string): string;
|
|
12
13
|
export declare function stripAnsi(s: string): string;
|
|
13
14
|
export declare function normalizePtyText(s: string): string;
|
|
14
|
-
export declare function tailWindow(text: string, cap: number): {
|
|
15
|
+
export declare function tailWindow(text: string, cap: number, stats?: OutputStats): {
|
|
15
16
|
text: string;
|
|
16
17
|
truncated?: number;
|
|
18
|
+
stats?: OutputStats;
|
|
17
19
|
};
|
|
18
20
|
export interface FrameParserEvents {
|
|
19
21
|
/** 输出文本:feed 路径经 normalize(剥 ANSI/规整换行);flush 路径为**裸放出**(残余字节原样,勿假设已剥洗)。 */
|
|
@@ -23,10 +23,18 @@ export function stripAnsi(s) {
|
|
|
23
23
|
export function normalizePtyText(s) {
|
|
24
24
|
return stripAnsi(s).replace(/\r\n/g, '\n').replace(/\r/g, '');
|
|
25
25
|
}
|
|
26
|
-
export function tailWindow(text, cap) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
export function tailWindow(text, cap, stats) {
|
|
27
|
+
let tail = text.slice(-cap);
|
|
28
|
+
if (tail.charCodeAt(0) >= 0xdc00 && tail.charCodeAt(0) <= 0xdfff)
|
|
29
|
+
tail = tail.slice(1);
|
|
30
|
+
const returned = stats?.unit === 'bytes' ? Buffer.byteLength(tail) : tail.length;
|
|
31
|
+
const total = stats?.total ?? text.length;
|
|
32
|
+
const updated = stats === undefined ? {} : { stats: { ...stats, returned } };
|
|
33
|
+
if (total <= returned)
|
|
34
|
+
return { text: tail, ...updated };
|
|
35
|
+
const unit = stats?.unit === 'bytes' ? 'bytes' : 'chars';
|
|
36
|
+
const eviction = stats !== undefined && stats.dropped > 0 ? `; retained ${stats.retained}, dropped head ${stats.dropped}, retention limit ${stats.retentionLimit} ${stats.unit === 'bytes' ? 'bytes' : 'UTF-16 code units'}` : '';
|
|
37
|
+
return { text: `[truncated: showing last ${returned} of ${total} ${unit}${eviction}]\n${tail}`, truncated: total, ...updated };
|
|
30
38
|
}
|
|
31
39
|
const FRAME_BODY_RE = /^(\d+);([^\x07\x1b]*)(?:\x07|\x1b\\)/; // cwd 是尾字段:合法路径可含 `;`,只有 BEL/ESC 终止它
|
|
32
40
|
/**
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type StreamOutputStats } from '../../remote/plugin.js';
|
|
1
2
|
import type { PtyChannel } from './exec-pty-channel.js';
|
|
2
3
|
/** spec §五.2 逐字。 */
|
|
3
4
|
export declare const RECONNECT_NOTICE = "[remote: session reconnected to fresh shell; cwd reset to default]";
|
|
@@ -15,6 +16,7 @@ export interface PtyDispatchOptions {
|
|
|
15
16
|
}
|
|
16
17
|
export interface PtyDispatchResult {
|
|
17
18
|
output: string;
|
|
19
|
+
outputStats?: StreamOutputStats;
|
|
18
20
|
exit: number | null;
|
|
19
21
|
cwd: string | null;
|
|
20
22
|
timedOut: boolean;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Utf16TailRing } from '../../remote/plugin.js';
|
|
1
2
|
// PTY session machinery — PORTED from better-dsh src/remote/pty-session.ts
|
|
2
3
|
// (spec §8.2 / D12 "原样照抄"), with the ONE sanctioned adaptation: the
|
|
3
4
|
// subprocess spawn is replaced by the PtyChannel seam (exec-pty-channel.ts) —
|
|
@@ -41,12 +42,12 @@ export class PtySession {
|
|
|
41
42
|
this.inFlight++;
|
|
42
43
|
return await new Promise((resolve) => {
|
|
43
44
|
const nonce = genNonce();
|
|
44
|
-
|
|
45
|
+
const ring = new Utf16TailRing(OUTPUT_HARD_CAP);
|
|
45
46
|
let interrupted = false;
|
|
46
47
|
let settled = false;
|
|
47
48
|
let interruptTimer;
|
|
48
49
|
let killTimer;
|
|
49
|
-
const deathWaiter = () => finish({ output, exit: null, cwd: null, timedOut: interrupted });
|
|
50
|
+
const deathWaiter = () => finish({ output: ring.text, exit: null, cwd: null, timedOut: interrupted });
|
|
50
51
|
const cleanup = () => {
|
|
51
52
|
this.onChunk = null;
|
|
52
53
|
// I1(终审):已结算 dispatch 的死亡等待必须摘除——否则闭包钉住整段 output,
|
|
@@ -65,20 +66,13 @@ export class PtySession {
|
|
|
65
66
|
this.inFlight--;
|
|
66
67
|
this.lastSettleAt = Date.now();
|
|
67
68
|
this.resetIdleTimer();
|
|
68
|
-
resolve({ ...r, reconnected, durationMs: Date.now() - started });
|
|
69
|
+
resolve({ ...r, outputStats: { stdout: ring.stats() }, reconnected, durationMs: Date.now() - started });
|
|
69
70
|
};
|
|
70
71
|
const parser = createNonceFrameParser(nonce, {
|
|
71
72
|
onOutput: (t) => {
|
|
72
|
-
|
|
73
|
-
// C1(终审):内存安全帽——无帽时一条 cat /dev/urandom 能在 120s 看门狗内
|
|
74
|
-
// 吃垮宿主 daemon。帽上切片避开代理对(首字符为低位代理则让出一字符)。
|
|
75
|
-
if (output.length > OUTPUT_HARD_CAP) {
|
|
76
|
-
output = output.slice(output.length - OUTPUT_HARD_CAP);
|
|
77
|
-
if (output.charCodeAt(0) >= 0xdc00 && output.charCodeAt(0) <= 0xdfff)
|
|
78
|
-
output = output.slice(1);
|
|
79
|
-
}
|
|
73
|
+
ring.push(t);
|
|
80
74
|
},
|
|
81
|
-
onFrame: ({ exit, cwd }) => finish({ output, exit, cwd, timedOut: interrupted }),
|
|
75
|
+
onFrame: ({ exit, cwd }) => finish({ output: ring.text, exit, cwd, timedOut: interrupted }),
|
|
82
76
|
});
|
|
83
77
|
this.onChunk = (c) => parser.feed(c);
|
|
84
78
|
this.write(wrapPtyCommand(cmd, nonce));
|
|
@@ -97,7 +91,7 @@ export class PtySession {
|
|
|
97
91
|
// (否则 trap-INT 的卡死进程永生,重连后旧组再不可达)
|
|
98
92
|
this.opts.channel.kill();
|
|
99
93
|
this.markDead();
|
|
100
|
-
finish({ output, exit: null, cwd: null, timedOut: true });
|
|
94
|
+
finish({ output: ring.text, exit: null, cwd: null, timedOut: true });
|
|
101
95
|
}, INTERRUPT_GRACE_MS);
|
|
102
96
|
}, dOpts.timeoutSec === undefined ? 120_000 : dOpts.timeoutSec * 1_000);
|
|
103
97
|
this.deathWaiters.push(deathWaiter);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { type StreamOutputStats } from '../../remote/plugin.js';
|
|
1
2
|
import type { A2aLogFn } from './a2a-log.js';
|
|
2
3
|
/** Shared-channel Fetch route path this API owns. */
|
|
3
4
|
export declare const EXEC_API_BASE = "/api/superd/exec";
|
|
4
5
|
export interface OneShotExecInput {
|
|
5
6
|
cmd: string;
|
|
7
|
+
stdin?: string;
|
|
6
8
|
cwd?: string;
|
|
7
9
|
timeoutSec?: number;
|
|
8
10
|
}
|
|
@@ -12,6 +14,7 @@ export interface OneShotExecResult {
|
|
|
12
14
|
stderr: string;
|
|
13
15
|
durationMs: number;
|
|
14
16
|
truncated: boolean;
|
|
17
|
+
outputStats?: StreamOutputStats;
|
|
15
18
|
timedOut: boolean;
|
|
16
19
|
/**
|
|
17
20
|
* Why the process ended (0.1.7-e, first-person finding F1/F2: `exit:null`
|
|
@@ -24,9 +27,9 @@ export interface OneShotExecResult {
|
|
|
24
27
|
*/
|
|
25
28
|
outcome: 'exited' | 'signaled' | 'timeout' | 'spawn-error';
|
|
26
29
|
}
|
|
27
|
-
/** Per-stream tail
|
|
30
|
+
/** Per-stream returned tail: at most 30,000 bytes, beginning on a UTF8 boundary. */
|
|
28
31
|
export declare const EXEC_TAIL_WINDOW = 30000;
|
|
29
|
-
/**
|
|
32
|
+
/** Hub oneshot per-stream raw-byte retention: 4MiB, distinct from native/PTy UTF16 caps. */
|
|
30
33
|
export declare const EXEC_RING_HARD_CAP: number;
|
|
31
34
|
export declare const EXEC_DEFAULT_TIMEOUT_SEC = 120;
|
|
32
35
|
export declare const EXEC_MAX_TIMEOUT_SEC = 600;
|
package/superd-hub/dist/exec.js
CHANGED
|
@@ -12,11 +12,12 @@
|
|
|
12
12
|
// process GROUP, 2s grace, SIGKILL to the group — `bash -lc` children must
|
|
13
13
|
// not outlive their parent's timeout.
|
|
14
14
|
import { spawn } from 'node:child_process';
|
|
15
|
+
import { ByteTailRing } from '../../remote/plugin.js';
|
|
15
16
|
/** Shared-channel Fetch route path this API owns. */
|
|
16
17
|
export const EXEC_API_BASE = '/api/superd/exec';
|
|
17
|
-
/** Per-stream tail
|
|
18
|
+
/** Per-stream returned tail: at most 30,000 bytes, beginning on a UTF8 boundary. */
|
|
18
19
|
export const EXEC_TAIL_WINDOW = 30_000;
|
|
19
|
-
/**
|
|
20
|
+
/** Hub oneshot per-stream raw-byte retention: 4MiB, distinct from native/PTy UTF16 caps. */
|
|
20
21
|
export const EXEC_RING_HARD_CAP = 4 * 1024 * 1024;
|
|
21
22
|
export const EXEC_DEFAULT_TIMEOUT_SEC = 120;
|
|
22
23
|
export const EXEC_MAX_TIMEOUT_SEC = 600;
|
|
@@ -44,7 +45,9 @@ export function parseOneShotExecBody(body) {
|
|
|
44
45
|
if (raw.timeoutSec !== undefined && (typeof raw.timeoutSec !== 'number' || !Number.isInteger(raw.timeoutSec) || raw.timeoutSec < 1 || raw.timeoutSec > EXEC_MAX_TIMEOUT_SEC)) {
|
|
45
46
|
return { ok: false, error: `exec: timeoutSec must be an integer in [1, ${EXEC_MAX_TIMEOUT_SEC}]` };
|
|
46
47
|
}
|
|
47
|
-
|
|
48
|
+
if (raw.stdin !== undefined && typeof raw.stdin !== 'string')
|
|
49
|
+
return { ok: false, error: 'exec: stdin must be a string' };
|
|
50
|
+
const known = new Set(['cmd', 'cwd', 'timeoutSec', 'stdin']);
|
|
48
51
|
for (const key of Object.keys(raw)) {
|
|
49
52
|
if (!known.has(key))
|
|
50
53
|
return { ok: false, error: `exec: unknown field "${key}"` };
|
|
@@ -53,71 +56,29 @@ export function parseOneShotExecBody(body) {
|
|
|
53
56
|
ok: true,
|
|
54
57
|
value: {
|
|
55
58
|
cmd: raw.cmd,
|
|
59
|
+
...(raw.stdin === undefined ? {} : { stdin: raw.stdin }),
|
|
56
60
|
...(raw.cwd === undefined ? {} : { cwd: raw.cwd }),
|
|
57
61
|
...(raw.timeoutSec === undefined ? {} : { timeoutSec: raw.timeoutSec }),
|
|
58
62
|
},
|
|
59
63
|
};
|
|
60
64
|
}
|
|
61
|
-
/**
|
|
62
|
-
* A capped tail-retaining accumulator: `total` counts every byte ever seen,
|
|
63
|
-
* `ring` keeps at most EXEC_RING_HARD_CAP of the most recent ones.
|
|
64
|
-
*/
|
|
65
|
-
class TailRing {
|
|
66
|
-
total = 0;
|
|
67
|
-
chunks = [];
|
|
68
|
-
retained = 0;
|
|
69
|
-
push(chunk) {
|
|
70
|
-
this.total += chunk.length;
|
|
71
|
-
this.chunks.push(chunk);
|
|
72
|
-
this.retained += chunk.length;
|
|
73
|
-
if (this.retained > EXEC_RING_HARD_CAP) {
|
|
74
|
-
let drop = this.retained - EXEC_RING_HARD_CAP;
|
|
75
|
-
while (drop > 0 && this.chunks.length > 0) {
|
|
76
|
-
const head = this.chunks[0];
|
|
77
|
-
if (head.length <= drop) {
|
|
78
|
-
drop -= head.length;
|
|
79
|
-
this.retained -= head.length;
|
|
80
|
-
this.chunks.shift();
|
|
81
|
-
}
|
|
82
|
-
else {
|
|
83
|
-
this.chunks[0] = head.subarray(drop);
|
|
84
|
-
this.retained -= drop;
|
|
85
|
-
drop = 0;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
tail(maxBytes) {
|
|
91
|
-
if (this.retained <= maxBytes)
|
|
92
|
-
return Buffer.concat(this.chunks);
|
|
93
|
-
let skip = this.retained - maxBytes;
|
|
94
|
-
const out = [];
|
|
95
|
-
for (const chunk of this.chunks) {
|
|
96
|
-
if (skip >= chunk.length) {
|
|
97
|
-
skip -= chunk.length;
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
out.push(skip === 0 ? chunk : chunk.subarray(skip));
|
|
101
|
-
skip = 0;
|
|
102
|
-
}
|
|
103
|
-
return Buffer.concat(out);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
65
|
/**
|
|
107
66
|
* Execute one command locally: `bash -lc <cmd>`, detached into its own
|
|
108
67
|
* process group so the timeout's group kill cannot leak to the hub process.
|
|
109
68
|
*/
|
|
110
69
|
export async function runOneShotExec(input, log) {
|
|
111
70
|
const started = Date.now();
|
|
112
|
-
const stdout = new
|
|
113
|
-
const stderr = new
|
|
71
|
+
const stdout = new ByteTailRing(EXEC_RING_HARD_CAP);
|
|
72
|
+
const stderr = new ByteTailRing(EXEC_RING_HARD_CAP);
|
|
114
73
|
const timeoutSec = input.timeoutSec ?? EXEC_DEFAULT_TIMEOUT_SEC;
|
|
115
74
|
let terminationRequested = false;
|
|
116
75
|
const child = spawn('bash', ['-lc', input.cmd], {
|
|
117
76
|
...(input.cwd === undefined ? {} : { cwd: input.cwd }),
|
|
118
77
|
detached: true,
|
|
119
|
-
stdio: ['
|
|
78
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
120
79
|
});
|
|
80
|
+
child.stdin.on('error', () => { });
|
|
81
|
+
child.stdin.end(input.stdin);
|
|
121
82
|
child.stdout.on('data', (chunk) => stdout.push(chunk));
|
|
122
83
|
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
|
123
84
|
let graceTimer;
|
|
@@ -148,10 +109,11 @@ export async function runOneShotExec(input, log) {
|
|
|
148
109
|
clearTimeout(killTimer);
|
|
149
110
|
if (graceTimer !== undefined)
|
|
150
111
|
clearTimeout(graceTimer);
|
|
151
|
-
const
|
|
112
|
+
const outTail = stdout.tail(EXEC_TAIL_WINDOW), errTail = stderr.tail(EXEC_TAIL_WINDOW);
|
|
113
|
+
const outText = outTail.toString('utf8');
|
|
152
114
|
const errText = spawnError !== undefined
|
|
153
115
|
? `exec: spawn failed — ${spawnError}${input.cwd === undefined ? '' : ` (cwd: ${input.cwd})`}`
|
|
154
|
-
:
|
|
116
|
+
: errTail.toString('utf8');
|
|
155
117
|
const timedOut = terminationRequested;
|
|
156
118
|
const outcome = spawnError !== undefined
|
|
157
119
|
? 'spawn-error'
|
|
@@ -165,7 +127,8 @@ export async function runOneShotExec(input, log) {
|
|
|
165
127
|
stdout: outText,
|
|
166
128
|
stderr: errText,
|
|
167
129
|
durationMs: Date.now() - started,
|
|
168
|
-
truncated: stdout.total >
|
|
130
|
+
truncated: stdout.total > outTail.length || stderr.total > errTail.length,
|
|
131
|
+
outputStats: { stdout: stdout.stats(outTail.length), stderr: stderr.stats(errTail.length) },
|
|
169
132
|
timedOut,
|
|
170
133
|
outcome,
|
|
171
134
|
};
|
|
@@ -221,5 +184,22 @@ export async function postOneShotExec(origin, input, opts = {}) {
|
|
|
221
184
|
truncated: raw.truncated === true,
|
|
222
185
|
timedOut,
|
|
223
186
|
outcome,
|
|
187
|
+
...(parseOutputStats(raw.outputStats) === undefined ? {} : { outputStats: parseOutputStats(raw.outputStats) }),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/** Older peers omit metrics: do not derive exact totals from their returned tail. */
|
|
191
|
+
function parseOutputStats(value) {
|
|
192
|
+
if (typeof value !== 'object' || value === null)
|
|
193
|
+
return undefined;
|
|
194
|
+
const raw = value;
|
|
195
|
+
const valid = (stats) => {
|
|
196
|
+
if (typeof stats !== 'object' || stats === null)
|
|
197
|
+
return false;
|
|
198
|
+
const s = stats;
|
|
199
|
+
return (s.unit === 'bytes' || s.unit === 'utf16') && [s.total, s.retained, s.dropped, s.returned, s.retentionLimit].every(n => Number.isSafeInteger(n) && n >= 0)
|
|
200
|
+
&& s.total === s.retained + s.dropped && s.retained <= s.retentionLimit && s.returned <= s.retained;
|
|
224
201
|
};
|
|
202
|
+
if (!valid(raw.stdout) || (raw.stderr !== undefined && !valid(raw.stderr)))
|
|
203
|
+
return undefined;
|
|
204
|
+
return { stdout: raw.stdout, ...(raw.stderr === undefined ? {} : { stderr: raw.stderr }) };
|
|
225
205
|
}
|
|
@@ -132,6 +132,7 @@ try {
|
|
|
132
132
|
// is active.
|
|
133
133
|
const isExempt = (path) => path === CONTROL || path.startsWith(CONTROL + '/')
|
|
134
134
|
|| path === CONTROL_PREFIX || path.startsWith(CONTROL_PREFIX + '/')
|
|
135
|
+
|| path === '/api/superdCompanions' || path.startsWith('/api/superdCompanions/')
|
|
135
136
|
// Key compare matches the server demux shape: searchParams.get('agent')
|
|
136
137
|
// looks up PERCENT-DECODED keys, so '%61gent=x' demuxes as agent=x and the
|
|
137
138
|
// surgery must strip those spellings too (+' ' wins the '+' spelling).
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { registerRemoteFileRpc } from './remote-file-rpc.js';
|
|
2
|
+
import { installHubRemoteTransport } from './remote-row.js';
|
|
1
3
|
import z from '@deepseek-ai/schemastery';
|
|
2
4
|
import { registerSelectorRpc, registryTargets } from './rpc.js';
|
|
3
5
|
import { registerMeshRpc } from './mesh-rpc.js';
|
|
@@ -60,6 +62,7 @@ export function apply(ctx, config) {
|
|
|
60
62
|
const real = host.get('webServer');
|
|
61
63
|
if (real instanceof WorldWebServer)
|
|
62
64
|
return;
|
|
65
|
+
installHubRemoteTransport(host, { home });
|
|
63
66
|
registerPairingAuth(host);
|
|
64
67
|
const connection = host.get('connection');
|
|
65
68
|
// The gate is ctx0's authorize source (connection.authorizeIndex); the
|
|
@@ -82,24 +85,34 @@ export function apply(ctx, config) {
|
|
|
82
85
|
// would multiply probes against every paired remote. The effect's
|
|
83
86
|
// disposer IS the sweep disposer — the timer dies with the row
|
|
84
87
|
// (unref'd, overlap-skipping, per-machine errors swallowed; mesh-link).
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
host.effect(() => startReprobeSweep({ home, intervalSec: config?.reprobeIntervalSec, immediate: true }), 'superd-hub: mesh reprobe sweep');
|
|
89
|
+
host.effect(() => registerHubRoute({
|
|
87
90
|
real: real,
|
|
88
91
|
authorize: (req, res) => connection.authorizeIndex(req, res),
|
|
89
92
|
requestRejection: (req) => connection.requestRejection(req),
|
|
90
93
|
gatewayOf: (label) => (federatedGatewayOf(label) ?? getTarget(label)?.gateway),
|
|
91
|
-
});
|
|
92
|
-
ctx.effect(() => dispose, 'superd-hub: /hub route family');
|
|
94
|
+
}), 'superd-hub: /hub route family');
|
|
93
95
|
// P4 §8.2: the PTY executor's upgrade route — same gate as the /hub family
|
|
94
96
|
// (authorizeIndex cookie auth first, then the browser-trust fence). The
|
|
95
|
-
// dumb pump lives in exec-pty-rpc.ts;
|
|
96
|
-
|
|
97
|
+
// dumb pump lives in exec-pty-rpc.ts; remote-provider owns the shared initiator implementation.
|
|
98
|
+
// The async effect belongs to this Connection injection: disposal waits
|
|
99
|
+
// for setup and releases its route even when setup finishes during unload.
|
|
100
|
+
host.effect(() => registerRemoteFileRpc(real, {
|
|
97
101
|
authorize: (req, res) => connection.authorizeIndex(req, res),
|
|
98
|
-
requestRejection:
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
requestRejection: req => connection.requestRejection(req),
|
|
103
|
+
}), 'superd-hub: binary file upgrade route');
|
|
104
|
+
host.effect(async () => {
|
|
105
|
+
try {
|
|
106
|
+
return await registerPtyExecutor(real, {
|
|
107
|
+
authorize: (req, res) => connection.authorizeIndex(req, res),
|
|
108
|
+
requestRejection: (req) => connection.requestRejection(req),
|
|
109
|
+
log: consoleA2aLog(),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return () => { };
|
|
114
|
+
}
|
|
115
|
+
}, 'superd-hub: PTY executor upgrade route');
|
|
103
116
|
});
|
|
104
117
|
}
|
|
105
118
|
export default apply;
|
|
@@ -18,6 +18,7 @@ export interface HubToolExecFace {
|
|
|
18
18
|
*/
|
|
19
19
|
pty(sessionKey: string, machine: string, cmd: string, opts?: {
|
|
20
20
|
timeoutSec?: number;
|
|
21
|
+
stdin?: string;
|
|
21
22
|
}): Promise<import('./exec-pty-session.js').PtyDispatchResult>;
|
|
22
23
|
}
|
|
23
24
|
import type { A2aAddress, A2aInitiator, A2aService } from './a2a-service.js';
|
|
@@ -71,6 +72,8 @@ export interface HubRosterView {
|
|
|
71
72
|
}
|
|
72
73
|
/** Everything the factory needs, all injected (pure module — see header). */
|
|
73
74
|
export interface HubToolDeps {
|
|
75
|
+
/** Explicit compatibility opt-in, never applied to receiving peer executors. */
|
|
76
|
+
localExec?: boolean;
|
|
74
77
|
/** The A2A conversation service (T2 core behind T3's host assembly). */
|
|
75
78
|
service: HubToolService;
|
|
76
79
|
/** T3's delivery face: live-agent routing + the bounded unread buffer. */
|