Skip to content

Commit 25bf356

Browse files
fangxiu-wfclaude
andcommitted
fix(qoderwork): load host app's own worker runtime, never a foreign one
QODER_WORKER_RUNTIME_PATH is set via `launchctl setenv`, which is global to the launchd user domain, and the whole @qoder-ai/qoder-agent-sdk family (QoderWork, QwenWorkCN, QoderWork CN, ...) honours the same variable. The previous wrapper hardcoded QoderWork.app in RUNTIME_CANDIDATES, so a sibling app's worker (e.g. QwenWorkCN) would load QoderWork's runtime instead of its own — a cross-version, cross-site mismatch that breaks the sibling app. Make the wrapper a transparent, app-agnostic shim: locate the host app's OWN runtime purely from the running process (first `.app` in process.execPath, then process.resourcesPath), then intercept + import it. Remove the hardcoded candidates entirely. If the host app's own runtime cannot be located with certainty, install nothing and load nothing — losing token interception is acceptable, loading a foreign runtime is not. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 7e9123f commit 25bf356

1 file changed

Lines changed: 143 additions & 81 deletions

File tree

Lines changed: 143 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,108 +1,170 @@
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.
32
//
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.
811
//
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.
1322

1423
import { createRequire } from 'module';
24+
import { fileURLToPath } from 'url';
1525
const require = createRequire(import.meta.url);
1626
const fs = require('node:fs');
1727
const path = require('node:path');
1828

1929
const INTERCEPT_DIR = path.join(process.env.HOME || '/tmp', '.loongsuite-pilot', 'logs');
2030
const INTERCEPT_FILE = path.join(INTERCEPT_DIR, 'qoderwork-intercept.jsonl');
31+
const ERROR_LOG = path.join(INTERCEPT_DIR, 'qoderwork-wrapper-error.log');
2132
const MIN_SYSTEM_PROMPT_LENGTH = 100;
2233

23-
try { fs.mkdirSync(INTERCEPT_DIR, { recursive: true }); } catch {}
24-
2534
const origParse = JSON.parse;
2635
const origStringify = JSON.stringify;
2736
let lastId = null;
2837
let systemPromptCaptured = false;
2938

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) {
3340
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`);
5343
} catch {}
54-
return result;
55-
};
44+
}
5645

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.
6773
fs.appendFileSync(INTERCEPT_FILE, origStringify.call(JSON, rec) + "\n");
6874
}
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 {}
69141
}
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;
95144
}
96145

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();
103153
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+
);
108170
}

0 commit comments

Comments
 (0)