|
1 | | -// QoderWork worker runtime wrapper — intercepts token data via JSON.parse hook. |
2 | | -// Loaded via: QODER_WORKER_RUNTIME_PATH=<this-file> |
| 1 | +// QoderWork-family worker runtime wrapper — transparent, app-agnostic shim. |
3 | 2 | // |
4 | | -// QoderWork runs its agent SDK in a Node.js worker_thread (not Bun), so the |
5 | | -// qodercli BUN_OPTIONS --preload trick does not apply. The SDK honors |
6 | | -// QODER_WORKER_RUNTIME_PATH as the worker entry, so we wrap it: install a |
7 | | -// JSON.parse/JSON.stringify hook, then `await import()` the real runtime. |
| 3 | +// Loaded via the SHARED env var QODER_WORKER_RUNTIME_PATH. The ENTIRE |
| 4 | +// @qoder-ai/qoder-agent-sdk family honours this variable (QoderWork, |
| 5 | +// QwenWorkCN, QoderWork CN, ...), and on macOS we set it with `launchctl |
| 6 | +// setenv`, which is GLOBAL to the launchd user domain. Consequences: |
| 7 | +// • Every GUI app inherits the variable, but only apps that actually run the |
| 8 | +// @qoder-ai SDK ever load this file as their worker entry. |
| 9 | +// • Therefore this wrapper CAN be the worker entry of ANY sibling app, not |
| 10 | +// just QoderWork. It MUST NOT assume which app loaded it. |
8 | 11 | // |
9 | | -// Writes to: ~/.loongsuite-pilot/logs/qoderwork-intercept.jsonl |
10 | | -// The captured `id` (chatcmpl-xxx) matches the hook processor's |
11 | | -// gen_ai.response.id (derived from transcript message.id), enabling direct |
12 | | -// token matching in qoder-work-trace-input without a transcript mapping module. |
| 12 | +// Design priority (do NOT weaken): NEVER break the host app. We only ever hand |
| 13 | +// control to the *host app's OWN* bundled runtime, located dynamically from the |
| 14 | +// running process. There is intentionally NO hardcoded/app-specific fallback: |
| 15 | +// loading a foreign runtime (e.g. QoderWork's runtime inside QwenWorkCN) is |
| 16 | +// exactly what corrupts the app. If we cannot locate the host app's own runtime |
| 17 | +// with certainty, we install nothing and load nothing — token interception is |
| 18 | +// sacrificed, the app is never handed a wrong runtime. |
| 19 | +// |
| 20 | +// On the success path only, token/system-prompt records are appended to |
| 21 | +// ~/.loongsuite-pilot/logs/qoderwork-intercept.jsonl. |
13 | 22 |
|
14 | 23 | import { createRequire } from 'module'; |
| 24 | +import { fileURLToPath } from 'url'; |
15 | 25 | const require = createRequire(import.meta.url); |
16 | 26 | const fs = require('node:fs'); |
17 | 27 | const path = require('node:path'); |
18 | 28 |
|
19 | 29 | const INTERCEPT_DIR = path.join(process.env.HOME || '/tmp', '.loongsuite-pilot', 'logs'); |
20 | 30 | const INTERCEPT_FILE = path.join(INTERCEPT_DIR, 'qoderwork-intercept.jsonl'); |
| 31 | +const ERROR_LOG = path.join(INTERCEPT_DIR, 'qoderwork-wrapper-error.log'); |
21 | 32 | const MIN_SYSTEM_PROMPT_LENGTH = 100; |
22 | 33 |
|
23 | | -try { fs.mkdirSync(INTERCEPT_DIR, { recursive: true }); } catch {} |
24 | | - |
25 | 34 | const origParse = JSON.parse; |
26 | 35 | const origStringify = JSON.stringify; |
27 | 36 | let lastId = null; |
28 | 37 | let systemPromptCaptured = false; |
29 | 38 |
|
30 | | -// Global override of JSON.parse to intercept SSE-parsed token usage. |
31 | | -JSON.parse = function (text, reviver) { |
32 | | - const result = origParse.call(JSON, text, reviver); |
| 39 | +function logDiag(msg) { |
33 | 40 | try { |
34 | | - if (result && typeof result === "object" |
35 | | - && result.usage && result.choices !== undefined |
36 | | - && result.id !== lastId) { |
37 | | - lastId = result.id; |
38 | | - const u = result.usage; |
39 | | - const rec = { |
40 | | - type: "token", |
41 | | - ts: Date.now(), |
42 | | - id: result.id, // chatcmpl-xxx, matches transcript message.id |
43 | | - model: result.model || "", |
44 | | - prompt_tokens: u.prompt_tokens || 0, |
45 | | - cached_tokens: (u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0, |
46 | | - completion_tokens: u.completion_tokens || 0, |
47 | | - reasoning_tokens: (u.completion_tokens_details && u.completion_tokens_details.reasoning_tokens) || 0, |
48 | | - total_tokens: u.total_tokens || 0, |
49 | | - }; |
50 | | - // Token records are ~200 bytes, well under PIPE_BUF — atomic on POSIX. |
51 | | - fs.appendFileSync(INTERCEPT_FILE, origStringify.call(JSON, rec) + "\n"); |
52 | | - } |
| 41 | + fs.mkdirSync(INTERCEPT_DIR, { recursive: true }); |
| 42 | + fs.appendFileSync(ERROR_LOG, `[${new Date().toISOString()}] ${msg}\n`); |
53 | 43 | } catch {} |
54 | | - return result; |
55 | | -}; |
| 44 | +} |
56 | 45 |
|
57 | | -// Global override of JSON.stringify to capture system prompt before request encryption. |
58 | | -// Each process captures at most once (systemPromptCaptured flag). |
59 | | -JSON.stringify = function (value, replacer, space) { |
60 | | - try { |
61 | | - if (!systemPromptCaptured && value && typeof value === "object" |
62 | | - && value.messages && Array.isArray(value.messages)) { |
63 | | - const sys = value.messages.find(m => m.role === "system"); |
64 | | - if (sys && typeof sys.content === "string" && sys.content.length > MIN_SYSTEM_PROMPT_LENGTH) { |
65 | | - systemPromptCaptured = true; |
66 | | - const rec = { type: "system_prompt", ts: Date.now(), content: sys.content }; |
| 46 | +// Install the JSON.parse / JSON.stringify interception hooks. Only ever called |
| 47 | +// right before we import the host app's own runtime, so a worker that fails to |
| 48 | +// self-locate is left completely untouched. |
| 49 | +function installInterceptHooks() { |
| 50 | + try { fs.mkdirSync(INTERCEPT_DIR, { recursive: true }); } catch {} |
| 51 | + |
| 52 | + // Intercept SSE-parsed token usage. |
| 53 | + JSON.parse = function (text, reviver) { |
| 54 | + const result = origParse.call(JSON, text, reviver); |
| 55 | + try { |
| 56 | + if (result && typeof result === "object" |
| 57 | + && result.usage && result.choices !== undefined |
| 58 | + && result.id !== lastId) { |
| 59 | + lastId = result.id; |
| 60 | + const u = result.usage; |
| 61 | + const rec = { |
| 62 | + type: "token", |
| 63 | + ts: Date.now(), |
| 64 | + id: result.id, // chatcmpl-xxx, matches transcript message.id |
| 65 | + model: result.model || "", |
| 66 | + prompt_tokens: u.prompt_tokens || 0, |
| 67 | + cached_tokens: (u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0, |
| 68 | + completion_tokens: u.completion_tokens || 0, |
| 69 | + reasoning_tokens: (u.completion_tokens_details && u.completion_tokens_details.reasoning_tokens) || 0, |
| 70 | + total_tokens: u.total_tokens || 0, |
| 71 | + }; |
| 72 | + // Token records are ~200 bytes, well under PIPE_BUF — atomic on POSIX. |
67 | 73 | fs.appendFileSync(INTERCEPT_FILE, origStringify.call(JSON, rec) + "\n"); |
68 | 74 | } |
| 75 | + } catch {} |
| 76 | + return result; |
| 77 | + }; |
| 78 | + |
| 79 | + // Capture system prompt before request encryption. Each process captures once. |
| 80 | + JSON.stringify = function (value, replacer, space) { |
| 81 | + try { |
| 82 | + if (!systemPromptCaptured && value && typeof value === "object" |
| 83 | + && value.messages && Array.isArray(value.messages)) { |
| 84 | + const sys = value.messages.find(m => m.role === "system"); |
| 85 | + if (sys && typeof sys.content === "string" && sys.content.length > MIN_SYSTEM_PROMPT_LENGTH) { |
| 86 | + systemPromptCaptured = true; |
| 87 | + const rec = { type: "system_prompt", ts: Date.now(), content: sys.content }; |
| 88 | + fs.appendFileSync(INTERCEPT_FILE, origStringify.call(JSON, rec) + "\n"); |
| 89 | + } |
| 90 | + } |
| 91 | + } catch {} |
| 92 | + return origStringify.call(JSON, value, replacer, space); |
| 93 | + }; |
| 94 | +} |
| 95 | + |
| 96 | +// The SDK worker runtime always lives at this fixed path relative to an app's |
| 97 | +// Resources dir. It bundles native deps (sharp / node-pty / keytar), so it must |
| 98 | +// be asar-UNPACKED and is guaranteed to exist on disk for a shipped app. |
| 99 | +const SDK_WORKER_REL = path.join( |
| 100 | + 'app.asar.unpacked', 'node_modules', '@qoder-ai', 'qoder-agent-sdk', 'dist', '_worker', |
| 101 | +); |
| 102 | +const RUNTIME_NAMES = ['qoder-worker-runtime.obf.mjs', 'qoder-worker-runtime.mjs']; |
| 103 | + |
| 104 | +// Resource roots derived purely from the running process, so they resolve to |
| 105 | +// WHICHEVER app is hosting this worker — no app name is ever hardcoded. |
| 106 | +function candidateResourceRoots() { |
| 107 | + const roots = []; |
| 108 | + |
| 109 | + // (1) Enclosing .app bundle from the executable path. In an Electron worker |
| 110 | + // thread process.execPath is the host app's own binary, e.g. |
| 111 | + // /Applications/QwenWorkCN.app/Contents/MacOS/QwenWorkCN |
| 112 | + // Match the FIRST ".app" (non-greedy) so a nested "*Helper.app" cannot shadow |
| 113 | + // the outer bundle. This is a hard macOS bundle-layout guarantee. |
| 114 | + const exec = process.execPath || ''; |
| 115 | + const m = /^(.*?\.app)(?:\/|$)/.exec(exec); |
| 116 | + if (m) roots.push(path.join(m[1], 'Contents', 'Resources')); |
| 117 | + |
| 118 | + // (2) Electron's resourcesPath, when present, is <App>/Contents/Resources. |
| 119 | + if (process.resourcesPath) roots.push(process.resourcesPath); |
| 120 | + |
| 121 | + return roots; |
| 122 | +} |
| 123 | + |
| 124 | +// Locate the host app's OWN worker runtime. Returns an absolute path or null. |
| 125 | +function findHostAppRuntime() { |
| 126 | + let selfPath = ''; |
| 127 | + try { selfPath = fs.realpathSync(fileURLToPath(import.meta.url)); } catch {} |
| 128 | + |
| 129 | + const seen = new Set(); |
| 130 | + for (const root of candidateResourceRoots()) { |
| 131 | + for (const name of RUNTIME_NAMES) { |
| 132 | + const cand = path.join(root, SDK_WORKER_REL, name); |
| 133 | + if (seen.has(cand)) continue; |
| 134 | + seen.add(cand); |
| 135 | + try { |
| 136 | + if (!fs.existsSync(cand)) continue; |
| 137 | + const real = fs.realpathSync(cand); |
| 138 | + if (real === selfPath) continue; // anti-recursion: never import ourselves |
| 139 | + return real; |
| 140 | + } catch {} |
69 | 141 | } |
70 | | - } catch {} |
71 | | - return origStringify.call(JSON, value, replacer, space); |
72 | | -}; |
73 | | - |
74 | | -// Discover and load the real QoderWork runtime. Cover both the system-wide |
75 | | -// install (`/Applications/...`) and the per-user install (`~/Applications/...`), |
76 | | -// each with obfuscated (`.obf.mjs`) and non-obfuscated (`.mjs`) variants. |
77 | | -const SDK_REL_BASE = 'Contents/Resources/app.asar.unpacked/node_modules/@qoder-ai/qoder-agent-sdk/dist/_worker'; |
78 | | -const RUNTIME_CANDIDATES = [ |
79 | | - `/Applications/QoderWork.app/${SDK_REL_BASE}/qoder-worker-runtime.obf.mjs`, |
80 | | - `/Applications/QoderWork.app/${SDK_REL_BASE}/qoder-worker-runtime.mjs`, |
81 | | - path.join(process.env.HOME || '', `Applications/QoderWork.app/${SDK_REL_BASE}/qoder-worker-runtime.obf.mjs`), |
82 | | - path.join(process.env.HOME || '', `Applications/QoderWork.app/${SDK_REL_BASE}/qoder-worker-runtime.mjs`), |
83 | | -]; |
84 | | - |
85 | | -let loaded = false; |
86 | | -let lastErr = null; |
87 | | -for (const candidate of RUNTIME_CANDIDATES) { |
88 | | - try { |
89 | | - if (fs.existsSync(candidate)) { |
90 | | - await import(candidate); |
91 | | - loaded = true; |
92 | | - break; |
93 | | - } |
94 | | - } catch (e) { lastErr = e; } |
| 142 | + } |
| 143 | + return null; |
95 | 144 | } |
96 | 145 |
|
97 | | -if (!loaded) { |
98 | | - // Log a diagnostic but do NOT throw. Throwing at module level crashes the |
99 | | - // worker_thread entirely, which would also prevent the QoderWork SDK from |
100 | | - // falling back to its ProcessTransport. By returning silently we let the SDK |
101 | | - // detect "no runtime registered" and degrade on its own — token interception |
102 | | - // is lost (acceptable) but normal agent operation is not disrupted. |
| 146 | +const hostRuntime = findHostAppRuntime(); |
| 147 | + |
| 148 | +if (hostRuntime) { |
| 149 | + // Certain we will hand control to the host app's OWN runtime. Install |
| 150 | + // interception, then load it. The app behaves exactly as if unhooked, plus we |
| 151 | + // capture token usage. |
| 152 | + installInterceptHooks(); |
103 | 153 | try { |
104 | | - fs.appendFileSync(path.join(INTERCEPT_DIR, 'qoderwork-wrapper-error.log'), |
105 | | - `[${new Date().toISOString()}] real runtime not found in candidates: ${RUNTIME_CANDIDATES.join(', ')}\n` + |
106 | | - (lastErr ? `last error: ${lastErr.message}\n` : '')); |
107 | | - } catch {} |
| 154 | + await import(hostRuntime); |
| 155 | + } catch (e) { |
| 156 | + // The app's own runtime failed to load — the app would have hit this even |
| 157 | + // without us. Do not throw (module-level throw crashes the worker_thread and |
| 158 | + // blocks the SDK's own transport fallback) and do not try any other runtime. |
| 159 | + logDiag(`host runtime import failed: ${hostRuntime} :: ${e && e.message}`); |
| 160 | + } |
| 161 | +} else { |
| 162 | + // Could not locate the host app's own runtime. Per design priority we refuse |
| 163 | + // to load any guessed/foreign runtime (that is what broke QwenWorkCN). Install |
| 164 | + // nothing, load nothing: token interception is lost, the app is never handed a |
| 165 | + // wrong runtime. The SDK detects the empty worker entry and degrades on its own. |
| 166 | + logDiag( |
| 167 | + 'host app runtime not found — skipping intercept to avoid loading a foreign runtime ' |
| 168 | + + `(execPath=${process.execPath || ''}, resourcesPath=${process.resourcesPath || ''})`, |
| 169 | + ); |
108 | 170 | } |
0 commit comments