dsh-plugin-lcu 0.1.0
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 +316 -0
- package/cordis.patch.yml +23 -0
- package/dev-types/dsh.d.ts +292 -0
- package/docs/README.zh.md +291 -0
- package/lib/approval.js +152 -0
- package/lib/approval.js.map +1 -0
- package/lib/connection.js +384 -0
- package/lib/connection.js.map +1 -0
- package/lib/diag.js +52 -0
- package/lib/diag.js.map +1 -0
- package/lib/host-guard.js +137 -0
- package/lib/host-guard.js.map +1 -0
- package/lib/index.js +318 -0
- package/lib/index.js.map +1 -0
- package/lib/tool.js +262 -0
- package/lib/tool.js.map +1 -0
- package/lib/types/approval.d.ts +71 -0
- package/lib/types/approval.d.ts.map +1 -0
- package/lib/types/connection.d.ts +178 -0
- package/lib/types/connection.d.ts.map +1 -0
- package/lib/types/diag.d.ts +17 -0
- package/lib/types/diag.d.ts.map +1 -0
- package/lib/types/host-guard.d.ts +46 -0
- package/lib/types/host-guard.d.ts.map +1 -0
- package/lib/types/index.d.ts +49 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/tool.d.ts +90 -0
- package/lib/types/tool.d.ts.map +1 -0
- package/package.json +79 -0
- package/scripts/codex-baseline.json +10 -0
- package/scripts/gen-presets.mjs +266 -0
- package/scripts/probe-lcu.mjs +154 -0
- package/scripts/update-codex.mjs +525 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* update-codex.mjs — keep the profile's `@openai/codex` on the newest version
|
|
4
|
+
* that still passes DSH's own compatibility gates.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists
|
|
7
|
+
* ---------------
|
|
8
|
+
* `@deepseek-ai/dsh-subagent-codex` pins `"@openai/codex": "0.153.4"` and resolves
|
|
9
|
+
* the binary from inside its own package (never the host PATH), so the pinned
|
|
10
|
+
* version is not configurable. DSH pins it because `codex app-server` is an
|
|
11
|
+
* experimental protocol: the package README requires regenerating upstream schema
|
|
12
|
+
* evidence and re-running handshake / answer-selection / approval / cancellation
|
|
13
|
+
* tests on any upgrade.
|
|
14
|
+
*
|
|
15
|
+
* This script automates exactly that: it tracks npm `latest`, installs it through
|
|
16
|
+
* a profile-level pnpm override, and only keeps it if three gates pass. A failing
|
|
17
|
+
* version is rolled back to the last verified one automatically.
|
|
18
|
+
*
|
|
19
|
+
* Gates
|
|
20
|
+
* -----
|
|
21
|
+
* 1. handshake — the new binary answers `initialize`
|
|
22
|
+
* 2. schema — every protocol method/field DSH's wire uses still exists
|
|
23
|
+
* (`codex app-server generate-json-schema`)
|
|
24
|
+
* 3. turn — a real end-to-end Codex turn through app-server returns an
|
|
25
|
+
* `agentMessage` with `phase: final_answer`
|
|
26
|
+
*
|
|
27
|
+
* Gate 3 is the definitive one: it exercises auth, model routing, thread/turn
|
|
28
|
+
* lifecycle and answer selection without DSH in the loop.
|
|
29
|
+
*
|
|
30
|
+
* Usage
|
|
31
|
+
* -----
|
|
32
|
+
* node scripts/update-codex.mjs # upgrade if a newer release passes
|
|
33
|
+
* node scripts/update-codex.mjs --check # report only, change nothing
|
|
34
|
+
* node scripts/update-codex.mjs --to 0.161.0 # target an explicit version
|
|
35
|
+
* node scripts/update-codex.mjs --rollback # restore the last verified version
|
|
36
|
+
* node scripts/update-codex.mjs --skip-turn # skip gate 3 (no Codex quota spend)
|
|
37
|
+
*
|
|
38
|
+
* Options: --profile DIR, --pnpm PATH
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
42
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
43
|
+
import { homedir, tmpdir } from 'node:os'
|
|
44
|
+
import { dirname, join } from 'node:path'
|
|
45
|
+
import { fileURLToPath } from 'node:url'
|
|
46
|
+
import { createInterface } from 'node:readline'
|
|
47
|
+
|
|
48
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
49
|
+
const BASELINE_FILE = join(HERE, 'codex-baseline.json')
|
|
50
|
+
const DEFAULT_PROFILE = join(homedir(), '.dsh', 'profiles', 'desktop')
|
|
51
|
+
const CODEX_ENTRY = 'node_modules/@openai/codex/bin/codex.js'
|
|
52
|
+
// Scope the override to the single edge that needs it. A bare `@openai/codex`
|
|
53
|
+
// override ALSO matches the platform packages — they are `npm:@openai/codex@…`
|
|
54
|
+
// aliases of the same name — which strips their os/cpu gating and makes pnpm
|
|
55
|
+
// fetch every OS/arch payload (~275 MB each).
|
|
56
|
+
const OVERRIDE_KEY = '"@deepseek-ai/dsh-subagent-codex>@openai/codex"'
|
|
57
|
+
const OVERRIDE_RE = /^\s*"@deepseek-ai\/dsh-subagent-codex>@openai\/codex":.*$/m
|
|
58
|
+
const APP_PNPM = '/Applications/DeepSeek Harness.app/Contents/Resources/runtime/pnpm/bin/pnpm.cjs'
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The protocol surface dsh-subagent-codex/src/wire.ts actually uses.
|
|
62
|
+
* Gate 2 fails if any of these disappears.
|
|
63
|
+
*
|
|
64
|
+
* Method unions live in the standalone per-type files (`ClientRequest.json`,
|
|
65
|
+
* `ServerNotification.json`, `ServerRequest.json`); parameter definitions live
|
|
66
|
+
* in the combined `*.schemas.json` bundles (v2 first, then the legacy one,
|
|
67
|
+
* because a few definitions — e.g. `ServerRequest` — only exist there).
|
|
68
|
+
*/
|
|
69
|
+
const REQUIRED_METHODS = {
|
|
70
|
+
'ClientRequest.json': ['initialize', 'thread/start', 'turn/start', 'turn/interrupt'],
|
|
71
|
+
'ServerNotification.json': ['turn/started', 'turn/completed', 'item/completed'],
|
|
72
|
+
'ServerRequest.json': [
|
|
73
|
+
'item/commandExecution/requestApproval',
|
|
74
|
+
'item/fileChange/requestApproval',
|
|
75
|
+
'item/permissions/requestApproval',
|
|
76
|
+
'item/tool/requestUserInput',
|
|
77
|
+
],
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const REQUIRED_FIELDS = [
|
|
81
|
+
['InitializeCapabilities', ['experimentalApi', 'requestAttestation']],
|
|
82
|
+
['ThreadStartParams', ['cwd', 'ephemeral', 'model', 'approvalPolicy', 'approvalsReviewer', 'sandbox']],
|
|
83
|
+
['TurnStartParams', ['input', 'threadId']],
|
|
84
|
+
['TurnInterruptParams', ['threadId', 'turnId']],
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
/** Enum values DSH maps permission modes onto. */
|
|
88
|
+
const REQUIRED_ENUMS = [
|
|
89
|
+
['AskForApproval', ['never', 'on-request']],
|
|
90
|
+
['SandboxMode', ['read-only', 'workspace-write', 'danger-full-access']],
|
|
91
|
+
['MessagePhase', ['final_answer', 'commentary']],
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Small helpers
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
const log = (...args) => process.stderr.write(`${args.join(' ')}\n`)
|
|
99
|
+
|
|
100
|
+
function run(command, args, options = {}) {
|
|
101
|
+
// stdin must be closed, not merely piped: pnpm (and the app-server) block
|
|
102
|
+
// forever on an open, never-written stdin pipe that the parent never closes.
|
|
103
|
+
return spawnSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...options })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readJson(path) {
|
|
107
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function installedCodexVersion(profile) {
|
|
111
|
+
return readJson(join(profile, CODEX_ENTRY, '..', '..', 'package.json')).version
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function latestPublishedVersion() {
|
|
115
|
+
const result = run('npm', ['view', '@openai/codex', 'version'])
|
|
116
|
+
if (result.status !== 0) throw new Error(`npm view failed: ${result.stderr.trim()}`)
|
|
117
|
+
return result.stdout.trim()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// The pnpm override lives in the profile's pnpm-workspace.yaml (pnpm >= 10).
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
function readOverride(text) {
|
|
125
|
+
const match = text.match(/^\s*"@deepseek-ai\/dsh-subagent-codex>@openai\/codex":\s*"?([^"\s]+)"?\s*$/m)
|
|
126
|
+
return match?.[1]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function writeOverride(text, version, key = OVERRIDE_KEY) {
|
|
130
|
+
const line = ` ${key}: "${version}"`
|
|
131
|
+
if (OVERRIDE_RE.test(text)) {
|
|
132
|
+
return text.replace(OVERRIDE_RE, line.trimStart())
|
|
133
|
+
}
|
|
134
|
+
if (/^overrides:/m.test(text)) {
|
|
135
|
+
return text.replace(/^overrides:\s*$/m, `overrides:\n${line}`)
|
|
136
|
+
}
|
|
137
|
+
return `${text.replace(/\s*$/, '')}\n\noverrides:\n${line}\n`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function removeOverride(text) {
|
|
141
|
+
return text
|
|
142
|
+
.replace(/^\s*"@deepseek-ai\/dsh-subagent-codex>@openai\/codex":.*\n?/m, '')
|
|
143
|
+
.replace(/^overrides:\s*\n(?=\s*\n|\s*$)/m, '')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// app-server JSON-RPC over newline-delimited stdio (same framing as wire.ts)
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
class AppServer {
|
|
151
|
+
constructor(binary, { cwd = tmpdir(), label = 'codex' } = {}) {
|
|
152
|
+
this.label = label
|
|
153
|
+
this.nextId = 1
|
|
154
|
+
this.pending = new Map()
|
|
155
|
+
this.listeners = []
|
|
156
|
+
this.stderr = ''
|
|
157
|
+
this.child = spawn(process.execPath, [binary, 'app-server', '--stdio'], {
|
|
158
|
+
cwd,
|
|
159
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
160
|
+
env: process.env,
|
|
161
|
+
})
|
|
162
|
+
this.child.stderr.on('data', (chunk) => { this.stderr += chunk.toString() })
|
|
163
|
+
this.child.on('exit', (code) => {
|
|
164
|
+
for (const { reject } of this.pending.values()) {
|
|
165
|
+
reject(new Error(`${label}: app-server exited (${code})`))
|
|
166
|
+
}
|
|
167
|
+
this.pending.clear()
|
|
168
|
+
})
|
|
169
|
+
createInterface({ input: this.child.stdout }).on('line', (line) => this.#dispatch(line))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#dispatch(line) {
|
|
173
|
+
if (line.trim() === '') return
|
|
174
|
+
let message
|
|
175
|
+
try {
|
|
176
|
+
message = JSON.parse(line)
|
|
177
|
+
} catch {
|
|
178
|
+
return // non-JSON chatter on stdout is not protocol traffic
|
|
179
|
+
}
|
|
180
|
+
if (message.id !== undefined && message.method === undefined) {
|
|
181
|
+
const entry = this.pending.get(message.id)
|
|
182
|
+
if (entry === undefined) return
|
|
183
|
+
this.pending.delete(message.id)
|
|
184
|
+
if (message.error !== undefined) entry.reject(new Error(`${this.label}: ${JSON.stringify(message.error)}`))
|
|
185
|
+
else entry.resolve(message.result)
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
if (message.method !== undefined) {
|
|
189
|
+
for (const listener of this.listeners) listener(message)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
onNotification(listener) {
|
|
194
|
+
this.listeners.push(listener)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
request(method, params, timeoutMs) {
|
|
198
|
+
const id = this.nextId++
|
|
199
|
+
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`)
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
const timer = setTimeout(() => {
|
|
202
|
+
this.pending.delete(id)
|
|
203
|
+
reject(new Error(`${this.label}: ${method} timed out after ${timeoutMs}ms`))
|
|
204
|
+
}, timeoutMs)
|
|
205
|
+
this.pending.set(id, {
|
|
206
|
+
resolve: (value) => { clearTimeout(timer); resolve(value) },
|
|
207
|
+
reject: (error) => { clearTimeout(timer); reject(error) },
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
notify(method, params) {
|
|
213
|
+
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
close() {
|
|
217
|
+
this.child.kill('SIGTERM')
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Gates
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
async function gateHandshake(binary) {
|
|
226
|
+
const server = new AppServer(binary, { label: 'gate1' })
|
|
227
|
+
try {
|
|
228
|
+
const result = await server.request('initialize', {
|
|
229
|
+
clientInfo: { name: 'deepseek-harness', title: 'DeepSeek Harness', version: '0.0.1' },
|
|
230
|
+
capabilities: { experimentalApi: false, requestAttestation: false },
|
|
231
|
+
}, 30_000)
|
|
232
|
+
if (typeof result?.userAgent !== 'string') throw new Error('initialize returned no userAgent')
|
|
233
|
+
return result.userAgent
|
|
234
|
+
} finally {
|
|
235
|
+
server.close()
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function gateSchema(binary) {
|
|
240
|
+
const out = mkdtempSync(join(tmpdir(), 'codex-schema-'))
|
|
241
|
+
try {
|
|
242
|
+
const result = run(process.execPath, [binary, 'app-server', 'generate-json-schema', '--out', out], { timeout: 120_000 })
|
|
243
|
+
if (result.status !== 0) throw new Error(`generate-json-schema failed: ${result.stderr.trim().slice(0, 400)}`)
|
|
244
|
+
const files = new Set(readdirSync(out))
|
|
245
|
+
const failures = []
|
|
246
|
+
|
|
247
|
+
// 1. Method unions, straight from the per-type files.
|
|
248
|
+
for (const [file, methods] of Object.entries(REQUIRED_METHODS)) {
|
|
249
|
+
if (!files.has(file)) { failures.push(`missing schema file: ${file}`); continue }
|
|
250
|
+
const node = readJson(join(out, file))
|
|
251
|
+
const present = new Set((node.oneOf ?? []).map((variant) => variant?.properties?.method?.enum?.[0]))
|
|
252
|
+
for (const method of methods) if (!present.has(method)) failures.push(`missing in ${file}: ${method}`)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 2. Parameter definitions: v2 bundle first, legacy bundle as fallback.
|
|
256
|
+
const bundles = ['codex_app_server_protocol.v2.schemas.json', 'codex_app_server_protocol.schemas.json']
|
|
257
|
+
.filter((name) => files.has(name))
|
|
258
|
+
if (bundles.length === 0) throw new Error('no combined *.schemas.json in the generated bundle')
|
|
259
|
+
const defs = Object.assign({}, ...bundles.slice().reverse().map((name) => readJson(join(out, name)).definitions ?? {}))
|
|
260
|
+
|
|
261
|
+
for (const [definition, fields] of REQUIRED_FIELDS) {
|
|
262
|
+
const node = defs[definition]
|
|
263
|
+
if (node === undefined) { failures.push(`missing definition ${definition}`); continue }
|
|
264
|
+
const props = new Set(Object.keys(node.properties ?? {}))
|
|
265
|
+
for (const field of fields) if (!props.has(field)) failures.push(`missing ${definition}.${field}`)
|
|
266
|
+
}
|
|
267
|
+
for (const [definition, values] of REQUIRED_ENUMS) {
|
|
268
|
+
const node = defs[definition]
|
|
269
|
+
if (node === undefined) { failures.push(`missing definition ${definition}`); continue }
|
|
270
|
+
const serialized = JSON.stringify(node)
|
|
271
|
+
for (const value of values) if (!serialized.includes(`"${value}"`)) failures.push(`missing ${definition} value: ${value}`)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 3. The text user-input variant, its text_elements field, and the agent message phase.
|
|
275
|
+
const textVariant = defs.UserInput?.oneOf?.find((variant) => variant?.properties?.type?.enum?.[0] === 'text')
|
|
276
|
+
if (textVariant === undefined) failures.push('missing UserInput text variant')
|
|
277
|
+
else {
|
|
278
|
+
for (const field of ['text', 'text_elements', 'type']) {
|
|
279
|
+
if (!(field in (textVariant.properties ?? {}))) failures.push(`missing UserInput text.${field}`)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const agentMessage = defs.ThreadItem?.oneOf?.find((variant) => variant?.properties?.type?.enum?.[0] === 'agentMessage')
|
|
283
|
+
if (agentMessage === undefined) failures.push('missing ThreadItem agentMessage variant')
|
|
284
|
+
else if (!('phase' in (agentMessage.properties ?? {}))) failures.push('missing ThreadItem.agentMessage.phase')
|
|
285
|
+
|
|
286
|
+
if (failures.length > 0) throw new Error(`protocol surface changed:\n - ${failures.join('\n - ')}`)
|
|
287
|
+
return `${Object.keys(defs).length} definitions, all required methods/fields present`
|
|
288
|
+
} finally {
|
|
289
|
+
rmSync(out, { recursive: true, force: true })
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function gateTurn(binary) {
|
|
294
|
+
const cwd = mkdtempSync(join(tmpdir(), 'codex-turn-'))
|
|
295
|
+
const server = new AppServer(binary, { cwd, label: 'gate3' })
|
|
296
|
+
try {
|
|
297
|
+
await server.request('initialize', {
|
|
298
|
+
clientInfo: { name: 'deepseek-harness', title: 'DeepSeek Harness', version: '0.0.1' },
|
|
299
|
+
capabilities: { experimentalApi: false, requestAttestation: false },
|
|
300
|
+
}, 30_000)
|
|
301
|
+
server.notify('initialized')
|
|
302
|
+
|
|
303
|
+
const threadResponse = await server.request('thread/start', {
|
|
304
|
+
cwd,
|
|
305
|
+
ephemeral: true,
|
|
306
|
+
approvalPolicy: 'never',
|
|
307
|
+
}, 60_000)
|
|
308
|
+
const threadId = threadResponse?.thread?.id
|
|
309
|
+
if (threadResponse?.thread?.ephemeral !== true) throw new Error('thread/start did not create an ephemeral thread')
|
|
310
|
+
if (typeof threadId !== 'string') throw new Error('thread/start returned no thread id')
|
|
311
|
+
|
|
312
|
+
let finalAnswer
|
|
313
|
+
let unphased
|
|
314
|
+
let resolveTurn
|
|
315
|
+
const completed = new Promise((resolve) => { resolveTurn = resolve })
|
|
316
|
+
const notices = []
|
|
317
|
+
server.onNotification((message) => {
|
|
318
|
+
const params = message.params ?? {}
|
|
319
|
+
if (message.method === 'error') notices.push(`error: ${JSON.stringify(params).slice(0, 300)}`)
|
|
320
|
+
if (params.threadId !== undefined && params.threadId !== threadId) return
|
|
321
|
+
if (message.method === 'item/completed' && params.item?.type === 'agentMessage') {
|
|
322
|
+
const text = params.item.text ?? ''
|
|
323
|
+
if (params.item.phase === 'final_answer') finalAnswer = text
|
|
324
|
+
else if (params.item.phase === null) unphased = text
|
|
325
|
+
}
|
|
326
|
+
if (message.method === 'turn/completed') resolveTurn({ turn: params.turn, notices, stderr: server.stderr })
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
await server.request('turn/start', {
|
|
330
|
+
threadId,
|
|
331
|
+
input: [{ type: 'text', text: 'Reply with exactly the two characters: OK', text_elements: [] }],
|
|
332
|
+
}, 60_000)
|
|
333
|
+
|
|
334
|
+
const outcome = await Promise.race([
|
|
335
|
+
completed,
|
|
336
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('turn/completed never arrived within 240s')), 240_000)),
|
|
337
|
+
])
|
|
338
|
+
const { turn } = outcome
|
|
339
|
+
if (turn?.status !== 'completed') {
|
|
340
|
+
throw new Error([
|
|
341
|
+
`turn status was ${JSON.stringify(turn?.status)}`,
|
|
342
|
+
`turn: ${JSON.stringify(turn).slice(0, 600)}`,
|
|
343
|
+
...outcome.notices.slice(-5),
|
|
344
|
+
outcome.stderr.trim() === '' ? 'stderr: (empty)' : `stderr: ${outcome.stderr.trim().slice(-500)}`,
|
|
345
|
+
].join('\n '))
|
|
346
|
+
}
|
|
347
|
+
const answer = finalAnswer ?? unphased
|
|
348
|
+
if (answer === undefined) throw new Error('no agentMessage answer reached the terminal turn')
|
|
349
|
+
return { answer: answer.trim().slice(0, 120), phased: finalAnswer !== undefined }
|
|
350
|
+
} finally {
|
|
351
|
+
server.close()
|
|
352
|
+
rmSync(cwd, { recursive: true, force: true })
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
// Main
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
function parseArgs(argv) {
|
|
361
|
+
const opts = {
|
|
362
|
+
profile: DEFAULT_PROFILE,
|
|
363
|
+
check: false,
|
|
364
|
+
skipTurn: false,
|
|
365
|
+
rollback: false,
|
|
366
|
+
verifyOnly: false,
|
|
367
|
+
to: undefined,
|
|
368
|
+
pnpm: undefined,
|
|
369
|
+
}
|
|
370
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
371
|
+
const arg = argv[i]
|
|
372
|
+
if (arg === '--check') opts.check = true
|
|
373
|
+
else if (arg === '--skip-turn') opts.skipTurn = true
|
|
374
|
+
else if (arg === '--rollback') opts.rollback = true
|
|
375
|
+
else if (arg === '--verify-only') opts.verifyOnly = true
|
|
376
|
+
else if (arg === '--to') opts.to = argv[++i]
|
|
377
|
+
else if (arg === '--profile') opts.profile = argv[++i]
|
|
378
|
+
else if (arg === '--pnpm') opts.pnpm = argv[++i]
|
|
379
|
+
else throw new Error(`unknown argument: ${arg}`)
|
|
380
|
+
}
|
|
381
|
+
return opts
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function applyOverride(profile, version) {
|
|
385
|
+
const path = join(profile, 'pnpm-workspace.yaml')
|
|
386
|
+
const before = readFileSync(path, 'utf8')
|
|
387
|
+
writeFileSync(path, writeOverride(before, version), { mode: 0o600 })
|
|
388
|
+
return before
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function install(profile, runner) {
|
|
392
|
+
log(` pnpm install (${runner.length > 1 ? 'app-bundled pnpm 11' : runner[0]}) …`)
|
|
393
|
+
// `--no-frozen-lockfile` is required: changing `overrides` must rewrite the
|
|
394
|
+
// lockfile, and `CI=true` (set below to avoid interactive aborts) otherwise
|
|
395
|
+
// implies a frozen install and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
|
|
396
|
+
const result = run(runner[0], [...runner.slice(1), 'install', '--no-frozen-lockfile'], {
|
|
397
|
+
cwd: profile,
|
|
398
|
+
timeout: 900_000,
|
|
399
|
+
env: { ...process.env, CI: 'true' },
|
|
400
|
+
})
|
|
401
|
+
if (result.status !== 0) {
|
|
402
|
+
const detail = `${result.stdout ?? ''}\n${result.stderr ?? ''}`
|
|
403
|
+
.split('\n')
|
|
404
|
+
.filter((line) => line.trim() !== '' && !line.includes('NPM_TOKEN'))
|
|
405
|
+
.slice(-25)
|
|
406
|
+
.join('\n')
|
|
407
|
+
throw new Error(`pnpm install failed (${result.status}):\n${detail}`)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Resolve the pnpm the Harness plugin manager itself uses. The app bundles its
|
|
413
|
+
* own (11.x); a newer/older PATH pnpm can decide the existing profile's
|
|
414
|
+
* `node_modules` came from an incompatible version and refuse to continue
|
|
415
|
+
* without a TTY (`ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`).
|
|
416
|
+
* @returns argv prefix, e.g. `[node, pnpm.cjs]` or `[pnpm]`
|
|
417
|
+
*/
|
|
418
|
+
function resolvePnpm(explicit) {
|
|
419
|
+
if (explicit !== undefined) return [explicit]
|
|
420
|
+
if (existsSync(APP_PNPM)) return [process.execPath, APP_PNPM]
|
|
421
|
+
return ['pnpm']
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function main() {
|
|
425
|
+
const opts = parseArgs(process.argv.slice(2))
|
|
426
|
+
const baseline = existsSync(BASELINE_FILE) ? readJson(BASELINE_FILE) : undefined
|
|
427
|
+
const current = installedCodexVersion(opts.profile)
|
|
428
|
+
const target = opts.rollback ? baseline?.codex : (opts.to ?? latestPublishedVersion())
|
|
429
|
+
if (target === undefined) throw new Error('nothing to roll back to: no baseline recorded')
|
|
430
|
+
|
|
431
|
+
log(`profile: ${opts.profile}`)
|
|
432
|
+
log(`current: ${current} (installed)`)
|
|
433
|
+
log(`baseline: ${baseline?.codex ?? '<none recorded>'}`)
|
|
434
|
+
log(`target: ${target}`)
|
|
435
|
+
|
|
436
|
+
if (current === target && !opts.rollback && !opts.verifyOnly) {
|
|
437
|
+
log(`\n已是最新且已通过验证的版本,无需操作。`)
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
if (opts.check) {
|
|
441
|
+
log(`\n--check: 目标版本与当前不同,未做任何改动。`)
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const previous = baseline?.codex ?? current
|
|
446
|
+
const binary = join(opts.profile, CODEX_ENTRY)
|
|
447
|
+
const runner = resolvePnpm(opts.pnpm)
|
|
448
|
+
|
|
449
|
+
// Verify the version already on disk without touching the override or pnpm.
|
|
450
|
+
if (opts.verifyOnly) {
|
|
451
|
+
try {
|
|
452
|
+
log('\n[gate 1/3] handshake')
|
|
453
|
+
log(` ok — ${await gateHandshake(binary)}`)
|
|
454
|
+
log('[gate 2/3] protocol schema')
|
|
455
|
+
log(` ok — ${gateSchema(binary)}`)
|
|
456
|
+
if (opts.skipTurn) {
|
|
457
|
+
log('[gate 3/3] real turn — SKIPPED (--skip-turn)')
|
|
458
|
+
} else {
|
|
459
|
+
log('[gate 3/3] real turn (spends a little Codex quota)')
|
|
460
|
+
const turn = await gateTurn(binary)
|
|
461
|
+
log(` ok — answer ${JSON.stringify(turn.answer)}${turn.phased ? ' (phase: final_answer)' : ' (unphased fallback)'}`)
|
|
462
|
+
}
|
|
463
|
+
writeFileSync(BASELINE_FILE, `${JSON.stringify({
|
|
464
|
+
codex: current,
|
|
465
|
+
verifiedAt: new Date().toISOString(),
|
|
466
|
+
previous,
|
|
467
|
+
gates: { handshake: 'ok', schema: 'ok', turn: opts.skipTurn ? 'skipped' : 'ok' },
|
|
468
|
+
}, null, 2)}\n`)
|
|
469
|
+
log(`\n✅ ${current} 已就地验证通过(未改动任何东西)`)
|
|
470
|
+
} catch (error) {
|
|
471
|
+
log(`\n❌ ${current} 未通过:${error.message}`)
|
|
472
|
+
process.exitCode = 1
|
|
473
|
+
}
|
|
474
|
+
return
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const beforeWorkspace = applyOverride(opts.profile, target)
|
|
478
|
+
let applied = false
|
|
479
|
+
try {
|
|
480
|
+
install(opts.profile, runner)
|
|
481
|
+
applied = true
|
|
482
|
+
const installed = installedCodexVersion(opts.profile)
|
|
483
|
+
if (installed !== target) throw new Error(`override did not take effect: installed ${installed}, wanted ${target}`)
|
|
484
|
+
|
|
485
|
+
log('\n[gate 1/3] handshake')
|
|
486
|
+
const userAgent = await gateHandshake(binary)
|
|
487
|
+
log(` ok — ${userAgent}`)
|
|
488
|
+
|
|
489
|
+
log('[gate 2/3] protocol schema')
|
|
490
|
+
log(` ok — ${gateSchema(binary)}`)
|
|
491
|
+
|
|
492
|
+
if (opts.skipTurn) {
|
|
493
|
+
log('[gate 3/3] real turn — SKIPPED (--skip-turn)')
|
|
494
|
+
} else {
|
|
495
|
+
log('[gate 3/3] real turn (spends a little Codex quota)')
|
|
496
|
+
const turn = await gateTurn(binary)
|
|
497
|
+
log(` ok — answer ${JSON.stringify(turn.answer)}${turn.phased ? ' (phase: final_answer)' : ' (unphased fallback)'}`)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
writeFileSync(BASELINE_FILE, `${JSON.stringify({
|
|
501
|
+
codex: target,
|
|
502
|
+
verifiedAt: new Date().toISOString(),
|
|
503
|
+
previous,
|
|
504
|
+
gates: { handshake: 'ok', schema: 'ok', turn: opts.skipTurn ? 'skipped' : 'ok' },
|
|
505
|
+
}, null, 2)}\n`)
|
|
506
|
+
log(`\n✅ 升级到 ${target} 并通过验证,已记录到 scripts/codex-baseline.json`)
|
|
507
|
+
} catch (error) {
|
|
508
|
+
log(`\n❌ ${target} 未通过:${error.message}`)
|
|
509
|
+
log(`↩️ 回退到 ${previous}`)
|
|
510
|
+
void applied
|
|
511
|
+
// Always restore verbatim: writing an override that pins the *old* version
|
|
512
|
+
// would leave the profile changed by a failed run.
|
|
513
|
+
writeFileSync(join(opts.profile, 'pnpm-workspace.yaml'), beforeWorkspace, { mode: 0o600 })
|
|
514
|
+
try {
|
|
515
|
+
install(opts.profile, runner)
|
|
516
|
+
log(` 回退完成,当前版本 ${installedCodexVersion(opts.profile)}`)
|
|
517
|
+
} catch (rollbackError) {
|
|
518
|
+
log(` ⚠️ 回退安装失败:${rollbackError.message}`)
|
|
519
|
+
log(` 请手动恢复 ${join(opts.profile, 'pnpm-workspace.yaml')}`)
|
|
520
|
+
}
|
|
521
|
+
process.exitCode = 1
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
await main()
|