@hope_phenom/dsh-plugin-remote-use 0.0.0-stage → 0.2.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +207 -2
- package/README.zh.md +195 -0
- package/assets/icon.svg +47 -0
- package/cordis.patch.yml +41 -0
- package/lib/bridge/acp-bridge.js +905 -0
- package/lib/bridge/peer-connection.js +730 -0
- package/lib/client.js +2842 -0
- package/lib/core/acp-model-overlay.js +121 -0
- package/lib/core/approval-queue.js +216 -0
- package/lib/core/audit-log.js +322 -0
- package/lib/core/desktop-notify.js +173 -0
- package/lib/core/environment-prompt.js +76 -0
- package/lib/core/relay-supervisor.js +278 -0
- package/lib/core/review-engine.js +679 -0
- package/lib/core/sandbox-diagnosis.js +258 -0
- package/lib/core/sandbox-probe.js +310 -0
- package/lib/core/self-acp-spec.js +117 -0
- package/lib/core/selfcheck-runner.js +150 -0
- package/lib/core/settings-store.js +226 -0
- package/lib/core/shot-gc.js +334 -0
- package/lib/core/task-cwd.js +50 -0
- package/lib/core/task-memory.js +163 -0
- package/lib/core/task-registry.js +238 -0
- package/lib/core/toolcall-cache.js +206 -0
- package/lib/core/transfer-queue.js +58 -0
- package/lib/core/transfer.js +1144 -0
- package/lib/core/worker-arming.js +334 -0
- package/lib/index.js +2207 -0
- package/lib/provider/out-of-process-helpers.js +76 -0
- package/lib/provider/remote-provider.js +261 -0
- package/lib/tools/mediator-shim.mjs +180 -0
- package/lib/tools/prepare-sandbox-mediator.mjs +529 -0
- package/lib/tools/remote-tools.js +680 -0
- package/lib/tools/selfcheck-fix-prompt.md +206 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +72 -4
- package/slave/acp-profile-approval.patch.yml +66 -0
- package/slave/file-transfer.mjs +552 -0
- package/slave/integrity-probe.ps1 +99 -0
- package/slave/make-launch-spec.mjs +184 -0
- package/slave/net-address.mjs +138 -0
- package/slave/node_modules/.package-lock.json +26 -0
- package/slave/node_modules/ws/LICENSE +20 -0
- package/slave/node_modules/ws/README.md +548 -0
- package/slave/node_modules/ws/browser.js +8 -0
- package/slave/node_modules/ws/index.js +22 -0
- package/slave/node_modules/ws/lib/buffer-util.js +131 -0
- package/slave/node_modules/ws/lib/constants.js +19 -0
- package/slave/node_modules/ws/lib/event-target.js +292 -0
- package/slave/node_modules/ws/lib/extension.js +203 -0
- package/slave/node_modules/ws/lib/limiter.js +55 -0
- package/slave/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/slave/node_modules/ws/lib/receiver.js +743 -0
- package/slave/node_modules/ws/lib/sender.js +607 -0
- package/slave/node_modules/ws/lib/stream.js +161 -0
- package/slave/node_modules/ws/lib/subprotocol.js +62 -0
- package/slave/node_modules/ws/lib/validation.js +152 -0
- package/slave/node_modules/ws/lib/websocket-server.js +562 -0
- package/slave/node_modules/ws/lib/websocket.js +1421 -0
- package/slave/node_modules/ws/package.json +74 -0
- package/slave/node_modules/ws/wrapper.mjs +21 -0
- package/slave/pairing.mjs +175 -0
- package/slave/permission-pairing.mjs +240 -0
- package/slave/prune-shots.mjs +170 -0
- package/slave/relay.mjs +1351 -0
- package/slave/repair-workspace-integrity.ps1 +75 -0
- package/slave/screenshot.ps1 +114 -0
- package/slave/selfcheck.ps1 +350 -0
- package/slave/stage-app.ps1 +116 -0
- package/slave/workspace-integrity.mjs +157 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ws",
|
|
3
|
+
"version": "8.22.0",
|
|
4
|
+
"description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"HyBi",
|
|
7
|
+
"Push",
|
|
8
|
+
"RFC-6455",
|
|
9
|
+
"WebSocket",
|
|
10
|
+
"WebSockets",
|
|
11
|
+
"real-time"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/websockets/ws",
|
|
14
|
+
"bugs": "https://github.com/websockets/ws/issues",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/websockets/ws.git"
|
|
18
|
+
},
|
|
19
|
+
"author": "Einar Otto Stangvik <einaros@gmail.com> (http://2x.io)",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"main": "index.js",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"browser": "./browser.js",
|
|
25
|
+
"import": "./wrapper.mjs",
|
|
26
|
+
"require": "./index.js"
|
|
27
|
+
},
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"browser": "browser.js",
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=10.0.0"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"browser.js",
|
|
36
|
+
"index.js",
|
|
37
|
+
"lib/*.js",
|
|
38
|
+
"wrapper.mjs"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"test": "nyc --reporter=lcov --reporter=text mocha --throw-deprecation test/*.test.js",
|
|
42
|
+
"integration": "mocha --throw-deprecation test/*.integration.js",
|
|
43
|
+
"lint": "eslint . && prettier --check --ignore-path .gitignore \"**/*.{json,md,yaml,yml}\""
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"bufferutil": "^4.0.1",
|
|
47
|
+
"utf-8-validate": ">=5.0.2"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"bufferutil": {
|
|
51
|
+
"optional": true
|
|
52
|
+
},
|
|
53
|
+
"utf-8-validate": {
|
|
54
|
+
"optional": true
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@eslint/js": "^10.0.1",
|
|
59
|
+
"benchmark": "^2.1.4",
|
|
60
|
+
"bufferutil": "^4.0.1",
|
|
61
|
+
"eslint": "^10.0.1",
|
|
62
|
+
"eslint-config-prettier": "^10.0.1",
|
|
63
|
+
"eslint-plugin-prettier": "^5.0.0",
|
|
64
|
+
"globals": "^17.0.0",
|
|
65
|
+
"mocha": "^8.4.0",
|
|
66
|
+
"nyc": "^15.0.0",
|
|
67
|
+
"prettier": "^3.0.0",
|
|
68
|
+
"utf-8-validate": "^6.0.0"
|
|
69
|
+
},
|
|
70
|
+
"allowScripts": {
|
|
71
|
+
"bufferutil": true,
|
|
72
|
+
"utf-8-validate": true
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import createWebSocketStream from './lib/stream.js';
|
|
2
|
+
import extension from './lib/extension.js';
|
|
3
|
+
import PerMessageDeflate from './lib/permessage-deflate.js';
|
|
4
|
+
import Receiver from './lib/receiver.js';
|
|
5
|
+
import Sender from './lib/sender.js';
|
|
6
|
+
import subprotocol from './lib/subprotocol.js';
|
|
7
|
+
import WebSocket from './lib/websocket.js';
|
|
8
|
+
import WebSocketServer from './lib/websocket-server.js';
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
createWebSocketStream,
|
|
12
|
+
extension,
|
|
13
|
+
PerMessageDeflate,
|
|
14
|
+
Receiver,
|
|
15
|
+
Sender,
|
|
16
|
+
subprotocol,
|
|
17
|
+
WebSocket,
|
|
18
|
+
WebSocketServer
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default WebSocket;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pairing string: everything the master needs to reach one worker, in one paste.
|
|
3
|
+
*
|
|
4
|
+
* WHY IT EXISTS: pairing meant typing an address literal and a 40-character bearer
|
|
5
|
+
* token by hand, from a value the operator had to go and read off the other machine. That is
|
|
6
|
+
* slow and gets a character wrong about as often as it is attempted.
|
|
7
|
+
*
|
|
8
|
+
* IT CARRIES A CREDENTIAL. The string contains the worker's token in recoverable form, so it
|
|
9
|
+
* is exactly as sensitive as the token itself -- anyone holding it can drive that worker. Two
|
|
10
|
+
* consequences shape the implementation:
|
|
11
|
+
*
|
|
12
|
+
* * it is never logged, echoed, or included in an error message;
|
|
13
|
+
* * the checksum is not decoration. A truncated paste must fail LOUDLY rather than yield a
|
|
14
|
+
* worker entry that looks plausible and cannot connect, because the failure mode of a
|
|
15
|
+
* silently-wrong token is an authentication error much later, far from the paste.
|
|
16
|
+
*
|
|
17
|
+
* The format is deliberately versioned (`dshpair1`) so a future change can be told apart from
|
|
18
|
+
* a corrupt one instead of being misread.
|
|
19
|
+
*
|
|
20
|
+
* This module is shared: the relay encodes, the host decodes. It has no dependencies beyond
|
|
21
|
+
* `node:crypto`, so it can be copied to a worker alongside `relay.mjs`.
|
|
22
|
+
*/
|
|
23
|
+
import { createHash } from 'node:crypto'
|
|
24
|
+
|
|
25
|
+
/** Marks which format this is, so a corrupt string is distinguishable from an old one. */
|
|
26
|
+
export const PAIRING_PREFIX = 'dshpair1'
|
|
27
|
+
|
|
28
|
+
/** Checksum length in base64url characters. 8 is ~48 bits: ample for catching a typo. */
|
|
29
|
+
const CHECKSUM_CHARS = 8
|
|
30
|
+
|
|
31
|
+
/** Longest pairing string accepted, so a paste of a whole file cannot be chewed through. */
|
|
32
|
+
export const MAX_PAIRING_CHARS = 8 * 1024
|
|
33
|
+
|
|
34
|
+
function checksumOf(body) {
|
|
35
|
+
return createHash('sha256').update(body).digest('base64url').slice(0, CHECKSUM_CHARS)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {{ url?: unknown, token?: unknown, name?: unknown, cwd?: unknown }} worker
|
|
40
|
+
* @returns {string} the pairing string, or an empty string when the input cannot be paired
|
|
41
|
+
*/
|
|
42
|
+
export function encodePairing(worker) {
|
|
43
|
+
const url = String(worker?.url ?? '').trim()
|
|
44
|
+
const token = String(worker?.token ?? '').trim()
|
|
45
|
+
if (url === '' || token === '') return ''
|
|
46
|
+
const payload = { v: 1, url, token }
|
|
47
|
+
// Optional fields are omitted rather than sent empty, so the string stays short and a
|
|
48
|
+
// missing name is unambiguous.
|
|
49
|
+
const name = String(worker?.name ?? '').trim()
|
|
50
|
+
if (name !== '') payload.name = name
|
|
51
|
+
const cwd = String(worker?.cwd ?? '').trim()
|
|
52
|
+
if (cwd !== '') payload.cwd = cwd
|
|
53
|
+
|
|
54
|
+
const body = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
|
|
55
|
+
return `${PAIRING_PREFIX}.${body}.${checksumOf(body)}`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse a pairing string pasted by an operator.
|
|
60
|
+
*
|
|
61
|
+
* Lenient about the things a copy-paste gets wrong -- surrounding whitespace, line breaks
|
|
62
|
+
* introduced by a terminal, and the base64url alphabet -- and strict about everything that
|
|
63
|
+
* could produce a wrong worker entry.
|
|
64
|
+
*
|
|
65
|
+
* @param {unknown} text
|
|
66
|
+
* @returns {{ ok: true, worker: { url: string, token: string, name: string, cwd: string } }
|
|
67
|
+
* | { ok: false, error: string }}
|
|
68
|
+
*/
|
|
69
|
+
export function decodePairing(text) {
|
|
70
|
+
const fail = (error) => ({ ok: false, error })
|
|
71
|
+
if (typeof text !== 'string') return fail('pairing string must be text')
|
|
72
|
+
if (text.length > MAX_PAIRING_CHARS) return fail(`pairing string is longer than ${MAX_PAIRING_CHARS} characters`)
|
|
73
|
+
|
|
74
|
+
// Copy-paste introduces whitespace and terminal wrapping. Only INTERNAL wrapping matters,
|
|
75
|
+
// and base64url has no spaces, so removing all whitespace is safe and fixes the common case.
|
|
76
|
+
const cleaned = text.replace(/\s+/gu, '')
|
|
77
|
+
if (cleaned === '') return fail('pairing string is empty')
|
|
78
|
+
|
|
79
|
+
const parts = cleaned.split('.')
|
|
80
|
+
if (parts.length !== 3) return fail(`expected ${PAIRING_PREFIX}.<payload>.<checksum>`)
|
|
81
|
+
const [prefix, body, checksum] = parts
|
|
82
|
+
if (prefix !== PAIRING_PREFIX) return fail(`not a ${PAIRING_PREFIX} string (got "${prefix.slice(0, 24)}")`)
|
|
83
|
+
if (body === '' || checksum === '') return fail('pairing string is incomplete')
|
|
84
|
+
if (checksum !== checksumOf(body)) {
|
|
85
|
+
// The important one: a truncated or mistyped paste must not become a plausible worker.
|
|
86
|
+
return fail('checksum mismatch -- the pairing string was truncated or altered in transit')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let payload
|
|
90
|
+
try {
|
|
91
|
+
payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))
|
|
92
|
+
} catch {
|
|
93
|
+
return fail('payload is not valid JSON')
|
|
94
|
+
}
|
|
95
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return fail('payload must be an object')
|
|
96
|
+
if (payload.v !== 1) return fail(`unsupported pairing version ${JSON.stringify(payload.v)}`)
|
|
97
|
+
|
|
98
|
+
const url = typeof payload.url === 'string' ? payload.url.trim() : ''
|
|
99
|
+
if (url === '') return fail('payload carries no url')
|
|
100
|
+
let parsed
|
|
101
|
+
try {
|
|
102
|
+
parsed = new URL(url)
|
|
103
|
+
} catch {
|
|
104
|
+
return fail(`url is not a URL: ${url.slice(0, 60)}`)
|
|
105
|
+
}
|
|
106
|
+
// Only the transports the plugin actually speaks. A plain `http://` here would produce a
|
|
107
|
+
// worker that cannot connect, discovered much later.
|
|
108
|
+
if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
|
|
109
|
+
return fail(`url must be ws:// or wss://, got ${parsed.protocol}`)
|
|
110
|
+
}
|
|
111
|
+
if (parsed.hostname === '') return fail('url has no host')
|
|
112
|
+
|
|
113
|
+
const token = typeof payload.token === 'string' ? payload.token.trim() : ''
|
|
114
|
+
if (token === '') return fail('payload carries no token')
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
ok: true,
|
|
118
|
+
worker: {
|
|
119
|
+
url,
|
|
120
|
+
token,
|
|
121
|
+
name: typeof payload.name === 'string' ? payload.name.trim() : '',
|
|
122
|
+
cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() : '',
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A short, non-secret label for a pairing string, safe to show or log.
|
|
129
|
+
* Reveals only the host and port, which the operator already typed or pasted.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} text
|
|
132
|
+
* @returns {string}
|
|
133
|
+
*/
|
|
134
|
+
export function describePairing(text) {
|
|
135
|
+
const decoded = decodePairing(text)
|
|
136
|
+
if (decoded.ok !== true) return ''
|
|
137
|
+
try {
|
|
138
|
+
const url = new URL(decoded.worker.url)
|
|
139
|
+
return url.host
|
|
140
|
+
} catch {
|
|
141
|
+
return ''
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Derive a worker id from a pairing string when the operator did not supply one.
|
|
147
|
+
*
|
|
148
|
+
* Pairing is a paste; asking the operator to also invent an id would defeat the point. The
|
|
149
|
+
* worker's own name is the best answer when it has one, and the host otherwise -- never the
|
|
150
|
+
* whole URL, which contains the port and reads badly in a table.
|
|
151
|
+
*
|
|
152
|
+
* The result is restricted to characters that are safe in a filename, because the id becomes
|
|
153
|
+
* part of the screenshot filenames on the host (`<id>-<stamp>.png`) and the pruning pattern
|
|
154
|
+
* assumes the separator is the only punctuation that matters.
|
|
155
|
+
*
|
|
156
|
+
* @param {{ url?: string, name?: string }} worker
|
|
157
|
+
* @returns {string} a non-empty id, or '' when nothing usable can be derived
|
|
158
|
+
*/
|
|
159
|
+
export function deriveWorkerId(worker) {
|
|
160
|
+
const name = String(worker?.name ?? '').trim()
|
|
161
|
+
let candidate = name
|
|
162
|
+
if (candidate === '') {
|
|
163
|
+
try {
|
|
164
|
+
candidate = new URL(String(worker?.url ?? '')).hostname
|
|
165
|
+
} catch {
|
|
166
|
+
return ''
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const slug = candidate
|
|
170
|
+
.toLowerCase()
|
|
171
|
+
.replace(/[^a-z0-9._-]+/gu, '-')
|
|
172
|
+
.replace(/^-+|-+$/gu, '')
|
|
173
|
+
.slice(0, 48)
|
|
174
|
+
return slug
|
|
175
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// permission-pairing.mjs -- will this worker's DSH profile accept the mode we are about to request?
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS (PITFALLS F34): `dsh-base` derives BOTH the sandbox mode and the approval policy
|
|
4
|
+
// from `DSH_PERMISSION_MODE`:
|
|
5
|
+
//
|
|
6
|
+
// cordis.patch.yml:232 sandbox-policy.mode = process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
|
|
7
|
+
// cordis.patch.yml:248 approval.policy = (that value === 'danger-full-access') ? 'never' : 'ask'
|
|
8
|
+
//
|
|
9
|
+
// and the `permission` row ships a fixed table of presets, with NO `defaultPreset`:
|
|
10
|
+
//
|
|
11
|
+
// read-only -> sandbox: read-only, approval: ask
|
|
12
|
+
// workspace-write -> sandbox: workspace-write, approval: ask
|
|
13
|
+
// danger-full-access -> sandbox: danger-full-access, approval: never
|
|
14
|
+
//
|
|
15
|
+
// A worker profile patch that PINS `policy` to a literal value stops the two halves from moving
|
|
16
|
+
// together. Request `danger-full-access` against a profile pinned to `ask` and the composition is
|
|
17
|
+
// (danger-full-access, ask), which is in no preset -- and `dsh-permission-presets` documents
|
|
18
|
+
// `defaultPreset` as required "when composition defaults match no preset", so the service can fail
|
|
19
|
+
// to load, the ACP process never comes up and the worker is simply GONE. That is the F24 shape: a
|
|
20
|
+
// configuration combination that stops the whole application, presenting as "the worker broke".
|
|
21
|
+
//
|
|
22
|
+
// WHY THE CHECK IS STILL AT THE SPEC BUILD: the plugin does know the mode it asked for (0.2.15),
|
|
23
|
+
// but a hand-managed relay and the manual generator do not go through the plugin at all. The spec
|
|
24
|
+
// generator is the one place every deployment passes through, and it can read the profile that must
|
|
25
|
+
// compose the mode -- so it is where the gate can cover both the supervised and the manual paths.
|
|
26
|
+
// Putting it only in the plugin would leave the hand-managed deployment ungated.
|
|
27
|
+
|
|
28
|
+
/** Every file-effect sandbox mode DSH understands. Mirrors `SANDBOX_MODES` in dsh-sandbox-policy. */
|
|
29
|
+
export const SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access']
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* DSH's own preset table, as composed in the acp profile.
|
|
33
|
+
*
|
|
34
|
+
* Transcribed from `dsh --profile acp --dump-config` (the `permission` row) rather than guessed.
|
|
35
|
+
* `auto` is deliberately absent: its identity is fixed inside the preset service and only exists
|
|
36
|
+
* while the Auto review integration is live, which this profile does not compose.
|
|
37
|
+
*/
|
|
38
|
+
export const PRESETS = [
|
|
39
|
+
{ name: 'read-only', sandbox: 'read-only', approval: 'ask' },
|
|
40
|
+
{ name: 'workspace-write', sandbox: 'workspace-write', approval: 'ask' },
|
|
41
|
+
{ name: 'danger-full-access', sandbox: 'danger-full-access', approval: 'never' },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
/** The approval policy `dsh-base` pairs with a mode. */
|
|
45
|
+
export function approvalPolicyFor(mode) {
|
|
46
|
+
return mode === 'danger-full-access' ? 'never' : 'ask'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The sandbox mode a child spawned by the relay will ACTUALLY get, and where it came from.
|
|
51
|
+
*
|
|
52
|
+
* THIS IS THE QUESTION THAT KEPT BEING ANSWERED WRONGLY. The relay spawns the ACP child with
|
|
53
|
+
* `{ ...process.env, ...LAUNCH.env }`, so the spec wins over the relay's own environment -- and
|
|
54
|
+
* neither of those is "the file someone edited". Measured 2026-10-15: an operator changed
|
|
55
|
+
* `C:\dsh-remote\plugin-slave\agent-spec.json` while the live relay had been started with a TEMP
|
|
56
|
+
* spec (`...\Temp\...\acp-spec-<pid>-<port>.json`, rewritten on every arm) that nothing re-read, so
|
|
57
|
+
* "the file says full access" and "the session runs workspace-write" were BOTH true for hours.
|
|
58
|
+
*
|
|
59
|
+
* @param {{ specEnv?: object, env?: object }} sources
|
|
60
|
+
* @returns {{ mode: string, from: 'spec' | 'relay-env' | 'nothing' }}
|
|
61
|
+
*/
|
|
62
|
+
export function effectiveSessionMode(sources = {}) {
|
|
63
|
+
const spec = sources.specEnv?.DSH_PERMISSION_MODE
|
|
64
|
+
if (typeof spec === 'string' && spec !== '') return { mode: spec, from: 'spec' }
|
|
65
|
+
const env = sources.env?.DSH_PERMISSION_MODE
|
|
66
|
+
if (typeof env === 'string' && env !== '') return { mode: env, from: 'relay-env' }
|
|
67
|
+
// Empty means the child falls through to `dsh-base`'s own default, which is workspace-write.
|
|
68
|
+
// Reported as '' rather than as that default, so a reader can tell "not set here" from "set here".
|
|
69
|
+
return { mode: '', from: 'nothing' }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The mode the OPERATOR configured for this machine, read from the plugin's settings file.
|
|
74
|
+
*
|
|
75
|
+
* WHY THE RELAY READS THIS AT ALL: a hand-run relay (a scheduled task with `--cmd-file
|
|
76
|
+
* agent-spec.json`) is started outside the plugin, so `workerSessionMode` can be changed on the
|
|
77
|
+
* settings page without anything rewriting that spec. The result is the exact failure this project
|
|
78
|
+
* already recorded once (PITFALLS F38): "the setting says full access" and "the spec says
|
|
79
|
+
* workspace-write" are both true, for as long as nobody restarts anything, and neither side says so.
|
|
80
|
+
*
|
|
81
|
+
* The relay is the process that can answer the question from the machine itself: it has the spec it
|
|
82
|
+
* will actually spawn, and it can read the same settings file the plugin writes.
|
|
83
|
+
*
|
|
84
|
+
* @param {object} sources
|
|
85
|
+
* @param {object} [sources.specEnv] the launch spec's `env`, which wins when it sets the mode
|
|
86
|
+
* @param {object} [sources.env] the relay's own environment
|
|
87
|
+
* @param {string} [sources.settingsText] the settings file contents, or '' when it does not exist
|
|
88
|
+
* @returns {{
|
|
89
|
+
* effective: { mode: string, from: 'spec' | 'relay-env' | 'nothing' },
|
|
90
|
+
* effectiveMode: string,
|
|
91
|
+
* configured: string,
|
|
92
|
+
* settingsError: string,
|
|
93
|
+
* mismatch: boolean
|
|
94
|
+
* }}
|
|
95
|
+
*/
|
|
96
|
+
export function sessionModeConsistency(sources = {}) {
|
|
97
|
+
const effective = effectiveSessionMode(sources)
|
|
98
|
+
// DSH's own default is workspace-write. Treating "nothing set" as that value is what lets a
|
|
99
|
+
// machine configured for workspace-write match an old spec that carries no variable at all.
|
|
100
|
+
const effectiveMode = effective.mode === '' ? 'workspace-write' : effective.mode
|
|
101
|
+
let configured = ''
|
|
102
|
+
let settingsError = ''
|
|
103
|
+
if (typeof sources.settingsText === 'string' && sources.settingsText !== '') {
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(sources.settingsText)
|
|
106
|
+
if (parsed !== null && typeof parsed === 'object' && typeof parsed.workerSessionMode === 'string') {
|
|
107
|
+
configured = parsed.workerSessionMode
|
|
108
|
+
}
|
|
109
|
+
} catch (error) {
|
|
110
|
+
// A corrupt settings file is reported as its own fact. It must not be read as "not
|
|
111
|
+
// configured": those are different states, and only one of them means the operator has no
|
|
112
|
+
// opinion about the mode.
|
|
113
|
+
settingsError = `settings file is unreadable: ${error instanceof Error ? error.message : String(error)}`
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
effective,
|
|
118
|
+
effectiveMode,
|
|
119
|
+
configured,
|
|
120
|
+
settingsError,
|
|
121
|
+
// Only a configured value can be wrong. With none, the relay has nothing to disagree with.
|
|
122
|
+
mismatch: configured !== '' && configured !== effectiveMode,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The self-check row that makes a configured/effective mismatch visible in the report the master
|
|
128
|
+
* already renders.
|
|
129
|
+
*
|
|
130
|
+
* Returns null when the operator has not configured a mode: an unconfigured relay should not gain a
|
|
131
|
+
* warning row that says nothing. The row is `warning`, not `blocking`: the relay still works, it is
|
|
132
|
+
* running at the wrong level, and that is exactly the state the operator has to be told about.
|
|
133
|
+
*
|
|
134
|
+
* @param {ReturnType<typeof sessionModeConsistency>} state
|
|
135
|
+
* @param {{ specPath?: string }} [meta]
|
|
136
|
+
* @returns {object | null}
|
|
137
|
+
*/
|
|
138
|
+
export function sessionModeCheck(state, meta = {}) {
|
|
139
|
+
if (state === null || state === undefined || state.configured === '') return null
|
|
140
|
+
const from = state.effective.from === 'nothing'
|
|
141
|
+
? 'nothing set in the spec or relay env, so DSH defaults to workspace-write'
|
|
142
|
+
: `from ${state.effective.from}`
|
|
143
|
+
return {
|
|
144
|
+
id: 'S19',
|
|
145
|
+
name: 'launch spec matches configured session mode',
|
|
146
|
+
severity: 'warning',
|
|
147
|
+
status: state.mismatch ? 'warn' : 'pass',
|
|
148
|
+
expected: `configured ${state.configured}`,
|
|
149
|
+
actual: `effective ${state.effectiveMode} (${from})${meta.specPath ? `; spec ${meta.specPath}` : ''}`,
|
|
150
|
+
fix: state.mismatch
|
|
151
|
+
? `re-generate the launch spec from the saved setting on the worker `
|
|
152
|
+
+ `(node slave/make-launch-spec.mjs --out <spec>), then restart the relay. `
|
|
153
|
+
+ `The relay would spawn ${state.effectiveMode} while this machine is configured for ${state.configured}.`
|
|
154
|
+
: '',
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The approval policy a profile patch PINS, if it pins one at all.
|
|
160
|
+
*
|
|
161
|
+
* A structural scan, not a YAML parse, for the same reason `shimNamedByProfile` is one: this runs on
|
|
162
|
+
* a worker with no dependencies, and the shape is a handful of lines.
|
|
163
|
+
*
|
|
164
|
+
* `derived` means the profile reaches for `DSH_PERMISSION_MODE` itself, i.e. it agrees with
|
|
165
|
+
* `dsh-base` and moves with the mode -- which is the form the shipped snippet recommends.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} text the profile patch's contents
|
|
168
|
+
* @returns {{ kind: 'literal' | 'derived' | 'absent', value: string }}
|
|
169
|
+
*/
|
|
170
|
+
export function pinnedApprovalPolicy(text) {
|
|
171
|
+
if (typeof text !== 'string') return { kind: 'absent', value: '' }
|
|
172
|
+
// The approval row's own `policy:` line. `[\s\S]` rather than `.` so a commented-out line cannot
|
|
173
|
+
// be mistaken for the real one; comments are dropped first for the same reason the plugin's
|
|
174
|
+
// guard drops them: the comments quote the forms they replace.
|
|
175
|
+
const body = text.split('\n').filter((line) => !/^\s*#/u.test(line)).join('\n')
|
|
176
|
+
const entry = /-\s*id:\s*approval\b[\s\S]{0,600}?\n\s*policy:\s*([^\n]+)/u.exec(body)
|
|
177
|
+
if (entry === null) return { kind: 'absent', value: '' }
|
|
178
|
+
const raw = entry[1].trim()
|
|
179
|
+
if (raw.includes('DSH_PERMISSION_MODE')) return { kind: 'derived', value: raw }
|
|
180
|
+
// Strip an inline comment and the YAML tag if one is present, then unquote.
|
|
181
|
+
const value = raw.replace(/\s+#.*$/u, '').replace(/^!!js\s+/u, '').replace(/^["']|["']$/gu, '').trim()
|
|
182
|
+
return { kind: 'literal', value }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Would requesting `mode` against this profile compose a pairing DSH accepts?
|
|
187
|
+
*
|
|
188
|
+
* @param {{ mode: string, profileText: string | undefined, profilePath?: string }} request
|
|
189
|
+
* @returns {{ ok: boolean, severity: 'ok' | 'block' | 'warn', reason: string }}
|
|
190
|
+
*/
|
|
191
|
+
export function checkPairing(request) {
|
|
192
|
+
const mode = String(request.mode ?? '')
|
|
193
|
+
if (mode === '') return { ok: true, severity: 'ok', reason: '' }
|
|
194
|
+
if (!SANDBOX_MODES.includes(mode)) {
|
|
195
|
+
return {
|
|
196
|
+
ok: false,
|
|
197
|
+
severity: 'block',
|
|
198
|
+
reason: `"${mode}" is not a sandbox mode DSH understands. dsh-base rejects an unknown value at load, so this would stop the worker's ACP process from starting. Use one of: ${SANDBOX_MODES.join(', ')}`,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const pinned = pinnedApprovalPolicy(request.profileText)
|
|
202
|
+
if (pinned.kind === 'absent') {
|
|
203
|
+
// Cannot verify is not the same as verified. Say which, so nobody reads silence as approval.
|
|
204
|
+
return {
|
|
205
|
+
ok: true,
|
|
206
|
+
severity: request.profileText === undefined ? 'warn' : 'ok',
|
|
207
|
+
reason: request.profileText === undefined
|
|
208
|
+
? `could not read ${request.profilePath ?? 'the profile patch'}, so the approval pairing was NOT verified`
|
|
209
|
+
: 'the profile does not pin an approval policy, so dsh-base pairs it with the mode',
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (pinned.kind === 'derived') return { ok: true, severity: 'ok', reason: '' }
|
|
213
|
+
|
|
214
|
+
const policy = pinned.value
|
|
215
|
+
const match = PRESETS.find((p) => p.sandbox === mode && p.approval === policy)
|
|
216
|
+
if (match !== undefined) return { ok: true, severity: 'ok', reason: '' }
|
|
217
|
+
|
|
218
|
+
const wanted = approvalPolicyFor(mode)
|
|
219
|
+
// WARN, not block. MEASURED 2026-10-15 on a real acp profile: with this exact mismatch, the app
|
|
220
|
+
// starts, a session is created, a TURN completes, and the agent's own runtime context reports the
|
|
221
|
+
// requested mode as effective ("Current DSH file policy: danger-full-access"). Only the
|
|
222
|
+
// `permission-presets` entry fails to activate.
|
|
223
|
+
//
|
|
224
|
+
// An earlier version of this file BLOCKED here, on a predicted "the worker becomes unreachable"
|
|
225
|
+
// that the measurement disproved. A gate that refuses a configuration it has measured to work is
|
|
226
|
+
// worse than no gate: it costs real deployments and teaches people to bypass the check.
|
|
227
|
+
return {
|
|
228
|
+
ok: true,
|
|
229
|
+
severity: 'warn',
|
|
230
|
+
reason: [
|
|
231
|
+
`this profile pins the approval policy to "${policy}", but dsh-base pairs "${mode}" with "${wanted}",`,
|
|
232
|
+
`so requesting ${mode} composes (${mode}, ${policy}), which is in NO configured preset and`,
|
|
233
|
+
'`defaultPreset` is unset. DSH reports one failed entry and dsh-permission-presets does not load.',
|
|
234
|
+
'MEASURED: the sandbox mode still applies and a turn still runs, so this degrades the preset',
|
|
235
|
+
'bookkeeping rather than the worker.',
|
|
236
|
+
'TO REMOVE THE WARNING: apply packages/dsh-plugin-remote-use/slave/acp-profile-approval.patch.yml,',
|
|
237
|
+
'which derives the policy from DSH_PERMISSION_MODE instead of pinning it (a no-op for every other mode).',
|
|
238
|
+
].join(' '),
|
|
239
|
+
}
|
|
240
|
+
}
|