@whaletalk/dsh-codex-collab 0.1.6 → 0.1.9

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.
@@ -0,0 +1,407 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * codex-desktop-probe.mjs —— 只读的桌面接入能力探针(doctor)。
4
+ *
5
+ * 回答一个问题:**本机现在能不能连上 Codex Desktop 正在使用的那个后台**,从而把消息投进
6
+ * 它已经打开的那条聊天?本探针只观察、不写入、不启动任何服务、不读凭据、不碰会话存储。
7
+ *
8
+ * 判定纪律(P2-4 之后):
9
+ * - "是桌面后台"必须由**父进程/祖先/二进制归属**证明,而不是"命令行里有 app-server"。
10
+ * 手工起在终端里的 app-server 会单列为 standalone 候选,绝不当成桌面。
11
+ * - **同一份存储不能证明是同一后台**:本探针不拿 storage 做判定,只在报告里附注。
12
+ * - 进程扫描或 TCP 扫描**失败** → probe-inconclusive(而不是宣称没有端点)。
13
+ * - 控制套接字存在、或 daemon 探测成功、或桌面 app-server 用了非 stdio 的 --listen
14
+ * → endpoint-present-unverified(**不因为"没有 TCP 监听"就声称没有端点**)。
15
+ * - canWakeIdle / canObserveTurns 一律为 false,直到真的完成协议校验与同聊天 canary。
16
+ *
17
+ * 输出:JSON(stdout)+ 人话摘要(stderr)。`--schema` 会把协议 JSON Schema 生成到临时目录
18
+ * 检查关键方法后删除(删除前显式校验目标位于系统临时根之内)。
19
+ */
20
+ import { execFileSync } from 'node:child_process'
21
+ import fs from 'node:fs'
22
+ import os from 'node:os'
23
+ import path from 'node:path'
24
+ import { fileURLToPath } from 'node:url'
25
+
26
+ /** 三种结论。 */
27
+ export const PROBE_REASONS = {
28
+ UNAVAILABLE: 'desktop-attachment-unavailable',
29
+ ENDPOINT_UNVERIFIED: 'endpoint-present-unverified',
30
+ INCONCLUSIVE: 'probe-inconclusive',
31
+ }
32
+
33
+ const DESKTOP_APP_RE = /^(chatgpt|codex)\.exe$/i
34
+
35
+ /** 安装式应用路径(Store 包 / Programs)——用来认同"这是桌面 App 本体"。 */
36
+ export const isInstalledAppPath = (exe) => {
37
+ if (typeof exe !== 'string' || exe === '') return false
38
+ const lower = exe.toLowerCase()
39
+ return lower.includes('\\program files\\windowsapps\\') || lower.includes('\\appdata\\local\\programs\\') || /\\programs\\[^\\]+\\/.test(lower)
40
+ }
41
+
42
+ /**
43
+ * 候选桌面 App 进程:名字是 ChatGPT/Codex 且位于安装式路径。
44
+ * 注意:桌面用的 bundled CLI 也在 %LOCALAPPDATA%\OpenAI\Codex\bin 下,所以**二进制路径本身
45
+ * 不足以判定**桌面——必须靠祖先链(见 classifyProcesses)。
46
+ */
47
+ export const isDesktopAppProcess = (proc) => {
48
+ if (proc === null || typeof proc !== 'object') return false
49
+ const name = typeof proc.Name === 'string' ? proc.Name : ''
50
+ return DESKTOP_APP_RE.test(name) && isInstalledAppPath(proc.ExecutablePath)
51
+ }
52
+
53
+ const looksLikeAppServer = (proc) => typeof proc.CommandLine === 'string'
54
+ && /\bapp-server\b/.test(proc.CommandLine)
55
+ && !/mcp-server/.test(proc.CommandLine)
56
+
57
+ const listenUrlOf = (commandLine) => {
58
+ if (typeof commandLine !== 'string') return null
59
+ const m = /--listen\s+(\S+)/.exec(commandLine)
60
+ return m === null ? null : m[1]
61
+ }
62
+
63
+ const ancestryOf = (proc, byPid, maxDepth = 6) => {
64
+ const chain = []
65
+ let current = proc
66
+ for (let depth = 0; depth < maxDepth; depth++) {
67
+ const parentId = current === null || typeof current !== 'object' ? undefined : current.ParentProcessId
68
+ const parent = byPid.get(parentId)
69
+ if (parent === undefined) break
70
+ chain.push({ pid: parent.ProcessId, Name: parent.Name, ExecutablePath: parent.ExecutablePath, CommandLine: parent.CommandLine, name: parent.Name, executable: parent.ExecutablePath })
71
+ current = parent
72
+ }
73
+ return chain
74
+ }
75
+
76
+ /**
77
+ * 纯分类:把"命令行里带 app-server 的进程"分成桌面 / 独立 CLI / 无法确认。
78
+ *
79
+ * - desktop:祖先链里**先**遇到安装式桌面 App(ChatGPT.exe/Codex.exe 在安装路径下);
80
+ * - standalone:祖先链里**先**遇到另一份 app-server(说明本实例是由那份后台派生的),
81
+ * 或能确认祖先链里既没有桌面 App 也没有别的 app-server(手工起的 CLI);
82
+ * - unidentified:祖先链不完整(拿不到父进程快照)→ 既不认作桌面,也不当作独立 CLI。
83
+ *
84
+ * 关键点:**祖先同属桌面并不足以证明某个 app-server 就是桌面正在使用的那个后台**。
85
+ * 桌面自己的运行时(node_repl/node)会派生出额外的 app-server 实例,它们的祖先链会先穿过
86
+ * 桌面那个主 app-server —— 这类实例必须单列,不能算作桌面端点。
87
+ */
88
+ export const classifyProcesses = ({ processes, scanOk, scanError }) => {
89
+ if (scanOk !== true) {
90
+ return { ok: false, error: scanError === undefined ? 'process scan failed' : scanError, desktop: [], standalone: [], unidentified: [], appServerCount: 0 }
91
+ }
92
+ const list = Array.isArray(processes) ? processes : []
93
+ const byPid = new Map(list.map((p) => [p.ProcessId, p]))
94
+ const desktop = []
95
+ const standalone = []
96
+ const unidentified = []
97
+ for (const proc of list) {
98
+ if (!looksLikeAppServer(proc)) continue
99
+ const ancestry = ancestryOf(proc, byPid)
100
+ const entry = {
101
+ pid: proc.ProcessId,
102
+ executable: proc.ExecutablePath,
103
+ listenUrl: listenUrlOf(proc.CommandLine) === null ? 'stdio:// (default)' : listenUrlOf(proc.CommandLine),
104
+ tcpListeners: [],
105
+ ancestry,
106
+ }
107
+ // 从近到远扫描祖先:谁先出现就按谁分类(另一份 app-server 会遮断归属)。
108
+ let classified = null
109
+ for (const ancestor of ancestry) {
110
+ if (looksLikeAppServer(ancestor)) {
111
+ classified = { kind: 'standalone', why: 'ancestry passes through another app-server (pid ' + ancestor.pid + '); not proven to be the desktop backend' }
112
+ break
113
+ }
114
+ if (isDesktopAppProcess({ Name: ancestor.name, ExecutablePath: ancestor.executable })) {
115
+ classified = { kind: 'desktop', why: 'ancestor ' + ancestor.name + ' (pid ' + ancestor.pid + ') at ' + ancestor.executable }
116
+ break
117
+ }
118
+ }
119
+ if (classified !== null && classified.kind === 'desktop') {
120
+ desktop.push({ ...entry, verifiedAsDesktop: true, why: classified.why })
121
+ } else if (classified !== null) {
122
+ standalone.push({ ...entry, verifiedAsDesktop: false, why: classified.why })
123
+ } else if (ancestry.length > 0) {
124
+ standalone.push({ ...entry, verifiedAsDesktop: false, why: 'ancestry has no installed desktop app and no other app-server: ' + ancestry.map((a) => a.name).join(' < ') })
125
+ } else {
126
+ unidentified.push({ ...entry, verifiedAsDesktop: false, why: 'ancestry unresolved (parent process not in snapshot)' })
127
+ }
128
+ }
129
+ const appServerCount = desktop.length + standalone.length + unidentified.length
130
+ return { ok: true, desktop, standalone, unidentified, appServerCount }
131
+ }
132
+
133
+ /**
134
+ * 纯判定:证据 → 能力结论。**任何扫描失败都先判 inconclusive**,绝不猜"没有端点"。
135
+ * canWakeIdle/canObserveTurns 恒为 false:它们要等协议校验 + 同一聊天 canary 真的通过。
136
+ */
137
+ export const decideCapabilities = (evidence) => {
138
+ const processScan = evidence.processScan === undefined ? { ok: false, error: 'missing' } : evidence.processScan
139
+ const listenerScan = evidence.listenerScan === undefined ? { ok: false, error: 'missing' } : evidence.listenerScan
140
+ const controlSocket = evidence.controlSocket === undefined ? { exists: false } : evidence.controlSocket
141
+ const daemon = evidence.daemon === undefined ? { attempted: false, ok: false } : evidence.daemon
142
+
143
+ const failedScans = []
144
+ if (processScan.ok !== true) failedScans.push('process scan: ' + (processScan.error === undefined ? 'failed' : processScan.error))
145
+ if (listenerScan.ok !== true) failedScans.push('listener scan: ' + (listenerScan.error === undefined ? 'failed' : listenerScan.error))
146
+ if (failedScans.length > 0) {
147
+ return {
148
+ available: false,
149
+ deliveryMode: 'inbox',
150
+ canWakeIdle: false,
151
+ canObserveTurns: false,
152
+ reason: PROBE_REASONS.INCONCLUSIVE,
153
+ detail: 'cannot conclude anything about desktop attachment: ' + failedScans.join('; '),
154
+ signals: { attachableDesktopServers: 0, controlSocket: controlSocket.exists === true, daemonOk: daemon.ok === true },
155
+ }
156
+ }
157
+
158
+ const desktop = Array.isArray(processScan.desktop) ? processScan.desktop : []
159
+ const attachable = desktop.filter((d) => typeof d.listenUrl === 'string' && !d.listenUrl.startsWith('stdio://'))
160
+ const endpointSignals = []
161
+ if (attachable.length > 0) endpointSignals.push(attachable.length + ' verified desktop app-server(s) on a non-stdio --listen URL: ' + attachable.map((d) => d.listenUrl).join(', '))
162
+ if (controlSocket.exists === true) endpointSignals.push('app-server control socket exists at ' + controlSocket.path)
163
+ if (daemon.ok === true) endpointSignals.push('app-server daemon probe succeeded')
164
+
165
+ if (endpointSignals.length > 0) {
166
+ return {
167
+ available: false,
168
+ deliveryMode: 'inbox',
169
+ canWakeIdle: false,
170
+ canObserveTurns: false,
171
+ reason: PROBE_REASONS.ENDPOINT_UNVERIFIED,
172
+ detail: 'a possible endpoint exists but has NOT been verified: ' + endpointSignals.join('; ') + '. Still required: connect to the same backend, thread/loaded/list must contain the target, observe live turns, then a same-chat canary.',
173
+ signals: { attachableDesktopServers: attachable.length, controlSocket: controlSocket.exists === true, daemonOk: daemon.ok === true },
174
+ }
175
+ }
176
+
177
+ const detail = desktop.length === 0
178
+ ? 'no verified desktop app-server observed (npx/standalone app-servers do not count; same storage dir is not proof of the same backend); no control socket; daemon probe failed or unsupported'
179
+ : 'verified desktop app-server runs stdio:// owned by its desktop parent; no non-stdio --listen URL; no control socket; daemon probe failed or unsupported'
180
+
181
+ return {
182
+ available: false,
183
+ deliveryMode: 'inbox',
184
+ canWakeIdle: false,
185
+ canObserveTurns: false,
186
+ reason: PROBE_REASONS.UNAVAILABLE,
187
+ detail,
188
+ signals: { attachableDesktopServers: 0, controlSocket: controlSocket.exists === true, daemonOk: daemon.ok === true },
189
+ }
190
+ }
191
+
192
+ /** 删除前必须证明目标确实位于预期父目录内。 */
193
+ export const isInsideDirectory = (candidate, parent) => {
194
+ const child = path.resolve(candidate)
195
+ const base = path.resolve(parent)
196
+ const relative = path.relative(base, child)
197
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
198
+ }
199
+
200
+ const safe = (fn, fallback) => {
201
+ try { return fn() } catch (e) { return fallback }
202
+ }
203
+
204
+ /** 采集脚本用它自己的退出码和这一行标记来区分"空结果"与"没结果"。 */
205
+ const SCAN_MARKER = 'DSH_PROBE_JSON:'
206
+
207
+ /**
208
+ * 采集层解析(纯函数,可测)。
209
+ *
210
+ * 为什么必须这么严:PowerShell 的**非终止**查询错误会让进程以 0 退出、stdout 为空。
211
+ * 如果把它当成"扫描成功且结果为空",探针就会从"没查到"误推成"没有端点"(unavailable)。
212
+ * 所以约定:脚本必须打印 `DSH_PROBE_JSON:<json>`;没有这一行、或退出码非 0、或 JSON 解不出,
213
+ * 一律返回 ok:false,由判定层给出 probe-inconclusive。
214
+ */
215
+ export const parseScanOutput = ({ status, stdout, stderr, kind }) => {
216
+ const detail = stderr === undefined || String(stderr).trim() === '' ? '' : (': ' + String(stderr).trim().slice(0, 200))
217
+ // 退出码优先:非零(或根本不是整数、无法确认)时先失败——即使 stdout 里带了一个合法 payload,
218
+ // 也不能当作"采集成功"。否则 `exit 1 + DSH_PROBE_JSON:[]` 会被读成"扫描成功且结果为空",
219
+ // 进而把"没查到"误推成"没有端点"。
220
+ if (typeof status !== 'number' || !Number.isInteger(status) || status !== 0) {
221
+ return { ok: false, error: kind + ' scan exited non-zero or unconfirmed (exit ' + status + ')' + detail }
222
+ }
223
+ const text = typeof stdout === 'string' ? stdout : ''
224
+ const line = text.split('\n').find((l) => l.startsWith(SCAN_MARKER))
225
+ if (line === undefined) {
226
+ return { ok: false, error: kind + ' scan produced no payload (exit ' + status + ')' + detail }
227
+ }
228
+ let parsed
229
+ try {
230
+ parsed = JSON.parse(line.slice(SCAN_MARKER.length))
231
+ } catch (e) {
232
+ return { ok: false, error: kind + ' scan payload is not valid JSON' + detail }
233
+ }
234
+ if (parsed === null || parsed === undefined) return { ok: false, error: kind + ' scan payload was null' + detail }
235
+ return { ok: true, items: Array.isArray(parsed) ? parsed : [parsed] }
236
+ }
237
+
238
+ const runPowershell = (script) => {
239
+ for (const shell of ['pwsh', 'powershell']) {
240
+ try {
241
+ const stdout = execFileSync(shell, ['-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, windowsHide: true })
242
+ return { status: 0, stdout, stderr: '' }
243
+ } catch (e) {
244
+ if (e.code === 'ENOENT') continue
245
+ // 非零退出:保留 stdout/stderr 供解析层判断
246
+ return {
247
+ status: typeof e.status === 'number' ? e.status : 1,
248
+ stdout: e.stdout === undefined ? '' : e.stdout,
249
+ stderr: e.stderr === undefined ? e.message : e.stderr,
250
+ }
251
+ }
252
+ }
253
+ return { status: -1, stdout: '', stderr: 'no powershell available' }
254
+ }
255
+
256
+ const DEBUG_SILENT = '$ProgressPreference = "SilentlyContinue"; '
257
+
258
+ const readProcesses = () => {
259
+ // -ErrorAction Stop:让查询错误变成**终止**错误(非零退出),不再被静默吞掉。
260
+ const script = DEBUG_SILENT
261
+ + '$ErrorActionPreference = "Stop"; '
262
+ + 'try { '
263
+ + " $items = @(Get-CimInstance Win32_Process | Where-Object { $_.Name -match 'codex|ChatGPT' -or $_.Name -eq 'node_repl.exe' -or ($_.ExecutablePath -ne $null -and $_.ExecutablePath -like '*\\OpenAI\\Codex\\*') } | Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine); "
264
+ + " $json = if ($items.Count -eq 0) { '[]' } else { $items | ConvertTo-Json -Compress -Depth 4 }; "
265
+ + " Write-Output ('" + SCAN_MARKER + "' + $json) "
266
+ + '} catch { Write-Error $_; exit 1 }'
267
+ const result = runPowershell(script)
268
+ const parsed = parseScanOutput({ ...result, kind: 'process' })
269
+ if (parsed.ok !== true) return { ok: false, error: parsed.error, processes: [] }
270
+ return { ok: true, processes: parsed.items }
271
+ }
272
+
273
+ const readListeners = () => {
274
+ const script = DEBUG_SILENT
275
+ + '$ErrorActionPreference = "Stop"; '
276
+ + 'try { '
277
+ + ' $items = @(Get-NetTCPConnection -State Listen | Select-Object LocalAddress,LocalPort,OwningProcess); '
278
+ + " $json = if ($items.Count -eq 0) { '[]' } else { $items | ConvertTo-Json -Compress -Depth 3 }; "
279
+ + " Write-Output ('" + SCAN_MARKER + "' + $json) "
280
+ + '} catch { Write-Error $_; exit 1 }'
281
+ const result = runPowershell(script)
282
+ const parsed = parseScanOutput({ ...result, kind: 'listener' })
283
+ if (parsed.ok !== true) return { ok: false, error: parsed.error, listeners: [] }
284
+ return { ok: true, listeners: parsed.items }
285
+ }
286
+
287
+ const readPipes = () => safe(() => fs.readdirSync('\\\\.\\pipe\\').filter((n) => /codex/i.test(n)).sort(), [])
288
+
289
+ const versionOf = (exe) => {
290
+ if (typeof exe !== 'string' || exe === '' || !fs.existsSync(exe)) return null
291
+ return safe(() => execFileSync(exe, ['--version'], { encoding: 'utf8', timeout: 20000, windowsHide: true }).trim(), null)
292
+ }
293
+
294
+ /** 取全局 CLI 版本:Windows 上 `codex` 是 `.cmd` 垫片,直接 spawn 会失败,退回 shim + shell。 */
295
+ const globalCliVersion = () => {
296
+ const direct = safe(() => execFileSync('codex', ['--version'], { encoding: 'utf8', timeout: 20000, windowsHide: true }).trim(), null)
297
+ if (direct !== null && direct !== '') return direct
298
+ const shim = path.join(process.env.APPDATA === undefined ? '' : process.env.APPDATA, 'npm', 'codex.cmd')
299
+ if (!fs.existsSync(shim)) return null
300
+ const viaShell = safe(() => execFileSync(shim, ['--version'], { encoding: 'utf8', timeout: 20000, windowsHide: true, shell: true }).trim(), null)
301
+ return viaShell === null || viaShell === '' ? null : viaShell
302
+ }
303
+
304
+ const probeDaemon = (exe) => {
305
+ if (typeof exe !== 'string' || exe === '' || !fs.existsSync(exe)) return { attempted: false, ok: false, reason: 'no bundled codex cli found' }
306
+ try {
307
+ const out = execFileSync(exe, ['app-server', 'daemon', 'version'], { encoding: 'utf8', timeout: 20000, windowsHide: true })
308
+ return { attempted: true, ok: true, output: out.trim().slice(0, 400) }
309
+ } catch (e) {
310
+ return { attempted: true, ok: false, error: ((e.stdout === undefined ? '' : e.stdout) + (e.stderr === undefined ? '' : e.stderr)).trim().slice(0, 400) }
311
+ }
312
+ }
313
+
314
+ const checkSchema = (exe) => {
315
+ if (typeof exe !== 'string' || exe === '' || !fs.existsSync(exe)) return { attempted: false, reason: 'no bundled codex cli found' }
316
+ const tempRoot = path.resolve(os.tmpdir())
317
+ const dir = fs.mkdtempSync(path.join(tempRoot, 'codex-schema-probe-'))
318
+ try {
319
+ execFileSync(exe, ['app-server', 'generate-json-schema', '--out', dir], { encoding: 'utf8', timeout: 60000, windowsHide: true })
320
+ const files = fs.readdirSync(dir, { recursive: true })
321
+ const has = (needle) => files.some((f) => String(f).includes(needle))
322
+ return {
323
+ attempted: true,
324
+ ok: true,
325
+ keyMethods: {
326
+ initialize: has('InitializeParams'),
327
+ threadLoadedList: has('ThreadLoadedListParams'),
328
+ threadRead: has('ThreadReadParams'),
329
+ threadList: has('ThreadListParams'),
330
+ turnStart: has('TurnStartParams'),
331
+ turnSteer: has('TurnSteerParams'),
332
+ turnInterrupt: has('TurnInterruptParams'),
333
+ threadInjectItems: has('ThreadInjectItemsParams'),
334
+ },
335
+ }
336
+ } catch (e) {
337
+ return { attempted: true, ok: false, error: (e.message || '').slice(0, 300) }
338
+ } finally {
339
+ // 递归删除前显式校验:解析后的目标必须真的在系统临时根之内。
340
+ if (isInsideDirectory(dir, tempRoot)) {
341
+ safe(() => fs.rmSync(dir, { recursive: true, force: true }), null)
342
+ } else {
343
+ process.stderr.write('refusing to remove schema dir outside the temp root: ' + dir + '\n')
344
+ }
345
+ }
346
+ }
347
+
348
+ const main = () => {
349
+ const flags = new Set(process.argv.slice(2).filter((a) => a.startsWith('--')))
350
+ const wantSchema = flags.has('--schema')
351
+
352
+ const processRead = readProcesses()
353
+ const listenerRead = readListeners()
354
+ const classified = classifyProcesses({ processes: processRead.processes, scanOk: processRead.ok, scanError: processRead.error })
355
+
356
+ // 把 TCP 监听挂到分类结果上(只按 pid 对应,不做其它推断)
357
+ const listeners = listenerRead.listeners
358
+ for (const group of [classified.desktop, classified.standalone, classified.unidentified]) {
359
+ for (const entry of group) entry.tcpListeners = listeners.filter((l) => l.OwningProcess === entry.pid).map((l) => l.LocalAddress + ':' + l.LocalPort)
360
+ }
361
+
362
+ const controlSocket = path.join(os.homedir(), '.codex', 'app-server-control', 'app-server-control.sock')
363
+ const bundledExe = classified.desktop.length > 0 ? classified.desktop[0].executable : (classified.standalone.length > 0 ? classified.standalone[0].executable : null)
364
+
365
+ const evidence = {
366
+ processScan: { ok: processRead.ok, error: processRead.error, ...classified },
367
+ listenerScan: { ok: listenerRead.ok, error: listenerRead.error, listeners },
368
+ controlSocket: { path: controlSocket, exists: fs.existsSync(controlSocket) },
369
+ daemon: probeDaemon(bundledExe),
370
+ }
371
+ const capabilities = decideCapabilities(evidence)
372
+ const schema = wantSchema ? checkSchema(bundledExe) : { attempted: false, reason: 'pass --schema to check protocol surface' }
373
+
374
+ const report = {
375
+ probe: 'codex-desktop-attachment',
376
+ platform: process.platform,
377
+ arch: process.arch,
378
+ hostname: os.hostname(),
379
+ globalCliVersion: globalCliVersion(),
380
+ processScan: { ok: processRead.ok, error: processRead.error, appServerCount: classified.appServerCount },
381
+ desktop: classified.desktop,
382
+ standalone: classified.standalone,
383
+ unidentified: classified.unidentified,
384
+ listenerScan: { ok: listenerRead.ok, error: listenerRead.error, count: listeners.length },
385
+ controlSocket: evidence.controlSocket,
386
+ daemonProbe: evidence.daemon,
387
+ namedPipes: { count: readPipes().length, sample: readPipes().slice(0, 8) },
388
+ schema,
389
+ capabilities,
390
+ notes: [
391
+ 'read-only probe: no writes outside an optional temp schema dir (validated before deletion), no service started, no credentials read, no session storage touched',
392
+ 'desktop identity requires a verified desktop ancestor, not just an app-server command line; standalone app-servers are listed separately',
393
+ 'the same storage directory does NOT prove the same backend and is deliberately not used as evidence here',
394
+ 'ordinary MCP notifications and Work-Cloud-only MCP Events are NOT local desktop wake and are not counted here',
395
+ ],
396
+ }
397
+
398
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n')
399
+ process.stderr.write('capabilities: deliveryMode=' + capabilities.deliveryMode + ' canWakeIdle=' + capabilities.canWakeIdle + ' reason=' + capabilities.reason + '\n')
400
+ process.stderr.write('desktop app-servers: ' + (classified.desktop.length === 0 ? 'none verified' : classified.desktop.map((d) => 'pid=' + d.pid + ' listen=' + d.listenUrl).join('; ')) + '\n')
401
+ process.stderr.write('standalone app-servers: ' + (classified.standalone.length === 0 ? 'none' : classified.standalone.map((d) => 'pid=' + d.pid).join(', ')) + '; control socket exists: ' + evidence.controlSocket.exists + '\n')
402
+ }
403
+
404
+ /** 只有作为程序直接运行时才真的探测;被 import(例如判定测试)时不得产生副作用。 */
405
+ const isDirectRun = process.argv[1] !== undefined && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))
406
+
407
+ if (isDirectRun) main()
@@ -0,0 +1,271 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * codex-inbox.mjs —— DSH → Codex 收件箱的命令行入口(零依赖)。
4
+ *
5
+ * 用途:M0 已证明本机没有可附着的桌面端点(见 docs/codex-desktop-attachment-evidence.md),
6
+ * 所以"自动唤醒桌面聊天"处于 blocked。这条 CLI 交付的是可独立验证的另一半:
7
+ * - DSH 侧:`send` 把要告诉 Codex 的话写进本地持久化收件箱(写盘成功才算 queued)。
8
+ * - Codex 侧/用户:`pending` 看到有哪些待办,`show` 看正文,`ack` 确认处理并回填回复。
9
+ *
10
+ * 能力如实上报:`capabilities` 永远显示 canWakeIdle=false / deliveryMode=inbox。
11
+ * 不写仓库、不读凭据、不启动任何服务、不触碰 Codex 会话存储。
12
+ *
13
+ * 参数解析是**严格**的(P2-3):除 `--help`/`--json` 两个布尔旗标外,任何带值旗标
14
+ * 出现却没有值(或出现未知旗标)都直接 exit 2,且校验发生在**打开存储、做任何变更之前**。
15
+ * 这样 `ack --reply`(缺值)不会被悄悄当成 undefined 而照常确认,`send --key`(缺值)
16
+ * 不会静默丢掉幂等键,`--root`(缺值)也不会悄悄回落到真实默认目录。
17
+ *
18
+ * 存储默认在 ~/.dsh-codex-inbox,可用 --root 或 DSH_CODEX_INBOX_DIR 覆盖。
19
+ */
20
+ import { INBOX_REASONS, MESSAGE_STATES, openOutbox } from '../harness/codex-outbox.mjs'
21
+ import { fileURLToPath } from 'node:url'
22
+ import path from 'node:path'
23
+
24
+ const EXIT = { OK: 0, ERROR: 1, USAGE: 2, UNBOUND: 3, CONFLICT: 4, UNKNOWN_MESSAGE: 5, ILLEGAL_STATE: 6 }
25
+
26
+ const REASON_EXIT = {
27
+ [INBOX_REASONS.UNBOUND_TARGET]: EXIT.UNBOUND,
28
+ [INBOX_REASONS.IDEMPOTENCY_CONFLICT]: EXIT.CONFLICT,
29
+ [INBOX_REASONS.UNKNOWN_MESSAGE]: EXIT.UNKNOWN_MESSAGE,
30
+ [INBOX_REASONS.ILLEGAL_STATE]: EXIT.ILLEGAL_STATE,
31
+ [INBOX_REASONS.INVALID_INPUT]: EXIT.USAGE,
32
+ [INBOX_REASONS.BINDING_THREAD_MISMATCH]: EXIT.CONFLICT,
33
+ [INBOX_REASONS.STORE_CORRUPT]: EXIT.ERROR,
34
+ [INBOX_REASONS.STORE_UNREADABLE]: EXIT.ERROR,
35
+ [INBOX_REASONS.LOCK_TIMEOUT]: EXIT.ERROR,
36
+ [INBOX_REASONS.LOCK_STALE_HOLDER]: EXIT.ERROR,
37
+ [INBOX_REASONS.LOCK_UNVERIFIED_HOLDER]: EXIT.ERROR,
38
+ [INBOX_REASONS.WRITE_FAILED]: EXIT.ERROR,
39
+ }
40
+
41
+ /** 带值的旗标:出现就必须紧跟一个值。 */
42
+ const VALUE_FLAGS = new Set(['root', 'binding', 'thread', 'source', 'note', 'text', 'key', 'deliver', 'reply', 'turn', 'by', 'reason', 'state', 'limit'])
43
+ /** 布尔旗标:唯一允许不带值的两种。 */
44
+ const BOOLEAN_FLAGS = new Set(['json', 'help'])
45
+
46
+ const USAGE = `codex-inbox —— DSH → Codex 本地收件箱(当前能力:只有收件箱,无桌面自动唤醒)
47
+
48
+ DSH 侧(发件):
49
+ codex-inbox send --binding <bindingId> --text "<正文>" [--key <幂等键>] [--source <DSH会话id>] [--deliver queue|steer]
50
+ codex-inbox status <messageId>
51
+ codex-inbox list [--binding <id>] [--state queued|delivered|acked|failed|delivery-unknown] [--limit N]
52
+
53
+ Codex 侧 / 用户(收件):
54
+ codex-inbox pending [--binding <id>] # 待处理(按绑定内顺序)
55
+ codex-inbox show <messageId> # 看正文
56
+ codex-inbox delivered <messageId> [--by X] # 标记已取走
57
+ codex-inbox ack <messageId> [--reply "<回复>"] [--turn <turnId>] # 确认处理 + 回填结果
58
+
59
+ 绑定与能力:
60
+ codex-inbox bind --binding <id> --thread <codexThreadId> [--source <DSH会话id>] [--note "<说明>"]
61
+ codex-inbox bindings
62
+ codex-inbox capabilities # 只读能力报告(含 reason)
63
+
64
+ 公共: --root <dir>(默认 ~/.dsh-codex-inbox,或 DSH_CODEX_INBOX_DIR)
65
+ --json(机器可读输出;默认对对象类命令已经输出 JSON)
66
+ 带值旗标缺值、或出现未知旗标 → 退出码 2(在打开存储之前就拒绝)
67
+
68
+ 退出码: 0 成功 / 1 失败 / 2 用法 / 3 未绑定 / 4 幂等冲突 / 5 未知消息 / 6 非法状态转移`
69
+
70
+ /**
71
+ * 严格解析。返回 { positional, flags, errors };errors 非空时调用方必须在**打开存储之前**退出。
72
+ * 规则:`--help`/`--json` 可裸用;其余 `--x` 必须有值,否则记错;不认识的旗标记错。
73
+ */
74
+ export const parseArgs = (argv) => {
75
+ const positional = []
76
+ const flags = {}
77
+ const errors = []
78
+ for (let i = 0; i < argv.length; i++) {
79
+ const arg = argv[i]
80
+ if (typeof arg !== 'string' || !arg.startsWith('--')) {
81
+ positional.push(arg)
82
+ continue
83
+ }
84
+ const name = arg.slice(2)
85
+ if (name === '') { errors.push('空旗标 "--"'); continue }
86
+ if (BOOLEAN_FLAGS.has(name)) { flags[name] = true; continue }
87
+ if (!VALUE_FLAGS.has(name)) { errors.push('未知旗标 --' + name); continue }
88
+ const next = argv[i + 1]
89
+ if (next === undefined || (typeof next === 'string' && next.startsWith('--'))) {
90
+ errors.push('--' + name + ' 需要一个值')
91
+ continue
92
+ }
93
+ flags[name] = next
94
+ i++
95
+ }
96
+ return { positional, flags, errors }
97
+ }
98
+
99
+ const out = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + '\n')
100
+ const note = (text) => process.stderr.write(text + '\n')
101
+
102
+ const main = () => {
103
+ const { positional, flags, errors } = parseArgs(process.argv.slice(2))
104
+ const command = positional[0]
105
+
106
+ if (flags.help === true) {
107
+ note(USAGE)
108
+ return EXIT.OK
109
+ }
110
+ if (command === 'help') {
111
+ note(USAGE)
112
+ return EXIT.OK
113
+ }
114
+ if (command === undefined) {
115
+ note(USAGE)
116
+ return EXIT.USAGE
117
+ }
118
+ // 参数错误一律在这里结束:**尚未打开存储、尚未做任何变更**。
119
+ if (errors.length > 0) {
120
+ for (const error of errors) note('参数错误: ' + error)
121
+ note(USAGE)
122
+ return EXIT.USAGE
123
+ }
124
+ // 值域校验同样放在打开存储之前。
125
+ if (flags.deliver !== undefined && flags.deliver !== 'queue' && flags.deliver !== 'steer') {
126
+ note('参数错误: --deliver 只接受 queue 或 steer')
127
+ return EXIT.USAGE
128
+ }
129
+ if (flags.limit !== undefined && !Number.isFinite(Number(flags.limit))) {
130
+ note('参数错误: --limit 需要数字')
131
+ return EXIT.USAGE
132
+ }
133
+
134
+ try {
135
+ const box = openOutbox({ root: typeof flags.root === 'string' ? flags.root : undefined })
136
+
137
+ const requireFlag = (name) => {
138
+ const value = flags[name]
139
+ if (typeof value !== 'string' || value.trim() === '') {
140
+ note('缺少 --' + name + '\n\n' + USAGE)
141
+ process.exitCode = EXIT.USAGE
142
+ throw Object.assign(new Error('missing --' + name), { reason: 'usage' })
143
+ }
144
+ return value
145
+ }
146
+
147
+ if (command === 'capabilities') {
148
+ out(box.capabilities())
149
+ return EXIT.OK
150
+ }
151
+
152
+ if (command === 'bind') {
153
+ const binding = box.bind({
154
+ bindingId: requireFlag('binding'),
155
+ codexThreadId: requireFlag('thread'),
156
+ sourceSessionId: typeof flags.source === 'string' ? flags.source : undefined,
157
+ note: typeof flags.note === 'string' ? flags.note : undefined,
158
+ })
159
+ out({ ok: true, root: box.root, binding })
160
+ return EXIT.OK
161
+ }
162
+
163
+ if (command === 'bindings') {
164
+ out({ ok: true, root: box.root, bindings: box.listBindings(), capabilities: box.capabilities() })
165
+ return EXIT.OK
166
+ }
167
+
168
+ if (command === 'send') {
169
+ const result = box.enqueue({
170
+ bindingId: requireFlag('binding'),
171
+ text: requireFlag('text'),
172
+ idempotencyKey: typeof flags.key === 'string' ? flags.key : undefined,
173
+ sourceSessionId: typeof flags.source === 'string' ? flags.source : undefined,
174
+ deliver: typeof flags.deliver === 'string' ? flags.deliver : 'queue',
175
+ })
176
+ out({
177
+ ok: true,
178
+ duplicate: result.duplicate,
179
+ messageId: result.message.messageId,
180
+ state: result.message.state,
181
+ seq: result.message.seq,
182
+ bindingId: result.message.bindingId,
183
+ codexThreadId: result.message.codexThreadId,
184
+ capabilities: box.capabilities(),
185
+ note: result.duplicate
186
+ ? '同一幂等键与相同正文:未重复入队,返回原 messageId'
187
+ : '已写入本地收件箱(queued)。本机没有可附着的桌面端点,Codex 需主动来读(codex-inbox pending)。',
188
+ })
189
+ return EXIT.OK
190
+ }
191
+
192
+ if (command === 'pending') {
193
+ const messages = box.pending(typeof flags.binding === 'string' ? flags.binding : undefined)
194
+ out({ ok: true, root: box.root, count: messages.length, messages, capabilities: box.capabilities() })
195
+ if (messages.length === 0) note('收件箱没有待处理消息。')
196
+ else note('有待处理消息 ' + messages.length + ' 条;处理完用 codex-inbox ack <messageId> 回填。')
197
+ return EXIT.OK
198
+ }
199
+
200
+ if (command === 'list') {
201
+ const messages = box.listMessages({
202
+ bindingId: typeof flags.binding === 'string' ? flags.binding : undefined,
203
+ state: typeof flags.state === 'string' ? flags.state : undefined,
204
+ limit: flags.limit === undefined ? undefined : Number(flags.limit),
205
+ })
206
+ out({ ok: true, root: box.root, count: messages.length, messages })
207
+ return EXIT.OK
208
+ }
209
+
210
+ const messageId = positional[1]
211
+ if (messageId === undefined || messageId === '') {
212
+ note('缺少 <messageId>\n\n' + USAGE)
213
+ return EXIT.USAGE
214
+ }
215
+
216
+ if (command === 'show' || command === 'status') {
217
+ out({ ok: true, message: box.getMessage(messageId) })
218
+ return EXIT.OK
219
+ }
220
+ if (command === 'delivered') {
221
+ out({ ok: true, message: box.markDelivered(messageId, { by: typeof flags.by === 'string' ? flags.by : 'codex-side' }) })
222
+ return EXIT.OK
223
+ }
224
+ if (command === 'ack') {
225
+ out({
226
+ ok: true,
227
+ message: box.ack(messageId, {
228
+ reply: typeof flags.reply === 'string' ? flags.reply : undefined,
229
+ turnId: typeof flags.turn === 'string' ? flags.turn : undefined,
230
+ by: typeof flags.by === 'string' ? flags.by : 'codex-side',
231
+ }),
232
+ })
233
+ return EXIT.OK
234
+ }
235
+ if (command === 'fail') {
236
+ out({ ok: true, message: box.markFailed(messageId, requireFlag('reason')) })
237
+ return EXIT.OK
238
+ }
239
+ if (command === 'unknown') {
240
+ out({ ok: true, message: box.markUnknown(messageId, typeof flags.reason === 'string' ? flags.reason : undefined) })
241
+ return EXIT.OK
242
+ }
243
+
244
+ note('未知子命令: ' + command + '\n\n' + USAGE)
245
+ return EXIT.USAGE
246
+ } catch (e) {
247
+ if (e !== null && typeof e === 'object' && e.reason === 'usage') return EXIT.USAGE
248
+ const reason = e !== null && typeof e === 'object' && typeof e.reason === 'string' ? e.reason : undefined
249
+ out({ ok: false, reason: reason === undefined ? 'error' : reason, error: e.message, ...(e.messageId === undefined ? {} : { messageId: e.messageId }) })
250
+ if (reason === INBOX_REASONS.UNBOUND_TARGET) {
251
+ note('该 binding 未登记。先用 codex-inbox bind --binding <id> --thread <codexThreadId> 显式绑定(不猜目标)。')
252
+ }
253
+ if (reason === INBOX_REASONS.LOCK_TIMEOUT) {
254
+ note('另一个进程正在写这个收件箱且仍然活着:本次没有写入,也没有抢它的锁。稍后重试。')
255
+ }
256
+ if (reason === INBOX_REASONS.LOCK_STALE_HOLDER) {
257
+ note('写锁的持有者进程已经不存在,但工具**不会**在线自动回收它(在共享路径上"检查再删除"会误删别人刚建的锁)。'
258
+ + '请确认没有任何写入进程在运行,然后手动删除错误里给出的锁文件,再重试。')
259
+ }
260
+ if (reason === INBOX_REASONS.LOCK_UNVERIFIED_HOLDER) {
261
+ note('写锁的元数据无法确认持有者(空/损坏)。工具不会按时间回收它。'
262
+ + '请先确认没有写入进程在运行,再人工检查错误里给出的那个文件(确认无人使用后可自行删除)。')
263
+ }
264
+ return REASON_EXIT[reason] === undefined ? EXIT.ERROR : REASON_EXIT[reason]
265
+ }
266
+ }
267
+
268
+ /** 只有作为程序直接运行时才执行;被 import(例如解析单测)时不得调用 process.exit。 */
269
+ const isDirectRun = process.argv[1] !== undefined && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))
270
+
271
+ if (isDirectRun) process.exit(main())