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
package/README.md
CHANGED
|
@@ -22,62 +22,20 @@ six primary `@pgmi-builds/agent-*` packages and three OPTIONAL SDK halves).
|
|
|
22
22
|
Restart the profile after replacing a loaded package. Each foreign world's
|
|
23
23
|
DSH home is created under `<DSH_HOME>/agents/<label>/` on first boot.
|
|
24
24
|
|
|
25
|
-
## Better Dash delivery
|
|
26
|
-
|
|
27
|
-
Super
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
fetch. This automatic local registration is separate from the user's single
|
|
37
|
-
installation command. Missing cached data reports a failure instead of falling
|
|
38
|
-
back to online fetching. Replacing loaded code or promoting loaded provenance
|
|
39
|
-
requires the documented cold restart.
|
|
40
|
-
|
|
41
|
-
The Plugin Manager shows both packages as parallel installed bundles with their
|
|
42
|
-
own YAML and independent switches. Better's Code source is `better-dsh@<version>`.
|
|
43
|
-
Updates retain disablement and same-version explicit uninstall; removing Super
|
|
44
|
-
retains the directly registered Better. Old `bundle-artifacts/` caches are left
|
|
45
|
-
intact for previous installations, but new releases do not write them. With Super
|
|
46
|
-
disabled, registration waits until Super is enabled. dev WebMCP is
|
|
47
|
-
separate and test-only.
|
|
48
|
-
|
|
49
|
-
Both packages use the same local dual-build pipeline. Tests serve the exact
|
|
50
|
-
unpublished artifact pair through a read-only local registry, without modifying
|
|
51
|
-
the release manifest or preinstalling Better. Current iteration: **0.3.0-f**.
|
|
52
|
-
|
|
53
|
-
For a tarball URL installation in the native Web UI, put the `.tgz` URL in the
|
|
54
|
-
main Add plugin input and keep the separate Registry selection on the official
|
|
55
|
-
npm registry. A tarball file server is not an npm registry.
|
|
56
|
-
|
|
57
|
-
Disabling Better restores native Web binding, trusted-host, login and onboarding
|
|
58
|
-
policies. Remote deployments must supply native DSH trusted-host configuration;
|
|
59
|
-
Super does not take ownership of those Better policies.
|
|
60
|
-
|
|
61
|
-
Better Dash owns the native DSH policies: `dashr-web-trust` retains cookie
|
|
62
|
-
authentication while accepting authenticated serving Host/Origin values; its
|
|
63
|
-
password page provides new-client login. Cookie expiry and authority binding remain.
|
|
64
|
-
`dashr-webserver` defaults to `0.0.0.0`; native `--port` and explicit loopback
|
|
65
|
-
`--host` overrides are preserved. Super Dash carries no duplicate WebTrust.
|
|
66
|
-
To restore the native address fence, set `dashr-web-trust`'s `trustAllHosts: false`.
|
|
67
|
-
|
|
68
|
-
Better Dash also disables automatic credential onboarding and acknowledges the
|
|
69
|
-
installed Models client's current welcome version before the listener opens.
|
|
70
|
-
Manual model configuration remains available. To restore native welcome behavior:
|
|
71
|
-
|
|
72
|
-
```yaml
|
|
73
|
-
- id: dashr-welcome-notice
|
|
74
|
-
disabled: true
|
|
75
|
-
```
|
|
25
|
+
## Better Dash delivery
|
|
26
|
+
|
|
27
|
+
Super installs [Better Dash](../better-dsh/) at runtime through the native DSH
|
|
28
|
+
Plugin Manager, requesting its installed Super version first, then `better-dsh@latest`, then bare
|
|
29
|
+
`better-dsh` after each preceding failure. The packages have no dependency edge or shared physical payload;
|
|
30
|
+
both retain independent native Plugin Manager rows and component switches.
|
|
31
|
+
|
|
32
|
+
First-use intent lives under `$DSH_HOME/superd/companions/`, so manual removal
|
|
33
|
+
remains removed across restarts and upgrades. Hub Settings offers explicit
|
|
34
|
+
reinstallation. Both packages ship at synchronized versions; publish Better
|
|
35
|
+
before Super. Pi's prebuilt SDK retains its separate bundled delivery.
|
|
76
36
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
entry. Dev WebMCP lives in the root `dev-webmcp/` directory and is included
|
|
80
|
-
only as an extra development profile bundle, never in either release tarball.
|
|
37
|
+
Better's welcome policy uses the official client onboarding slot. Native
|
|
38
|
+
WebServer and HMR stay in place; listener configuration belongs to deployment.
|
|
81
39
|
|
|
82
40
|
## Runtimes
|
|
83
41
|
|
package/client/index.js
CHANGED
|
@@ -2198,6 +2198,145 @@ async function removeProvider(agentKey, namespace, id, doFetch) {
|
|
|
2198
2198
|
return requireProvidersView(await callRuntimeSettings(agentKey, namespace, "remove", { request: { id } }, doFetch));
|
|
2199
2199
|
}
|
|
2200
2200
|
|
|
2201
|
+
//#endregion
|
|
2202
|
+
//#region src/client/companion-api.ts
|
|
2203
|
+
async function callCompanion(operation, doFetch) {
|
|
2204
|
+
const method = `superdCompanions/${operation}`;
|
|
2205
|
+
const response = await (doFetch ?? ((path, init) => fetch(path, init)))(`/api/${method}`, {
|
|
2206
|
+
method: "POST",
|
|
2207
|
+
credentials: "same-origin",
|
|
2208
|
+
headers: { "content-type": "application/json" },
|
|
2209
|
+
body: JSON.stringify({
|
|
2210
|
+
type: "client-request",
|
|
2211
|
+
rpcId: mintRpcId(),
|
|
2212
|
+
method,
|
|
2213
|
+
payload: { args: {} }
|
|
2214
|
+
})
|
|
2215
|
+
});
|
|
2216
|
+
if (!response.ok) throw new Error(`Better Dash: HTTP ${response.status}`);
|
|
2217
|
+
let body;
|
|
2218
|
+
try {
|
|
2219
|
+
body = JSON.parse(await response.text());
|
|
2220
|
+
} catch {
|
|
2221
|
+
throw new Error("Better Dash: unreadable response");
|
|
2222
|
+
}
|
|
2223
|
+
const result = body?.result;
|
|
2224
|
+
if (result?.ok !== true) throw new Error(result?.error?.message ?? "Better Dash: request failed");
|
|
2225
|
+
const value = result.value;
|
|
2226
|
+
if (value === null || typeof value !== "object" || typeof value.installed !== "boolean" || typeof value.installing !== "boolean" || typeof value.requestedVersion !== "string" || value.version !== void 0 && typeof value.version !== "string" || value.enabled !== void 0 && typeof value.enabled !== "boolean" || value.error !== void 0 && typeof value.error !== "string") throw new Error("Better Dash: invalid status");
|
|
2227
|
+
return value;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
//#endregion
|
|
2231
|
+
//#region src/client/CompanionSettings.tsx
|
|
2232
|
+
/** Native plugin switches remain in Plugin Manager; this row provides explicit recovery. */
|
|
2233
|
+
function CompanionSettings() {
|
|
2234
|
+
const [status, setStatus] = react.default.useState();
|
|
2235
|
+
const [error, setError] = react.default.useState();
|
|
2236
|
+
const [busy, setBusy] = react.default.useState(false);
|
|
2237
|
+
const live = react.default.useRef(false);
|
|
2238
|
+
const operation = react.default.useRef(false);
|
|
2239
|
+
react.default.useEffect(() => {
|
|
2240
|
+
live.current = true;
|
|
2241
|
+
let reading = false;
|
|
2242
|
+
const refresh = async () => {
|
|
2243
|
+
if (reading || operation.current) return;
|
|
2244
|
+
reading = true;
|
|
2245
|
+
try {
|
|
2246
|
+
const next = await callCompanion("getBetterDash");
|
|
2247
|
+
if (live.current) {
|
|
2248
|
+
setStatus(next);
|
|
2249
|
+
setError(next.error);
|
|
2250
|
+
}
|
|
2251
|
+
} catch (cause) {
|
|
2252
|
+
if (live.current) setError(cause instanceof Error ? cause.message : String(cause));
|
|
2253
|
+
} finally {
|
|
2254
|
+
reading = false;
|
|
2255
|
+
}
|
|
2256
|
+
};
|
|
2257
|
+
refresh();
|
|
2258
|
+
const timer = window.setInterval(() => {
|
|
2259
|
+
refresh();
|
|
2260
|
+
}, 5e3);
|
|
2261
|
+
return () => {
|
|
2262
|
+
live.current = false;
|
|
2263
|
+
window.clearInterval(timer);
|
|
2264
|
+
};
|
|
2265
|
+
}, []);
|
|
2266
|
+
const install = async () => {
|
|
2267
|
+
if (operation.current) return;
|
|
2268
|
+
operation.current = true;
|
|
2269
|
+
setBusy(true);
|
|
2270
|
+
setError(void 0);
|
|
2271
|
+
try {
|
|
2272
|
+
const next = await callCompanion("installBetterDash");
|
|
2273
|
+
if (live.current) {
|
|
2274
|
+
setStatus(next);
|
|
2275
|
+
setError(next.error);
|
|
2276
|
+
}
|
|
2277
|
+
} catch (cause) {
|
|
2278
|
+
if (live.current) setError(cause instanceof Error ? cause.message : String(cause));
|
|
2279
|
+
} finally {
|
|
2280
|
+
operation.current = false;
|
|
2281
|
+
if (live.current) setBusy(false);
|
|
2282
|
+
}
|
|
2283
|
+
};
|
|
2284
|
+
const installing = busy || status?.installing === true;
|
|
2285
|
+
const label = status === void 0 ? "Loading…" : status.installed ? `Installed${status.version ? " · " + status.version : ""}${status.enabled === false ? " · Disabled" : ""}` : "Not installed";
|
|
2286
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2287
|
+
style: {
|
|
2288
|
+
borderRadius: 12,
|
|
2289
|
+
padding: "12px 14px",
|
|
2290
|
+
background: "var(--dsw-alias-interactive-bg-hover)"
|
|
2291
|
+
},
|
|
2292
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2293
|
+
style: {
|
|
2294
|
+
display: "flex",
|
|
2295
|
+
alignItems: "center",
|
|
2296
|
+
gap: 16
|
|
2297
|
+
},
|
|
2298
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2299
|
+
style: { flex: 1 },
|
|
2300
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2301
|
+
style: {
|
|
2302
|
+
fontSize: 14,
|
|
2303
|
+
fontWeight: 600
|
|
2304
|
+
},
|
|
2305
|
+
children: "Better Dash"
|
|
2306
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2307
|
+
role: "status",
|
|
2308
|
+
style: {
|
|
2309
|
+
fontSize: 12,
|
|
2310
|
+
color: "var(--dsw-alias-label-secondary)",
|
|
2311
|
+
marginTop: 4
|
|
2312
|
+
},
|
|
2313
|
+
children: installing ? "Installing…" : label
|
|
2314
|
+
})]
|
|
2315
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2316
|
+
type: "button",
|
|
2317
|
+
disabled: installing || status?.installed === true,
|
|
2318
|
+
onClick: () => {
|
|
2319
|
+
install();
|
|
2320
|
+
},
|
|
2321
|
+
style: {
|
|
2322
|
+
padding: "6px 12px",
|
|
2323
|
+
borderRadius: 8,
|
|
2324
|
+
cursor: installing || status?.installed ? "default" : "pointer"
|
|
2325
|
+
},
|
|
2326
|
+
children: installing ? "Installing…" : status?.installed ? "Installed" : "Install"
|
|
2327
|
+
})]
|
|
2328
|
+
}), error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2329
|
+
role: "alert",
|
|
2330
|
+
style: {
|
|
2331
|
+
fontSize: 12,
|
|
2332
|
+
margin: "8px 0 0",
|
|
2333
|
+
color: "var(--dsw-alias-state-error-primary)"
|
|
2334
|
+
},
|
|
2335
|
+
children: error
|
|
2336
|
+
})]
|
|
2337
|
+
});
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2201
2340
|
//#endregion
|
|
2202
2341
|
//#region src/client/HubPage.tsx
|
|
2203
2342
|
const EMPTY_DRAFT = {
|
|
@@ -3415,16 +3554,8 @@ function HubPage() {
|
|
|
3415
3554
|
},
|
|
3416
3555
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(GroupHead, {
|
|
3417
3556
|
title: "Settings",
|
|
3418
|
-
count:
|
|
3419
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(
|
|
3420
|
-
style: {
|
|
3421
|
-
margin: 0,
|
|
3422
|
-
fontSize: 13,
|
|
3423
|
-
lineHeight: "20px",
|
|
3424
|
-
color: "var(--dsw-alias-label-tertiary)"
|
|
3425
|
-
},
|
|
3426
|
-
children: "Hub preferences — pairing, runtime visibility, and orchestration defaults land with the hub service."
|
|
3427
|
-
})]
|
|
3557
|
+
count: 1
|
|
3558
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CompanionSettings, {})]
|
|
3428
3559
|
}),
|
|
3429
3560
|
tab === "home" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
3430
3561
|
style: {
|
package/cordis.patch.yml
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
# zero-port sibling root, mounted under /<label>
|
|
13
13
|
# - super-dsh/join the generic hub joiner for the standalone
|
|
14
14
|
# claude adapter (R2)
|
|
15
|
-
# DSH-only WebTrust
|
|
16
|
-
# Better Dash
|
|
15
|
+
# DSH-only WebTrust and onboarding policies belong to independently installed
|
|
16
|
+
# Better Dash; the native Plugin Manager owns its package and enablement.
|
|
17
17
|
# - super-dsh/a2a the agent-facing A2A tool face (mesh P3): the
|
|
18
18
|
# single `superd-hub` model tool on ctx0's tools
|
|
19
19
|
# service (dormant without tools; world trees
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
# Profile-layer values (webserver port/host, settings path) stay in the USER's
|
|
23
23
|
# profile patch layer, never here.
|
|
24
24
|
- insert:
|
|
25
|
-
#
|
|
25
|
+
# Expose native companion management and perform first-use installation.
|
|
26
26
|
- id: superd-bundle-setup
|
|
27
27
|
name: 'super-dsh/setup'
|
|
28
28
|
# BARE package row: subpath rows (…/agents/omp) are permanently not client
|
|
@@ -51,6 +51,9 @@
|
|
|
51
51
|
# get their own injection in P5, spec §10). Dormant without tools.
|
|
52
52
|
# Off: restate with `disabled: true` or drop the row — nothing else
|
|
53
53
|
# registers the tool.
|
|
54
|
+
# Independent build of Better's canonical remote source; default-on per Agent.
|
|
55
|
+
- id: superd-remote
|
|
56
|
+
name: 'super-dsh/remote'
|
|
54
57
|
- id: superd-a2a
|
|
55
58
|
name: 'super-dsh/a2a'
|
|
56
59
|
- id: agent-omp
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "super-dsh",
|
|
3
|
-
"version": "0.3.0-
|
|
3
|
+
"version": "0.3.0-g",
|
|
4
4
|
"description": "Super D for the DeepSeek Harness (DSH) — one dsh plugin that mounts the superd hub (runtime selector, roster, per-runtime agents) and joins local foreign-agent runtimes (OMP, Codex, Claude, Pi, Hermes, Antigravity) into the DSH Web UI. Each runtime keeps its native home and session store; the hub only bridges.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,7 +27,11 @@
|
|
|
27
27
|
"./package.json": "./package.json",
|
|
28
28
|
"./fusion-transport": "./superd-hub/dist/fusion-transport.js",
|
|
29
29
|
"./a2a": "./superd-hub/dist/a2a-row.js",
|
|
30
|
-
"./setup": "./superd-hub/dist/bundle-setup.js"
|
|
30
|
+
"./setup": "./superd-hub/dist/bundle-setup.js",
|
|
31
|
+
"./remote": {
|
|
32
|
+
"types": "./remote/plugin.d.ts",
|
|
33
|
+
"default": "./remote/plugin.js"
|
|
34
|
+
}
|
|
31
35
|
},
|
|
32
36
|
"files": [
|
|
33
37
|
"cordis.patch.yml",
|
|
@@ -69,7 +73,7 @@
|
|
|
69
73
|
"agent-agy-sdk/package.json",
|
|
70
74
|
"agent-agy-sdk/cordis.patch.yml",
|
|
71
75
|
"agent-agy-sdk/bridge",
|
|
72
|
-
"
|
|
76
|
+
"remote"
|
|
73
77
|
],
|
|
74
78
|
"dsh": {
|
|
75
79
|
"bundle": {
|
|
@@ -83,7 +87,6 @@
|
|
|
83
87
|
}
|
|
84
88
|
},
|
|
85
89
|
"dependencies": {
|
|
86
|
-
"better-dsh": "0.3.0-e",
|
|
87
90
|
"@earendil-works/pi-coding-agent": "0.84.2",
|
|
88
91
|
"zod": "4.4.3",
|
|
89
92
|
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
@@ -108,7 +111,8 @@
|
|
|
108
111
|
"@deepseek-ai/dsh-app-boot": ">=0.1.5-0 <0.3.0-0",
|
|
109
112
|
"@deepseek-ai/dsh-atomic-write": ">=0.1.5-0 <0.3.0-0",
|
|
110
113
|
"@deepseek-ai/dsh-cmdline": ">=0.1.5-0 <0.3.0-0",
|
|
111
|
-
"@deepseek-ai/dsh-plugin-manager": ">=0.1.5-0 <0.3.0-0"
|
|
114
|
+
"@deepseek-ai/dsh-plugin-manager": ">=0.1.5-0 <0.3.0-0",
|
|
115
|
+
"@deepseek-ai/dsh-jobs": ">=0.1.5-0 <0.3.0-0"
|
|
112
116
|
},
|
|
113
117
|
"peerDependenciesMeta": {
|
|
114
118
|
"@deepseek-ai/cordis": {
|
|
@@ -170,6 +174,9 @@
|
|
|
170
174
|
},
|
|
171
175
|
"@deepseek-ai/dsh-plugin-manager": {
|
|
172
176
|
"optional": true
|
|
177
|
+
},
|
|
178
|
+
"@deepseek-ai/dsh-jobs": {
|
|
179
|
+
"optional": true
|
|
173
180
|
}
|
|
174
181
|
},
|
|
175
182
|
"publishConfig": {
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { constants, createReadStream, createWriteStream, mkdirSync } from "node:fs";
|
|
2
|
+
import { pipeline } from "node:stream/promises";
|
|
3
|
+
import { lstat, mkdir, mkdtemp, open, readdir, readlink, rename, rm, symlink } from "node:fs/promises";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { dirname, join, posix, resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
//#region src/remote/archive.ts
|
|
10
|
+
const FILE_FRAME_BYTES = 64 * 1024;
|
|
11
|
+
const FILE_MAX_ENTRIES = 1e4;
|
|
12
|
+
const FILE_MAX_METADATA = 8 * 1024 * 1024;
|
|
13
|
+
const order = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
|
|
14
|
+
function checkRelative(p) {
|
|
15
|
+
if (typeof p !== "string" || Buffer.byteLength(p) > 4096 || p.includes("\0") || p !== "" && (p.startsWith("/") || p.split("/").some((s) => !s || s === "." || s === ".."))) throw new Error("unsafe archive relative path");
|
|
16
|
+
}
|
|
17
|
+
function checkAbsolute(p) {
|
|
18
|
+
if (typeof p !== "string" || !p.startsWith("/") || p.includes("\0") || p.split("/").includes("..")) throw new Error("file endpoint requires an absolute path without traversal or NUL");
|
|
19
|
+
}
|
|
20
|
+
function checkSelection(selection) {
|
|
21
|
+
if (selection.length > FILE_MAX_ENTRIES || Buffer.byteLength(JSON.stringify(selection)) > FILE_MAX_METADATA) throw new Error("file selection metadata limit exceeded");
|
|
22
|
+
const seen = /* @__PURE__ */ new Set();
|
|
23
|
+
for (const p of selection) {
|
|
24
|
+
checkRelative(p);
|
|
25
|
+
if (seen.has(p)) throw new Error("duplicate selection path");
|
|
26
|
+
seen.add(p);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function checkLink(e) {
|
|
30
|
+
const link = e.link;
|
|
31
|
+
if (e.p === "" || typeof link !== "string" || !link || link.includes("\0") || link.startsWith("/") || Buffer.byteLength(link) > 4096) throw new Error("unsafe archive symlink");
|
|
32
|
+
const target = posix.normalize(posix.join(posix.dirname(e.p), link));
|
|
33
|
+
if (target === ".." || target.startsWith("../")) throw new Error("unsafe archive symlink escape");
|
|
34
|
+
}
|
|
35
|
+
/** Conservative effective resolution: links may traverse only members of this incoming tree.
|
|
36
|
+
* Preserved destination extras cannot supply a symlink target or any of its ancestors.
|
|
37
|
+
*/
|
|
38
|
+
function checkTreeLinks(entries) {
|
|
39
|
+
const incoming = new Map(entries.map((e) => [e.p, e]));
|
|
40
|
+
for (const link of entries.filter((e) => e.t === "l")) {
|
|
41
|
+
const stack = posix.dirname(link.p) === "." ? [] : posix.dirname(link.p).split("/");
|
|
42
|
+
let pending = link.link.split("/"), hops = 0;
|
|
43
|
+
while (pending.length) {
|
|
44
|
+
const part = pending.shift();
|
|
45
|
+
if (!part || part === ".") continue;
|
|
46
|
+
if (part === "..") {
|
|
47
|
+
if (!stack.length) throw new Error("unsafe symlink chain escape");
|
|
48
|
+
stack.pop();
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const entry = incoming.get([...stack, part].join("/"));
|
|
52
|
+
if (!entry) throw new Error("unsafe symlink target outside incoming tree (destination aliases are not followed)");
|
|
53
|
+
if (entry.t === "l") {
|
|
54
|
+
if (++hops > 40) throw new Error("unsafe symlink cycle or chain limit");
|
|
55
|
+
pending = [...entry.link.split("/"), ...pending];
|
|
56
|
+
} else {
|
|
57
|
+
if (pending.length && entry.t !== "d") throw new Error("unsafe symlink non-directory target ancestor");
|
|
58
|
+
stack.push(part);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function header(e) {
|
|
64
|
+
const b = Buffer.from(JSON.stringify(e)), n = Buffer.alloc(4);
|
|
65
|
+
n.writeUInt32BE(b.length);
|
|
66
|
+
return Buffer.concat([n, b]);
|
|
67
|
+
}
|
|
68
|
+
function manifestDigest(entries) {
|
|
69
|
+
const hash = createHash("sha256");
|
|
70
|
+
let bytes = 0;
|
|
71
|
+
for (const e of [...entries].sort((a, b) => order(a.p, b.p))) {
|
|
72
|
+
hash.update(header([
|
|
73
|
+
e.p,
|
|
74
|
+
e.t,
|
|
75
|
+
e.n,
|
|
76
|
+
e.t === "f" ? e.hash : e.t === "l" ? e.link : ""
|
|
77
|
+
]));
|
|
78
|
+
if (e.t === "f") bytes += e.n;
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
sha256: hash.digest("hex"),
|
|
82
|
+
bytes,
|
|
83
|
+
entries: entries.length
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Reject even existing parent symlinks; leaf may be an explicitly copied symlink. */
|
|
87
|
+
async function ancestors(path, includeLeaf = false) {
|
|
88
|
+
checkAbsolute(path);
|
|
89
|
+
const parts = resolve(path).split("/").filter(Boolean);
|
|
90
|
+
for (let i = 1; i <= parts.length - (includeLeaf ? 0 : 1); i++) try {
|
|
91
|
+
const st = await lstat("/" + parts.slice(0, i).join("/"));
|
|
92
|
+
if (st.isSymbolicLink() || !st.isDirectory()) throw new Error("unsafe destination/source ancestor: symlink or non-directory");
|
|
93
|
+
} catch (e) {
|
|
94
|
+
if (e.code !== "ENOENT") throw e;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function safeRead(path, signal) {
|
|
98
|
+
return (await open(path, constants.O_RDONLY | constants.O_NOFOLLOW)).createReadStream({
|
|
99
|
+
autoClose: true,
|
|
100
|
+
highWaterMark: FILE_FRAME_BYTES,
|
|
101
|
+
signal
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
async function entryAt(root, p, signal) {
|
|
105
|
+
signal.throwIfAborted();
|
|
106
|
+
const path = p ? join(root, p) : root;
|
|
107
|
+
await ancestors(path);
|
|
108
|
+
const st = await lstat(path);
|
|
109
|
+
if (st.isDirectory()) return {
|
|
110
|
+
p,
|
|
111
|
+
t: "d",
|
|
112
|
+
n: 0
|
|
113
|
+
};
|
|
114
|
+
if (st.isSymbolicLink()) {
|
|
115
|
+
const e = {
|
|
116
|
+
p,
|
|
117
|
+
t: "l",
|
|
118
|
+
n: 0,
|
|
119
|
+
link: await readlink(path)
|
|
120
|
+
};
|
|
121
|
+
checkLink(e);
|
|
122
|
+
return e;
|
|
123
|
+
}
|
|
124
|
+
if (!st.isFile()) throw new Error("unsupported file type (only files, directories and safe relative symlinks)");
|
|
125
|
+
const hash = createHash("sha256");
|
|
126
|
+
for await (const chunk of await safeRead(path, signal)) hash.update(chunk);
|
|
127
|
+
return {
|
|
128
|
+
p,
|
|
129
|
+
t: "f",
|
|
130
|
+
n: st.size,
|
|
131
|
+
hash: hash.digest("hex")
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
async function tree(root, signal, selection) {
|
|
135
|
+
checkAbsolute(root);
|
|
136
|
+
const entries = [];
|
|
137
|
+
const visit = async (p) => {
|
|
138
|
+
const e = await entryAt(root, p, signal);
|
|
139
|
+
entries.push(e);
|
|
140
|
+
if (entries.length > FILE_MAX_ENTRIES) throw new Error("archive entry limit exceeded");
|
|
141
|
+
if (e.t === "d") for (const name of (await readdir(p ? join(root, p) : root)).sort(order)) await visit(p ? `${p}/${name}` : name);
|
|
142
|
+
};
|
|
143
|
+
if (selection) {
|
|
144
|
+
checkSelection(selection);
|
|
145
|
+
for (const p of selection) entries.push(await entryAt(root, p, signal));
|
|
146
|
+
} else await visit("");
|
|
147
|
+
if (Buffer.byteLength(JSON.stringify(entries)) > FILE_MAX_METADATA) throw new Error("archive metadata limit exceeded");
|
|
148
|
+
return entries;
|
|
149
|
+
}
|
|
150
|
+
async function inspectArchive(path, signal) {
|
|
151
|
+
const file = await open(path, "r");
|
|
152
|
+
let offset = 0, metadata = 0;
|
|
153
|
+
const entries = [];
|
|
154
|
+
const seen = /* @__PURE__ */ new Map();
|
|
155
|
+
const exact = async (n) => {
|
|
156
|
+
const b = Buffer.alloc(n);
|
|
157
|
+
let at = 0;
|
|
158
|
+
while (at < n) {
|
|
159
|
+
signal.throwIfAborted();
|
|
160
|
+
const r = await file.read(b, at, n - at, offset);
|
|
161
|
+
if (!r.bytesRead) throw new Error("truncated binary archive");
|
|
162
|
+
at += r.bytesRead;
|
|
163
|
+
offset += r.bytesRead;
|
|
164
|
+
}
|
|
165
|
+
return b;
|
|
166
|
+
};
|
|
167
|
+
try {
|
|
168
|
+
while (true) {
|
|
169
|
+
const size = (await exact(4)).readUInt32BE();
|
|
170
|
+
if (!size || size > FILE_FRAME_BYTES) throw new Error("archive header limit exceeded");
|
|
171
|
+
metadata += size;
|
|
172
|
+
if (metadata > FILE_MAX_METADATA) throw new Error("archive metadata limit exceeded");
|
|
173
|
+
const e = JSON.parse((await exact(size)).toString("utf8"));
|
|
174
|
+
if (e.end === true) {
|
|
175
|
+
if ((await file.stat()).size !== offset) throw new Error("trailing archive bytes");
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
checkRelative(e.p);
|
|
179
|
+
if (![
|
|
180
|
+
"f",
|
|
181
|
+
"d",
|
|
182
|
+
"l"
|
|
183
|
+
].includes(e.t) || !Number.isSafeInteger(e.n) || e.n < 0 || e.t !== "f" && e.n !== 0) throw new Error("unsafe archive type/size");
|
|
184
|
+
if (entries.length >= FILE_MAX_ENTRIES || seen.has(e.p)) throw new Error("archive entry limit or duplicate path");
|
|
185
|
+
if (e.p !== "" && seen.get(posix.dirname(e.p) === "." ? "" : posix.dirname(e.p))?.t !== "d") throw new Error("unsafe archive parent ordering/type");
|
|
186
|
+
if (!entries.length && e.p !== "") throw new Error("archive root missing");
|
|
187
|
+
if (e.t === "l") checkLink(e);
|
|
188
|
+
e.offset = offset;
|
|
189
|
+
if (e.t === "f") {
|
|
190
|
+
const hash = createHash("sha256");
|
|
191
|
+
let left = e.n;
|
|
192
|
+
while (left) {
|
|
193
|
+
const chunk = await exact(Math.min(FILE_FRAME_BYTES, left));
|
|
194
|
+
hash.update(chunk);
|
|
195
|
+
left -= chunk.length;
|
|
196
|
+
}
|
|
197
|
+
e.hash = hash.digest("hex");
|
|
198
|
+
}
|
|
199
|
+
entries.push(e);
|
|
200
|
+
seen.set(e.p, e);
|
|
201
|
+
}
|
|
202
|
+
if (!entries.length) throw new Error("archive root missing");
|
|
203
|
+
return entries;
|
|
204
|
+
} finally {
|
|
205
|
+
await file.close();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function importArchive(destination, payload, mkdirParents, signal, resources) {
|
|
209
|
+
checkAbsolute(destination);
|
|
210
|
+
const entries = await inspectArchive(payload, signal);
|
|
211
|
+
checkTreeLinks(entries);
|
|
212
|
+
for (const e of entries) {
|
|
213
|
+
const path = e.p ? join(destination, e.p) : destination;
|
|
214
|
+
await ancestors(path);
|
|
215
|
+
try {
|
|
216
|
+
const st = await lstat(path);
|
|
217
|
+
const type = st.isSymbolicLink() ? "l" : st.isDirectory() ? "d" : st.isFile() ? "f" : "?";
|
|
218
|
+
if (type !== e.t || type === "l") throw new Error("destination path/type conflict or existing symlink");
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (error.code !== "ENOENT") throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
await ancestors(destination);
|
|
224
|
+
if (mkdirParents) await mkdir(dirname(destination), { recursive: true });
|
|
225
|
+
for (const e of entries) {
|
|
226
|
+
signal.throwIfAborted();
|
|
227
|
+
const path = e.p ? join(destination, e.p) : destination;
|
|
228
|
+
await ancestors(path);
|
|
229
|
+
if (e.t === "d") {
|
|
230
|
+
await mkdir(path, { recursive: false }).catch((error) => {
|
|
231
|
+
if (error.code !== "EEXIST") throw error;
|
|
232
|
+
});
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (e.t === "l") {
|
|
236
|
+
await symlink(e.link, path);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const temporary = join(dirname(path), `.remote-copy-${randomUUID()}`);
|
|
240
|
+
await resources.temporary?.(temporary, true);
|
|
241
|
+
try {
|
|
242
|
+
const output = createWriteStream(temporary, {
|
|
243
|
+
flags: "wx",
|
|
244
|
+
mode: 384
|
|
245
|
+
});
|
|
246
|
+
await pipeline(e.n ? createReadStream(payload, {
|
|
247
|
+
start: e.offset,
|
|
248
|
+
end: e.offset + e.n - 1
|
|
249
|
+
}) : Readable.from([]), output, { signal });
|
|
250
|
+
await ancestors(path);
|
|
251
|
+
signal.throwIfAborted();
|
|
252
|
+
await rename(temporary, path);
|
|
253
|
+
} finally {
|
|
254
|
+
await rm(temporary, { force: true });
|
|
255
|
+
await resources.temporary?.(temporary, false);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function localFileEndpoint(resources = {}) {
|
|
260
|
+
return {
|
|
261
|
+
async exportTree(path, signal) {
|
|
262
|
+
const entries = await tree(path, signal);
|
|
263
|
+
return Readable.from((async function* () {
|
|
264
|
+
for (const e of entries) {
|
|
265
|
+
signal.throwIfAborted();
|
|
266
|
+
yield header({
|
|
267
|
+
p: e.p,
|
|
268
|
+
t: e.t,
|
|
269
|
+
n: e.n,
|
|
270
|
+
...e.link === void 0 ? {} : { link: e.link }
|
|
271
|
+
});
|
|
272
|
+
if (e.t === "f" && e.n) for await (const chunk of await safeRead(e.p ? join(path, e.p) : path, signal)) yield chunk;
|
|
273
|
+
}
|
|
274
|
+
yield header({ end: true });
|
|
275
|
+
})());
|
|
276
|
+
},
|
|
277
|
+
async importTree(path, source, { mkdir: parents, signal }) {
|
|
278
|
+
checkAbsolute(path);
|
|
279
|
+
const scratch = resources.receiveRoot ?? await mkdtemp(join(tmpdir(), "remote-file-receive-"));
|
|
280
|
+
try {
|
|
281
|
+
const payload = join(scratch, "payload");
|
|
282
|
+
await pipeline(source, createWriteStream(payload, {
|
|
283
|
+
flags: "wx",
|
|
284
|
+
mode: 384
|
|
285
|
+
}), { signal });
|
|
286
|
+
await importArchive(path, payload, parents, signal, resources);
|
|
287
|
+
} finally {
|
|
288
|
+
source.destroy?.();
|
|
289
|
+
await rm(scratch, {
|
|
290
|
+
recursive: true,
|
|
291
|
+
force: true
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
async digest(path, signal, selection) {
|
|
296
|
+
return manifestDigest(await tree(path, signal, selection));
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
//#endregion
|
|
302
|
+
//#region src/remote/file-worker.ts
|
|
303
|
+
async function runFileWorker(request) {
|
|
304
|
+
const abort = new AbortController(), cancel = () => abort.abort(/* @__PURE__ */ new Error("binary file worker cancelled"));
|
|
305
|
+
process.on("SIGTERM", cancel);
|
|
306
|
+
process.on("SIGHUP", cancel);
|
|
307
|
+
const report = (value) => new Promise((resolve$1, reject) => process.stderr.write(JSON.stringify(value) + "\n", (error) => error ? reject(error) : resolve$1()));
|
|
308
|
+
try {
|
|
309
|
+
mkdirSync(request.root, { mode: 448 });
|
|
310
|
+
const endpoint = localFileEndpoint({
|
|
311
|
+
receiveRoot: request.root,
|
|
312
|
+
temporary: (path, active) => report({
|
|
313
|
+
temporary: path,
|
|
314
|
+
active
|
|
315
|
+
})
|
|
316
|
+
});
|
|
317
|
+
if (request.action === "export") await pipeline(await endpoint.exportTree(request.path, abort.signal), process.stdout, { signal: abort.signal });
|
|
318
|
+
else if (request.action === "import") await endpoint.importTree(request.path, process.stdin, {
|
|
319
|
+
mkdir: request.mkdir,
|
|
320
|
+
signal: abort.signal
|
|
321
|
+
});
|
|
322
|
+
else {
|
|
323
|
+
let metadata = "";
|
|
324
|
+
process.stdin.setEncoding("utf8");
|
|
325
|
+
for await (const chunk of process.stdin) {
|
|
326
|
+
metadata += String(chunk);
|
|
327
|
+
if (Buffer.byteLength(metadata) > FILE_MAX_METADATA) throw new Error("file selection metadata limit exceeded");
|
|
328
|
+
}
|
|
329
|
+
process.stdout.write(JSON.stringify(await endpoint.digest(request.path, abort.signal, request.selected ? JSON.parse(metadata) : void 0)));
|
|
330
|
+
}
|
|
331
|
+
} catch (error) {
|
|
332
|
+
await report({ error: error instanceof Error ? error.message : String(error) }).catch(() => {});
|
|
333
|
+
process.exitCode = 1;
|
|
334
|
+
} finally {
|
|
335
|
+
process.removeListener("SIGTERM", cancel);
|
|
336
|
+
process.removeListener("SIGHUP", cancel);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
//#endregion
|
|
341
|
+
export { runFileWorker };
|