dsh-creator-mode-plus 0.3.8
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/AGENTS.md +40 -0
- package/CHANGELOG.md +104 -0
- package/LICENSE +21 -0
- package/README.en.md +83 -0
- package/README.md +82 -0
- package/docs/bridge-contract.md +431 -0
- package/docs/dshx-v0.7-alignment.md +108 -0
- package/docs/screenshots/already-installed.png +0 -0
- package/docs/screenshots/install.png +0 -0
- package/docs/screenshots/mode-picker.gif +0 -0
- package/docs/screenshots/mode-picker.png +0 -0
- package/docs/screenshots/mode-selected.png +0 -0
- package/dshx.yml +16 -0
- package/package.json +67 -0
- package/preset/preset.yml +2 -0
- package/preset/skills/creator-mode-plus/SKILL.md +101 -0
- package/preset/skills/creator-mode-plus/existing-plugin-trials.md +60 -0
- package/preset/skills/creator-mode-plus/maintenance.md +48 -0
- package/preset/skills/creator-mode-plus/verification-recovery.md +54 -0
- package/scripts/install.mjs +292 -0
- package/scripts/verify-dshx.mjs +77 -0
- package/scripts/verify-harness-install.mjs +101 -0
- package/src/auth.js +26 -0
- package/src/client.js +112 -0
- package/src/compatibility.js +261 -0
- package/src/delivery.js +331 -0
- package/src/index.js +377 -0
- package/src/preset-015.js +28 -0
- package/src/runner.js +464 -0
- package/src/safety.js +79 -0
- package/src/takeover.js +157 -0
package/src/runner.js
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import { privateStartupUrl, redactStartupOutput } from './auth.js'
|
|
2
|
+
/** Fixed-argument bridge from DSH tools to the external dshx CLI. */
|
|
3
|
+
|
|
4
|
+
import { spawn } from 'node:child_process'
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
7
|
+
import { createRequire } from 'node:module'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import {
|
|
12
|
+
CREATOR_BRIDGE_VERSION,
|
|
13
|
+
DSHX_CONTRACT,
|
|
14
|
+
inspectDshxCompatibility,
|
|
15
|
+
} from './compatibility.js'
|
|
16
|
+
import { forgetCreatorClaim, rememberCreatorClaim } from './safety.js'
|
|
17
|
+
import { deliveryStatus, readDelivery, recordDelivery, verifyDeliveryClient, withOperationOutcome } from './delivery.js'
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
CREATOR_BRIDGE_VERSION,
|
|
21
|
+
DSHX_CONTRACT,
|
|
22
|
+
inspectDshxCompatibility,
|
|
23
|
+
SUPPORTED_DSHX,
|
|
24
|
+
supportsDshxVersion,
|
|
25
|
+
} from './compatibility.js'
|
|
26
|
+
|
|
27
|
+
const MAX_CAPTURE_BYTES = 64 * 1024
|
|
28
|
+
const CLIENT_FAILURE_TIMEOUT_MS = 15_000
|
|
29
|
+
const PLUGIN_ID = /^[a-z][a-z0-9-]*$/
|
|
30
|
+
const KINDS = new Set(['function', 'tool', 'client', 'object', 'class'])
|
|
31
|
+
const CHANGES = new Set(['patch', 'manifest', 'preset', 'client', 'new-client', 'server', 'artifact'])
|
|
32
|
+
|
|
33
|
+
function isAllowedArgs(args) {
|
|
34
|
+
if (args.length === 1) return args[0] === 'status'
|
|
35
|
+
if (args.length === 2) return args[0] === 'check' && PLUGIN_ID.test(args[1])
|
|
36
|
+
if (args.length === 3 && args[0] === 'browser' && args[1] === 'open' && args[2] === '--json') return true
|
|
37
|
+
if (args.length === 3) {
|
|
38
|
+
return args[0] === 'creator'
|
|
39
|
+
&& (((args[1] === 'claim' || args[1] === 'remove') && PLUGIN_ID.test(args[2]))
|
|
40
|
+
|| ((args[1] === 'watch' || args[1] === 'release') && args[2] === '--json'))
|
|
41
|
+
}
|
|
42
|
+
if (args.length === 4) {
|
|
43
|
+
if (args[0] === 'creator') {
|
|
44
|
+
if (args[1] === 'takeover' && PLUGIN_ID.test(args[2]) && args[3] === '--json') return true
|
|
45
|
+
return (args[1] === 'recovery'
|
|
46
|
+
&& args[2] === 'pull'
|
|
47
|
+
&& args[3] === '--json')
|
|
48
|
+
|| (args[1] === 'scaffold'
|
|
49
|
+
&& PLUGIN_ID.test(args[2])
|
|
50
|
+
&& KINDS.has(args[3]))
|
|
51
|
+
}
|
|
52
|
+
if (!PLUGIN_ID.test(args[1])) return false
|
|
53
|
+
return args[0] === 'activation-plan'
|
|
54
|
+
&& args[2] === '--change'
|
|
55
|
+
&& CHANGES.has(args[3])
|
|
56
|
+
}
|
|
57
|
+
if (args.length === 5) {
|
|
58
|
+
return args[0] === 'creator'
|
|
59
|
+
&& args[1] === 'recovery'
|
|
60
|
+
&& args[2] === 'ack'
|
|
61
|
+
&& /^[0-9a-f-]{36}$/.test(args[3])
|
|
62
|
+
&& args[4] === '--json'
|
|
63
|
+
}
|
|
64
|
+
if (args.length === 6 && args[0] === 'activate-new-client' && PLUGIN_ID.test(args[1])) {
|
|
65
|
+
return args[2] === '--profile'
|
|
66
|
+
&& args[3] === 'web'
|
|
67
|
+
&& args[4] === '--port'
|
|
68
|
+
&& /^\d{1,5}$/.test(args[5])
|
|
69
|
+
&& Number(args[5]) >= 1
|
|
70
|
+
&& Number(args[5]) <= 65_535
|
|
71
|
+
}
|
|
72
|
+
if (args.length === 7 && args[0] === 'hot-reload' && PLUGIN_ID.test(args[1])) {
|
|
73
|
+
return args[2] === '--profile'
|
|
74
|
+
&& args[3] === 'web'
|
|
75
|
+
&& args[4] === '--port'
|
|
76
|
+
&& /^\d{1,5}$/.test(args[5])
|
|
77
|
+
&& Number(args[5]) >= 1
|
|
78
|
+
&& Number(args[5]) <= 65_535
|
|
79
|
+
&& args[6] === '--json'
|
|
80
|
+
}
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Resolve the current official Web profile's loopback port without model input. */
|
|
85
|
+
export function currentWebPort(argv = process.argv) {
|
|
86
|
+
const webAlias = argv.includes('web')
|
|
87
|
+
const profileIndex = argv.lastIndexOf('--profile')
|
|
88
|
+
const webProfile = profileIndex >= 0 && argv[profileIndex + 1] === 'web'
|
|
89
|
+
if (!webAlias && !webProfile) {
|
|
90
|
+
throw new Error('dsh-creator-mode-plus: activation requires the current Web profile')
|
|
91
|
+
}
|
|
92
|
+
const portIndex = argv.lastIndexOf('--port')
|
|
93
|
+
const value = portIndex >= 0 ? Number(argv[portIndex + 1]) : 3080
|
|
94
|
+
if (!Number.isInteger(value) || value < 1 || value > 65_535) {
|
|
95
|
+
throw new Error('dsh-creator-mode-plus: current Web profile does not expose a valid TCP port')
|
|
96
|
+
}
|
|
97
|
+
return value
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function contextFromExecution(exec, hostPort) {
|
|
101
|
+
const sessionId = exec?.agent?.id
|
|
102
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
103
|
+
throw new Error('dsh-creator-mode-plus: bridge v2 requires the calling DSH session identity')
|
|
104
|
+
}
|
|
105
|
+
const callId = typeof exec.callId === 'string' ? exec.callId : undefined
|
|
106
|
+
const rootCallId = typeof exec.rootCallId === 'string' ? exec.rootCallId : callId
|
|
107
|
+
const workspaceRoot = exec?.agent?.session?.header?.cwd
|
|
108
|
+
if (workspaceRoot !== undefined && (
|
|
109
|
+
typeof workspaceRoot !== 'string'
|
|
110
|
+
|| workspaceRoot.length === 0
|
|
111
|
+
|| workspaceRoot.length > 4_096
|
|
112
|
+
|| !isAbsolute(workspaceRoot)
|
|
113
|
+
)) {
|
|
114
|
+
throw new Error('dsh-creator-mode-plus: bridge v2 requires an absolute bounded session workspace')
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
sessionId,
|
|
118
|
+
...callId ? { callId } : {},
|
|
119
|
+
...rootCallId ? { rootCallId } : {},
|
|
120
|
+
hostPid: process.pid,
|
|
121
|
+
hostParentPid: process.ppid,
|
|
122
|
+
hostPort,
|
|
123
|
+
bridgeVersion: CREATOR_BRIDGE_VERSION,
|
|
124
|
+
...workspaceRoot === undefined ? {} : { workspaceRoot: resolve(workspaceRoot) },
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isHarnessRoot(path) {
|
|
129
|
+
return existsSync(join(path, 'apps/cli/src/bin.ts'))
|
|
130
|
+
&& existsSync(join(path, 'tools/dshx/src/cli.ts'))
|
|
131
|
+
&& existsSync(join(path, 'tools/dshx/package.json'))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function walkForHarness(start) {
|
|
135
|
+
let cursor = resolve(start)
|
|
136
|
+
while (true) {
|
|
137
|
+
if (isHarnessRoot(cursor)) return cursor
|
|
138
|
+
const parent = dirname(cursor)
|
|
139
|
+
if (parent === cursor) return undefined
|
|
140
|
+
cursor = parent
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Resolve exactly one Harness checkout using the same authority rule as dshx. */
|
|
145
|
+
export function resolveHarnessRoot(options = {}) {
|
|
146
|
+
if (options.harnessRoot) {
|
|
147
|
+
const explicit = resolve(options.harnessRoot)
|
|
148
|
+
if (!isHarnessRoot(explicit)) {
|
|
149
|
+
throw new Error(`dsh-creator-mode-plus: --harness is not a checkout with tools/dshx: ${explicit}`)
|
|
150
|
+
}
|
|
151
|
+
return explicit
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const candidates = []
|
|
155
|
+
const addCandidate = (path, source) => {
|
|
156
|
+
const root = resolve(path)
|
|
157
|
+
const existing = candidates.find(candidate => candidate.root === root)
|
|
158
|
+
if (existing) existing.sources.push(source)
|
|
159
|
+
else candidates.push({ root, sources: [source] })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const configured = options.envRoot === undefined
|
|
163
|
+
? process.env.DSHX_HARNESS?.trim()
|
|
164
|
+
: options.envRoot?.trim()
|
|
165
|
+
if (configured) {
|
|
166
|
+
if (!isHarnessRoot(configured)) {
|
|
167
|
+
throw new Error(`dsh-creator-mode-plus: DSHX_HARNESS is not a Harness checkout with tools/dshx: ${resolve(configured)}`)
|
|
168
|
+
}
|
|
169
|
+
addCandidate(configured, 'env')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const configFile = options.configFile === undefined
|
|
173
|
+
? join(homedir(), '.config/dshx/harness')
|
|
174
|
+
: options.configFile
|
|
175
|
+
if (configFile && existsSync(configFile)) {
|
|
176
|
+
const value = readFileSync(configFile, 'utf8').trim()
|
|
177
|
+
if (value) {
|
|
178
|
+
if (!isHarnessRoot(value)) {
|
|
179
|
+
throw new Error(`dsh-creator-mode-plus: ${configFile} is not a Harness checkout with tools/dshx: ${resolve(value)}`)
|
|
180
|
+
}
|
|
181
|
+
addCandidate(value, 'config')
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const fromCwd = walkForHarness(options.cwd ?? process.cwd())
|
|
186
|
+
if (fromCwd) addCandidate(fromCwd, 'cwd')
|
|
187
|
+
|
|
188
|
+
const moduleDir = options.moduleDir ?? dirname(fileURLToPath(import.meta.url))
|
|
189
|
+
const fromModule = walkForHarness(moduleDir)
|
|
190
|
+
if (fromModule) addCandidate(fromModule, 'module')
|
|
191
|
+
|
|
192
|
+
if (candidates.length === 0) {
|
|
193
|
+
throw new Error('dsh-creator-mode-plus: no Harness checkout found; run dshx setup, pass --harness, or set DSHX_HARNESS')
|
|
194
|
+
}
|
|
195
|
+
if (candidates.length > 1) {
|
|
196
|
+
const listed = candidates
|
|
197
|
+
.map(candidate => `${candidate.sources.join('+')}: ${candidate.root}`)
|
|
198
|
+
.join('; ')
|
|
199
|
+
throw new Error(`dsh-creator-mode-plus: multiple Harness checkouts found (${listed}); pass an explicit --harness`)
|
|
200
|
+
}
|
|
201
|
+
return candidates[0].root
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Resolve and version-gate the external dshx bridge implementation. */
|
|
205
|
+
export function resolveDshxRuntime(options = {}) {
|
|
206
|
+
const root = resolveHarnessRoot(options)
|
|
207
|
+
const compatibility = inspectDshxCompatibility(root)
|
|
208
|
+
|
|
209
|
+
const cli = join(root, 'tools/dshx/src/cli.ts')
|
|
210
|
+
const loader = options.loaderPath
|
|
211
|
+
|| createRequire(compatibility.packagePath).resolve('tsx/esm')
|
|
212
|
+
return {
|
|
213
|
+
root,
|
|
214
|
+
cli,
|
|
215
|
+
loader,
|
|
216
|
+
dshxVersion: compatibility.dshxVersion,
|
|
217
|
+
bridgeVersion: CREATOR_BRIDGE_VERSION,
|
|
218
|
+
contractId: compatibility.contractId,
|
|
219
|
+
capabilities: compatibility.capabilities,
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function appendBounded(current, chunk) {
|
|
224
|
+
const combined = Buffer.concat([Buffer.from(current, 'utf8'), chunk])
|
|
225
|
+
if (combined.byteLength <= MAX_CAPTURE_BYTES) return combined.toString('utf8')
|
|
226
|
+
return `[earlier output truncated]\n${combined.subarray(-MAX_CAPTURE_BYTES).toString('utf8')}`
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Execute one allowlisted dshx operation with structured session provenance. */
|
|
230
|
+
function executeDshx(args, exec, options = {}) {
|
|
231
|
+
if (!Array.isArray(args) || !args.every(value => typeof value === 'string') || !isAllowedArgs(args)) {
|
|
232
|
+
throw new Error('dsh-creator-mode-plus: refusing an operation outside bridge v2')
|
|
233
|
+
}
|
|
234
|
+
const runtime = resolveDshxRuntime(options)
|
|
235
|
+
const argv = ['--import', runtime.loader, runtime.cli, ...args, ...args[0] === 'activation-plan' ? ['--json'] : [], '--harness', runtime.root]
|
|
236
|
+
const spawnProcess = options.spawnProcess ?? spawn
|
|
237
|
+
const hostPort = options.hostPort ?? currentWebPort()
|
|
238
|
+
const creatorContext = contextFromExecution(exec, hostPort)
|
|
239
|
+
const startup = privateStartupUrl(options.getWebStartupUrl, hostPort)
|
|
240
|
+
const signal = exec?.signal
|
|
241
|
+
return new Promise((resolveResult, reject) => {
|
|
242
|
+
const child = spawnProcess(process.execPath, argv, {
|
|
243
|
+
cwd: runtime.root,
|
|
244
|
+
env: {
|
|
245
|
+
...process.env,
|
|
246
|
+
DSHX_HARNESS: runtime.root,
|
|
247
|
+
DSHX_CREATOR_CONTEXT: JSON.stringify(creatorContext),
|
|
248
|
+
DSHX_CREATOR_TAKEOVER_GRANT: args[0] === 'creator' && args[1] === 'takeover' && options.takeoverGrant ? JSON.stringify(options.takeoverGrant) : '',
|
|
249
|
+
DSHX_WEB_STARTUP_URL: startup,
|
|
250
|
+
},
|
|
251
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
252
|
+
})
|
|
253
|
+
let stdout = ''
|
|
254
|
+
let stderr = ''
|
|
255
|
+
child.stdout.on('data', chunk => { stdout = appendBounded(stdout, chunk) })
|
|
256
|
+
child.stderr.on('data', chunk => { stderr = appendBounded(stderr, chunk) })
|
|
257
|
+
|
|
258
|
+
const abort = () => { child.kill('SIGTERM') }
|
|
259
|
+
if (signal?.aborted) abort()
|
|
260
|
+
else signal?.addEventListener('abort', abort, { once: true })
|
|
261
|
+
child.once('error', reject)
|
|
262
|
+
child.once('close', (code) => {
|
|
263
|
+
signal?.removeEventListener('abort', abort)
|
|
264
|
+
resolveResult({
|
|
265
|
+
command: `dshx ${args.join(' ')}`,
|
|
266
|
+
exitCode: code ?? 1,
|
|
267
|
+
stdout: redactStartupOutput(stdout.trim(), startup),
|
|
268
|
+
stderr: redactStartupOutput(stderr.trim(), startup),
|
|
269
|
+
dshxVersion: runtime.dshxVersion,
|
|
270
|
+
creatorBridgeVersion: runtime.bridgeVersion,
|
|
271
|
+
dshxContract: runtime.contractId,
|
|
272
|
+
dshxCapabilities: runtime.capabilities,
|
|
273
|
+
...((args[0] === 'activate-new-client' || args[0] === 'hot-reload')
|
|
274
|
+
? { hostPid: process.pid, hostPort: Number(args[5]) }
|
|
275
|
+
: {}),
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function runDshx(args, exec, options = {}) {
|
|
282
|
+
if (!Array.isArray(args) || !args.every(value => typeof value === 'string') || !isAllowedArgs(args)) {
|
|
283
|
+
throw new Error('dsh-creator-mode-plus: refusing an operation outside bridge v2')
|
|
284
|
+
}
|
|
285
|
+
let root
|
|
286
|
+
return Promise.resolve().then(() => executeDshx(args, exec, options)).then(async result => {
|
|
287
|
+
root = resolveHarnessRoot(options)
|
|
288
|
+
// No receipt is inferred from an empty/malformed subprocess response.
|
|
289
|
+
if ((result.stdout || args[0] === 'hot-reload') && ['activation-plan', 'check', 'hot-reload'].includes(args[0])) {
|
|
290
|
+
recordDelivery(root, args, result, exec.agent.id)
|
|
291
|
+
}
|
|
292
|
+
const port = options.hostPort ?? currentWebPort()
|
|
293
|
+
const receipt = readDelivery(root, exec.agent.id)
|
|
294
|
+
const delivery = deliveryStatus(receipt, { port })
|
|
295
|
+
if (args[0] === 'status' && delivery?.state === 'RUNTIME_VERIFICATION_REQUIRED') {
|
|
296
|
+
try { delivery.runtimeProof = await verifyDeliveryClient(receipt, port, options.getWebStartupUrl?.(port)) }
|
|
297
|
+
catch { delivery.runtimeProof = { state: 'RUNTIME_PROOF_FAILED', behavior: 'UNVERIFIED' } }
|
|
298
|
+
}
|
|
299
|
+
return withOperationOutcome(args, result, delivery)
|
|
300
|
+
}).catch((error) => {
|
|
301
|
+
if (args[0] === 'hot-reload') {
|
|
302
|
+
root ??= resolveHarnessRoot(options)
|
|
303
|
+
recordDelivery(root, args, { exitCode: 1, stdout: '', stderr: '' }, exec.agent.id)
|
|
304
|
+
}
|
|
305
|
+
throw error
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Forward one browser-loader failure through fixed argv and Host-owned identity. */
|
|
310
|
+
export function runClientFailureDshx(report, options = {}) {
|
|
311
|
+
const runtime = resolveDshxRuntime(options)
|
|
312
|
+
const argv = ['--import', runtime.loader, runtime.cli, 'creator', 'client-failure', '--json']
|
|
313
|
+
const spawnProcess = options.spawnProcess ?? spawn
|
|
314
|
+
const startup = privateStartupUrl(options.getWebStartupUrl, report.hostPort)
|
|
315
|
+
const { DSHX_CREATOR_CONTEXT: _discardContext, DSHX_CREATOR_CLIENT_FAILURE: _discardFailure, ...baseEnv } = process.env
|
|
316
|
+
return new Promise((resolveResult, reject) => {
|
|
317
|
+
const child = spawnProcess(process.execPath, argv, {
|
|
318
|
+
cwd: runtime.root,
|
|
319
|
+
env: {
|
|
320
|
+
...baseEnv,
|
|
321
|
+
DSHX_HARNESS: runtime.root,
|
|
322
|
+
DSHX_CREATOR_CLIENT_FAILURE: JSON.stringify(report),
|
|
323
|
+
DSHX_WEB_STARTUP_URL: startup,
|
|
324
|
+
},
|
|
325
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
326
|
+
})
|
|
327
|
+
let stdout = ''
|
|
328
|
+
let stderr = ''
|
|
329
|
+
let timedOut = false
|
|
330
|
+
const timer = setTimeout(() => {
|
|
331
|
+
timedOut = true
|
|
332
|
+
child.kill('SIGTERM')
|
|
333
|
+
}, options.timeoutMs ?? CLIENT_FAILURE_TIMEOUT_MS)
|
|
334
|
+
child.stdout.on('data', chunk => { stdout = appendBounded(stdout, chunk) })
|
|
335
|
+
child.stderr.on('data', chunk => { stderr = appendBounded(stderr, chunk) })
|
|
336
|
+
child.once('error', (error) => {
|
|
337
|
+
clearTimeout(timer)
|
|
338
|
+
reject(error)
|
|
339
|
+
})
|
|
340
|
+
child.once('close', (code) => {
|
|
341
|
+
clearTimeout(timer)
|
|
342
|
+
resolveResult({
|
|
343
|
+
command: 'dshx creator client-failure',
|
|
344
|
+
exitCode: timedOut ? 124 : (code ?? 1),
|
|
345
|
+
stdout: redactStartupOutput(stdout.trim(), startup),
|
|
346
|
+
stderr: redactStartupOutput(timedOut ? `${stderr.trim()}\nclient-failure recovery timed out`.trim() : stderr.trim(), startup),
|
|
347
|
+
dshxVersion: runtime.dshxVersion,
|
|
348
|
+
creatorBridgeVersion: runtime.bridgeVersion,
|
|
349
|
+
dshxContract: runtime.contractId,
|
|
350
|
+
dshxCapabilities: runtime.capabilities,
|
|
351
|
+
})
|
|
352
|
+
})
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Refresh the session/plugin lease before every named plugin operation. */
|
|
357
|
+
export async function runClaimedDshx(pluginId, args, exec, options = {}) {
|
|
358
|
+
let claim
|
|
359
|
+
try {
|
|
360
|
+
claim = await runDshx(['creator', 'claim', pluginId], exec, options)
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if (args[0] === 'hot-reload') {
|
|
363
|
+
recordDelivery(resolveHarnessRoot(options), args, { exitCode: 1, stdout: '', stderr: '' }, exec.agent.id)
|
|
364
|
+
}
|
|
365
|
+
throw error
|
|
366
|
+
}
|
|
367
|
+
if (claim.exitCode !== 0) {
|
|
368
|
+
if (args[0] === 'hot-reload') {
|
|
369
|
+
const root = resolveHarnessRoot(options)
|
|
370
|
+
recordDelivery(root, args, claim, exec.agent.id)
|
|
371
|
+
const { delivery: _staleDelivery, ...withoutStaleDelivery } = claim
|
|
372
|
+
const delivery = deliveryStatus(readDelivery(root, exec.agent.id), {
|
|
373
|
+
port: options.hostPort ?? currentWebPort(),
|
|
374
|
+
})
|
|
375
|
+
return delivery ? { ...withoutStaleDelivery, delivery } : withoutStaleDelivery
|
|
376
|
+
}
|
|
377
|
+
return claim
|
|
378
|
+
}
|
|
379
|
+
rememberCreatorClaim(exec, pluginId)
|
|
380
|
+
const result = await runDshx(args, exec, options)
|
|
381
|
+
return { ...result, claim: { sessionId: exec.agent.id, pluginId } }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function recoveryText(incident) {
|
|
385
|
+
const lines = [
|
|
386
|
+
`[Creator+ Guardian incident ${incident.id}]`,
|
|
387
|
+
incident.summary,
|
|
388
|
+
incident.pluginId ? `Attributed plugin: ${incident.pluginId}` : 'Attributed plugin: ambiguous',
|
|
389
|
+
`Rollback: ${incident.rollback}`,
|
|
390
|
+
incident.previousPid ? `Previous Host pid: ${incident.previousPid}` : undefined,
|
|
391
|
+
incident.recoveredPid ? `Recovered Host pid: ${incident.recoveredPid}` : undefined,
|
|
392
|
+
incident.port ? `Recovered Web port: ${incident.port}` : undefined,
|
|
393
|
+
incident.logExcerpt ? `Diagnostic excerpt:\n${incident.logExcerpt}` : undefined,
|
|
394
|
+
'Inspect the preserved source and incident evidence, fix the plugin, run dshx_check, and only then retry the classified activation branch.',
|
|
395
|
+
]
|
|
396
|
+
return lines.filter(Boolean).join('\n')
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function pluginMessage(text) {
|
|
400
|
+
return Object.freeze({
|
|
401
|
+
id: randomUUID(),
|
|
402
|
+
role: 'user',
|
|
403
|
+
content: Object.freeze([{ type: 'text', text }]),
|
|
404
|
+
source: Object.freeze({ kind: 'plugin', plugin: 'dsh-creator-mode-plus' }),
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Deliver pending external incidents when their exact Creator+ session starts or resumes. */
|
|
409
|
+
export async function deliverCreatorRecovery(agent, options = {}) {
|
|
410
|
+
const callId = `creator-recovery-${randomUUID()}`
|
|
411
|
+
const exec = {
|
|
412
|
+
agent,
|
|
413
|
+
callId,
|
|
414
|
+
rootCallId: callId,
|
|
415
|
+
signal: options.signal ?? AbortSignal.timeout(15_000),
|
|
416
|
+
}
|
|
417
|
+
const run = options.runDshx ?? runDshx
|
|
418
|
+
const watch = await run(['creator', 'watch', '--json'], exec, options)
|
|
419
|
+
if (watch.exitCode !== 0) throw new Error(watch.stderr || watch.stdout || 'Creator+ Guardian arm failed')
|
|
420
|
+
const pull = await run(['creator', 'recovery', 'pull', '--json'], exec, options)
|
|
421
|
+
if (pull.exitCode !== 0) throw new Error(pull.stderr || pull.stdout || 'Creator+ recovery pull failed')
|
|
422
|
+
const decoded = JSON.parse(pull.stdout || '{}')
|
|
423
|
+
const incidents = Array.isArray(decoded?.data?.incidents) ? decoded.data.incidents : []
|
|
424
|
+
for (const incident of incidents) {
|
|
425
|
+
agent.steer(pluginMessage(recoveryText(incident)))
|
|
426
|
+
const ack = await run(['creator', 'recovery', 'ack', incident.id, '--json'], exec, options)
|
|
427
|
+
if (ack.exitCode !== 0) throw new Error(ack.stderr || ack.stdout || `Creator+ recovery ack failed for ${incident.id}`)
|
|
428
|
+
}
|
|
429
|
+
const root = options.harnessRoot ?? (options.runDshx ? undefined : resolveHarnessRoot(options))
|
|
430
|
+
const delivery = root ? deliveryStatus(readDelivery(root, agent.id), { port: options.hostPort ?? currentWebPort() }) : undefined
|
|
431
|
+
if (delivery) agent.steer(pluginMessage(`[Creator+ pending delivery]\n${JSON.stringify(delivery)}\nContinue verification of this existing plugin; do not report delivery complete before its requested behavior works.`))
|
|
432
|
+
return incidents
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export async function releaseCreatorClaim(agent, options = {}) {
|
|
436
|
+
const callId = `creator-release-${randomUUID()}`
|
|
437
|
+
const result = await (options.runDshx ?? runDshx)(['creator', 'release', '--json'], {
|
|
438
|
+
agent,
|
|
439
|
+
callId,
|
|
440
|
+
rootCallId: callId,
|
|
441
|
+
signal: AbortSignal.timeout(5_000),
|
|
442
|
+
}, options)
|
|
443
|
+
if (result.exitCode !== 0) throw new Error(result.stderr || result.stdout || 'Creator+ claim release failed')
|
|
444
|
+
forgetCreatorClaim({ agent })
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Register fail-contained recovery delivery on the preset-scoped Agent lifecycle. */
|
|
448
|
+
export function installCreatorRecovery(ctx, options = {}) {
|
|
449
|
+
if (typeof ctx.on !== 'function') return
|
|
450
|
+
ctx.on('agent/session-start', ({ agent }) => {
|
|
451
|
+
void deliverCreatorRecovery(agent, options).catch((error) => {
|
|
452
|
+
const message = `dsh-creator-mode-plus: recovery delivery failed: ${error instanceof Error ? error.message : String(error)}`
|
|
453
|
+
if (ctx.logger?.warn) ctx.logger.warn(message)
|
|
454
|
+
else console.warn(message)
|
|
455
|
+
})
|
|
456
|
+
})
|
|
457
|
+
ctx.on('agent/disposed', ({ agent }) => {
|
|
458
|
+
void releaseCreatorClaim(agent, options).catch((error) => {
|
|
459
|
+
const message = `dsh-creator-mode-plus: claim release failed: ${error instanceof Error ? error.message : String(error)}`
|
|
460
|
+
if (ctx.logger?.warn) ctx.logger.warn(message)
|
|
461
|
+
else console.warn(message)
|
|
462
|
+
})
|
|
463
|
+
})
|
|
464
|
+
}
|
package/src/safety.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Creator Mode+ claim tracking and last-mile destructive-shell guard. */
|
|
2
|
+
|
|
3
|
+
const claimedPlugins = new WeakMap()
|
|
4
|
+
const PLUGIN_ID = /^[a-z][a-z0-9-]*$/
|
|
5
|
+
|
|
6
|
+
function record(value) {
|
|
7
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function sessionWorkspace(exec) {
|
|
11
|
+
const cwd = exec?.agent?.session?.header?.cwd
|
|
12
|
+
return typeof cwd === 'string' && cwd.startsWith('/') ? cwd.replace(/\/$/, '') : undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function commandFrom(exec) {
|
|
16
|
+
const args = record(exec?.arguments)
|
|
17
|
+
return typeof args?.command === 'string' ? args.command : undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function destructiveShell(command) {
|
|
21
|
+
return /(?:^|[;&|()\n]\s*)(?:(?:command|env|sudo)\s+)*(?:(?:\/[\w@.+-]+)+\/)?(?:rm|rmdir|unlink|mv)\b/.test(command)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function containsPath(command, path) {
|
|
25
|
+
return command.includes(path)
|
|
26
|
+
|| command.includes(JSON.stringify(path))
|
|
27
|
+
|| command.includes(`'${path.replaceAll("'", "'\\''")}'`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Remember the exact plugin claimed by the current agent object. */
|
|
31
|
+
export function rememberCreatorClaim(exec, pluginId) {
|
|
32
|
+
if (exec?.agent && PLUGIN_ID.test(pluginId)) claimedPlugins.set(exec.agent, pluginId)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Forget a claim after safe removal or preset disposal. */
|
|
36
|
+
export function forgetCreatorClaim(exec) {
|
|
37
|
+
if (exec?.agent) claimedPlugins.delete(exec.agent)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function claimedCreatorPlugin(exec) {
|
|
41
|
+
return exec?.agent ? claimedPlugins.get(exec.agent) : undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Deny only plugin-root and active-profile teardown. Normal edits and removal
|
|
46
|
+
* of files inside a plugin remain available through bash.
|
|
47
|
+
*/
|
|
48
|
+
export function creatorDestructiveCommandReason(exec, explicitPluginId) {
|
|
49
|
+
if (exec?.name !== 'bash') return undefined
|
|
50
|
+
const command = commandFrom(exec)
|
|
51
|
+
if (!command || !destructiveShell(command)) return undefined
|
|
52
|
+
|
|
53
|
+
if (/(?:^|[\s'"=])(?:\/Users\/[^/]+\/)?\.dsh\/profiles(?:\/|[\s'";]|$)/.test(command)
|
|
54
|
+
|| /\/\.dsh\/profiles(?:\/|[\s'";]|$)/.test(command)) {
|
|
55
|
+
return 'Creator Mode+ blocks direct teardown of the active DSH profile. Use dshx_remove_plugin so the live Host row is removed before its profile dependency.'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const pluginId = explicitPluginId ?? claimedCreatorPlugin(exec)
|
|
59
|
+
if (!pluginId) return undefined
|
|
60
|
+
const workspace = sessionWorkspace(exec)
|
|
61
|
+
const pluginRoot = workspace ? `${workspace}/${pluginId}` : undefined
|
|
62
|
+
const protectsHarnessLink = command.includes(`/my-plugins/${pluginId}`)
|
|
63
|
+
const protectsWorkspaceRoot = pluginRoot ? containsPath(command, pluginRoot) : false
|
|
64
|
+
const escapedId = pluginId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
65
|
+
const removesClaimedRelativeRoot = new RegExp(`(?:^|[\\s'"=;])(?:\\.\\/)?${escapedId}(?:[\\s'";]|$)`).test(command)
|
|
66
|
+
const removesCurrentRoot = workspace?.endsWith(`/${pluginId}`) === true
|
|
67
|
+
&& /(?:^|[;&|()\n]\s*)(?:(?:command|env|sudo)\s+)*(?:(?:\/[\w@.+-]+)+\/)?(?:rm|rmdir|unlink|mv)\s+(?:-[^\s]+\s+)*(?:\.|\.\/)(?:[\s;]|$)/.test(command)
|
|
68
|
+
|
|
69
|
+
if (protectsHarnessLink || protectsWorkspaceRoot || removesClaimedRelativeRoot || removesCurrentRoot) {
|
|
70
|
+
return `Creator Mode+ blocks direct teardown of claimed plugin ${pluginId}. Use dshx_remove_plugin; it deactivates the live Host first and preserves source.`
|
|
71
|
+
}
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Install the preset-scoped monotonic guard when the Host exposes it. */
|
|
76
|
+
export function installCreatorSafetyGuard(ctx) {
|
|
77
|
+
if (typeof ctx?.tools?.guard !== 'function') return
|
|
78
|
+
ctx.tools.guard(exec => creatorDestructiveCommandReason(exec))
|
|
79
|
+
}
|