@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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +207 -2
  3. package/README.zh.md +195 -0
  4. package/assets/icon.svg +47 -0
  5. package/cordis.patch.yml +41 -0
  6. package/lib/bridge/acp-bridge.js +905 -0
  7. package/lib/bridge/peer-connection.js +730 -0
  8. package/lib/client.js +2842 -0
  9. package/lib/core/acp-model-overlay.js +121 -0
  10. package/lib/core/approval-queue.js +216 -0
  11. package/lib/core/audit-log.js +322 -0
  12. package/lib/core/desktop-notify.js +173 -0
  13. package/lib/core/environment-prompt.js +76 -0
  14. package/lib/core/relay-supervisor.js +278 -0
  15. package/lib/core/review-engine.js +679 -0
  16. package/lib/core/sandbox-diagnosis.js +258 -0
  17. package/lib/core/sandbox-probe.js +310 -0
  18. package/lib/core/self-acp-spec.js +117 -0
  19. package/lib/core/selfcheck-runner.js +150 -0
  20. package/lib/core/settings-store.js +226 -0
  21. package/lib/core/shot-gc.js +334 -0
  22. package/lib/core/task-cwd.js +50 -0
  23. package/lib/core/task-memory.js +163 -0
  24. package/lib/core/task-registry.js +238 -0
  25. package/lib/core/toolcall-cache.js +206 -0
  26. package/lib/core/transfer-queue.js +58 -0
  27. package/lib/core/transfer.js +1144 -0
  28. package/lib/core/worker-arming.js +334 -0
  29. package/lib/index.js +2207 -0
  30. package/lib/provider/out-of-process-helpers.js +76 -0
  31. package/lib/provider/remote-provider.js +261 -0
  32. package/lib/tools/mediator-shim.mjs +180 -0
  33. package/lib/tools/prepare-sandbox-mediator.mjs +529 -0
  34. package/lib/tools/remote-tools.js +680 -0
  35. package/lib/tools/selfcheck-fix-prompt.md +206 -0
  36. package/locale/en.json +6 -0
  37. package/locale/zh.json +6 -0
  38. package/package.json +72 -4
  39. package/slave/acp-profile-approval.patch.yml +66 -0
  40. package/slave/file-transfer.mjs +552 -0
  41. package/slave/integrity-probe.ps1 +99 -0
  42. package/slave/make-launch-spec.mjs +184 -0
  43. package/slave/net-address.mjs +138 -0
  44. package/slave/node_modules/.package-lock.json +26 -0
  45. package/slave/node_modules/ws/LICENSE +20 -0
  46. package/slave/node_modules/ws/README.md +548 -0
  47. package/slave/node_modules/ws/browser.js +8 -0
  48. package/slave/node_modules/ws/index.js +22 -0
  49. package/slave/node_modules/ws/lib/buffer-util.js +131 -0
  50. package/slave/node_modules/ws/lib/constants.js +19 -0
  51. package/slave/node_modules/ws/lib/event-target.js +292 -0
  52. package/slave/node_modules/ws/lib/extension.js +203 -0
  53. package/slave/node_modules/ws/lib/limiter.js +55 -0
  54. package/slave/node_modules/ws/lib/permessage-deflate.js +530 -0
  55. package/slave/node_modules/ws/lib/receiver.js +743 -0
  56. package/slave/node_modules/ws/lib/sender.js +607 -0
  57. package/slave/node_modules/ws/lib/stream.js +161 -0
  58. package/slave/node_modules/ws/lib/subprotocol.js +62 -0
  59. package/slave/node_modules/ws/lib/validation.js +152 -0
  60. package/slave/node_modules/ws/lib/websocket-server.js +562 -0
  61. package/slave/node_modules/ws/lib/websocket.js +1421 -0
  62. package/slave/node_modules/ws/package.json +74 -0
  63. package/slave/node_modules/ws/wrapper.mjs +21 -0
  64. package/slave/pairing.mjs +175 -0
  65. package/slave/permission-pairing.mjs +240 -0
  66. package/slave/prune-shots.mjs +170 -0
  67. package/slave/relay.mjs +1351 -0
  68. package/slave/repair-workspace-integrity.ps1 +75 -0
  69. package/slave/screenshot.ps1 +114 -0
  70. package/slave/selfcheck.ps1 +350 -0
  71. package/slave/stage-app.ps1 +116 -0
  72. 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
+ }