@harness-mix/cli 0.1.4 → 0.1.5

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.
Files changed (126) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +26 -96
  3. package/docs/chatgpt-web-sidebar.md +7 -1
  4. package/docs/cross-platform.md +15 -0
  5. package/docs/harness-handoff-design.md +377 -0
  6. package/docs/harness-management.md +28 -1
  7. package/docs/native-codex.md +21 -10
  8. package/output/native-build/harness-mix-shim.exe +0 -0
  9. package/output/native-build/renderer-extension.js +972 -35
  10. package/package.json +18 -9
  11. package/scripts/chatgpt-context-ui-smoke.cjs +16 -0
  12. package/scripts/codex-accounts-test.cjs +81 -0
  13. package/scripts/codex-accounts-ui-smoke.cjs +70 -0
  14. package/scripts/e2e-integrations.cjs +52 -0
  15. package/scripts/e2e-native-host.cjs +39 -0
  16. package/scripts/handoff-checkpoint-test.cjs +83 -0
  17. package/scripts/install-native.cjs +4 -1
  18. package/scripts/integrations-test.cjs +64 -0
  19. package/scripts/integrations-ui-smoke.cjs +63 -0
  20. package/scripts/native-protocol-test.cjs +78 -4
  21. package/scripts/native-secret-test.cjs +2 -2
  22. package/scripts/native-shim-test.cjs +20 -4
  23. package/scripts/native-ui-smoke.cjs +58 -2
  24. package/scripts/publish-native-package.cjs +8 -5
  25. package/scripts/support/integrations-mcp-fixture.cjs +15 -0
  26. package/scripts/switch-harness-test.cjs +27 -1
  27. package/src/main/adapters/acp.js +3 -3
  28. package/src/main/adapters/antigravity.js +10 -13
  29. package/src/main/adapters/claude.js +10 -3
  30. package/src/main/adapters/codex-app-server.js +20 -13
  31. package/src/main/adapters/codex.js +6 -4
  32. package/src/main/adapters/dsh.js +2 -1
  33. package/src/main/adapters/grok.js +4 -3
  34. package/src/main/adapters/managed-mcp.js +12 -0
  35. package/src/main/adapters/native-acp.js +4 -4
  36. package/src/main/adapters/opencode.js +9 -3
  37. package/src/main/adapters/pi.js +1 -0
  38. package/src/main/harness-adapter/manifest.js +17 -2
  39. package/src/main/host/handoff-access.js +53 -0
  40. package/src/main/host/handoff-checkpoints.js +206 -0
  41. package/src/main/host/handoff-mcp.cjs +38 -0
  42. package/src/main/host/handoff-tools.js +13 -0
  43. package/src/main/host/handoff.js +28 -6
  44. package/src/main/host/integrations.js +201 -0
  45. package/src/main/host/runtime.js +75 -35
  46. package/src/main/host/store.js +4 -4
  47. package/src/main/native/codex-accounts.js +260 -0
  48. package/src/main/native/host.js +1 -1
  49. package/src/main/native/launcher.js +2 -2
  50. package/src/main/native/protocol.js +74 -23
  51. package/src/main/native/redact.js +1 -1
  52. package/src/main/native/rs/crates/shim/src/main.rs +155 -12
  53. package/src/native-ui/desktop-control/dist/tsconfig.tsbuildinfo +1 -1
  54. package/src/native-ui/renderer-extension/dist/types/harness-mix-settings.d.ts.map +1 -1
  55. package/src/native-ui/renderer-extension/dist/types/index.d.ts +4 -0
  56. package/src/native-ui/renderer-extension/dist/types/index.d.ts.map +1 -1
  57. package/src/native-ui/renderer-extension/dist/types/renderer-agent-picker.d.ts +2 -0
  58. package/src/native-ui/renderer-extension/dist/types/renderer-agent-picker.d.ts.map +1 -1
  59. package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
  60. package/src/native-ui/renderer-extension/dist/types/renderer-chatgpt-context.d.ts +36 -0
  61. package/src/native-ui/renderer-extension/dist/types/renderer-chatgpt-context.d.ts.map +1 -0
  62. package/src/native-ui/renderer-extension/dist/types/renderer-codex-account-state.d.ts +1 -0
  63. package/src/native-ui/renderer-extension/dist/types/renderer-codex-account-state.d.ts.map +1 -1
  64. package/src/native-ui/renderer-extension/dist/types/renderer-composer-dom.d.ts +3 -1
  65. package/src/native-ui/renderer-extension/dist/types/renderer-composer-dom.d.ts.map +1 -1
  66. package/src/native-ui/renderer-extension/dist/types/renderer-harness-handoff.d.ts +53 -0
  67. package/src/native-ui/renderer-extension/dist/types/renderer-harness-handoff.d.ts.map +1 -0
  68. package/src/native-ui/renderer-extension/dist/types/renderer-integrations-client.d.ts +80 -0
  69. package/src/native-ui/renderer-extension/dist/types/renderer-integrations-client.d.ts.map +1 -0
  70. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +7 -1
  71. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
  72. package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts +1 -0
  73. package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts.map +1 -1
  74. package/src/native-ui/renderer-extension/dist/types/settings/accounts-list.d.ts +2 -0
  75. package/src/native-ui/renderer-extension/dist/types/settings/accounts-list.d.ts.map +1 -1
  76. package/src/native-ui/renderer-extension/dist/types/settings/accounts-page.d.ts +1 -0
  77. package/src/native-ui/renderer-extension/dist/types/settings/accounts-page.d.ts.map +1 -1
  78. package/src/native-ui/renderer-extension/dist/types/settings/integrations-page.d.ts +5 -0
  79. package/src/native-ui/renderer-extension/dist/types/settings/integrations-page.d.ts.map +1 -0
  80. package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts +5 -0
  81. package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts.map +1 -1
  82. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts +8 -4
  83. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts.map +1 -1
  84. package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
  85. package/src/native-ui/renderer-extension/src/harness-mix-settings.ts +3 -1
  86. package/src/native-ui/renderer-extension/src/index.ts +28 -0
  87. package/src/native-ui/renderer-extension/src/renderer-agent-icon.ts +4 -4
  88. package/src/native-ui/renderer-extension/src/renderer-agent-picker.ts +46 -6
  89. package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +159 -40
  90. package/src/native-ui/renderer-extension/src/renderer-chatgpt-context.ts +83 -0
  91. package/src/native-ui/renderer-extension/src/renderer-codex-account-state.ts +10 -0
  92. package/src/native-ui/renderer-extension/src/renderer-composer-dom.ts +10 -0
  93. package/src/native-ui/renderer-extension/src/renderer-harness-handoff.ts +330 -0
  94. package/src/native-ui/renderer-extension/src/renderer-integrations-client.ts +26 -0
  95. package/src/native-ui/renderer-extension/src/renderer-model-client.ts +17 -1
  96. package/src/native-ui/renderer-extension/src/renderer-settings-lifecycle.ts +2 -0
  97. package/src/native-ui/renderer-extension/src/settings/accounts-list.ts +16 -1
  98. package/src/native-ui/renderer-extension/src/settings/accounts-page.ts +32 -3
  99. package/src/native-ui/renderer-extension/src/settings/integrations-page.ts +102 -0
  100. package/src/native-ui/renderer-extension/src/settings/localization.ts +30 -13
  101. package/src/native-ui/renderer-extension/src/settings/pages.ts +47 -18
  102. package/src/native-ui/renderer-extension/src/settings/shell.css +13 -0
  103. package/src/native-ui/renderer-extension/test/renderer-agent-picker.test.ts +44 -13
  104. package/src/native-ui/renderer-extension/test/renderer-chatgpt-context.test.ts +18 -0
  105. package/src/native-ui/renderer-extension/test/renderer-codex-account-state.test.ts +13 -1
  106. package/src/native-ui/renderer-extension/test/renderer-harness-handoff.test.ts +20 -0
  107. package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +9 -2
  108. package/src/native-ui/renderer-extension/test/settings/localization.test.ts +1 -1
  109. package/src/native-ui/renderer-extension/test/settings/pages.test.ts +77 -8
  110. package/src/native-ui/renderer-extension/test/settings/shell.test.ts +3 -0
  111. package/src/native-ui/shared-contracts/dist/codex-accounts.d.ts +19 -4
  112. package/src/native-ui/shared-contracts/dist/codex-accounts.d.ts.map +1 -1
  113. package/src/native-ui/shared-contracts/dist/codex-accounts.js +2 -0
  114. package/src/native-ui/shared-contracts/dist/codex-accounts.js.map +1 -1
  115. package/src/native-ui/shared-contracts/dist/index.d.ts +2 -2
  116. package/src/native-ui/shared-contracts/dist/index.d.ts.map +1 -1
  117. package/src/native-ui/shared-contracts/dist/index.js +1 -1
  118. package/src/native-ui/shared-contracts/dist/index.js.map +1 -1
  119. package/src/native-ui/shared-contracts/dist/thread-harness-switch.d.ts +29 -0
  120. package/src/native-ui/shared-contracts/dist/thread-harness-switch.d.ts.map +1 -1
  121. package/src/native-ui/shared-contracts/dist/thread-harness-switch.js +13 -0
  122. package/src/native-ui/shared-contracts/dist/thread-harness-switch.js.map +1 -1
  123. package/src/native-ui/shared-contracts/dist/tsconfig.tsbuildinfo +1 -1
  124. package/src/native-ui/shared-contracts/src/codex-accounts.ts +2 -0
  125. package/src/native-ui/shared-contracts/src/index.ts +8 -1
  126. package/src/native-ui/shared-contracts/src/thread-harness-switch.ts +17 -0
@@ -0,0 +1,260 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const os = require('node:os');
4
+ const { randomUUID } = require('node:crypto');
5
+ const { CodexAppServer } = require('../adapters/codex-app-server');
6
+
7
+ const OFFICIAL_ACCOUNT_ID = 'official-codex';
8
+ const ACCOUNT_ID = /^[A-Za-z0-9._~-]{1,256}$/;
9
+ const PLAN_TYPES = new Set(['free', 'go', 'plus', 'pro', 'prolite', 'team', 'self_serve_business_prolite', 'self_serve_business_usage_based', 'business', 'ent26', 'enterprise_cbp_automation', 'enterprise_cbp_usage_based', 'enterprise', 'edu', 'edu_plus', 'edu_pro', 'unknown']);
10
+
11
+ function isoFromUnixSeconds(value) {
12
+ return Number.isSafeInteger(value) && value >= 0 ? new Date(value * 1000).toISOString() : undefined;
13
+ }
14
+
15
+ function rateLimitView(result) {
16
+ const snapshot = result?.rateLimits;
17
+ const primary = snapshot?.primary;
18
+ const secondary = snapshot?.secondary;
19
+ const usage = {};
20
+ if (typeof primary?.usedPercent === 'number' && primary.usedPercent >= 0 && primary.usedPercent <= 100) {
21
+ usage.planFiveHourUsedPercent = primary.usedPercent;
22
+ if (Number.isSafeInteger(primary.resetsAt) && primary.resetsAt >= 0) usage.planFiveHourResetsAtUnix = primary.resetsAt;
23
+ }
24
+ if (typeof secondary?.usedPercent === 'number' && secondary.usedPercent >= 0 && secondary.usedPercent <= 100) {
25
+ usage.planSevenDayUsedPercent = secondary.usedPercent;
26
+ if (Number.isSafeInteger(secondary.resetsAt) && secondary.resetsAt >= 0) usage.planSevenDayResetsAtUnix = secondary.resetsAt;
27
+ }
28
+ let accountCredits;
29
+ if (typeof primary?.usedPercent === 'number' && primary.usedPercent >= 0 && primary.usedPercent <= 100) {
30
+ accountCredits = {
31
+ usedPercent: primary.usedPercent,
32
+ periodType: primary.windowDurationMins === 300 ? 'five_hour' : 'unknown',
33
+ ...(isoFromUnixSeconds(primary.resetsAt) ? { resetsAt: isoFromUnixSeconds(primary.resetsAt) } : {}),
34
+ };
35
+ if (typeof secondary?.usedPercent === 'number' && secondary.usedPercent >= 0 && secondary.usedPercent <= 100) {
36
+ accountCredits.productUsage = [{
37
+ product: secondary.windowDurationMins === 10080 ? '7-day window' : (snapshot.limitName || 'Secondary limit'),
38
+ usagePercent: secondary.usedPercent,
39
+ ...(isoFromUnixSeconds(secondary.resetsAt) ? { resetsAt: isoFromUnixSeconds(secondary.resetsAt) } : {}),
40
+ }];
41
+ }
42
+ const reset = result?.rateLimitResetCredits;
43
+ if (Number.isSafeInteger(reset?.availableCount) && reset.availableCount > 0) {
44
+ const expiresAt = Array.isArray(reset.credits)
45
+ ? reset.credits.map(credit => isoFromUnixSeconds(credit?.expiresAt)).filter(Boolean).slice(0, 32)
46
+ : [];
47
+ accountCredits.resetCredits = {
48
+ availableCount: reset.availableCount,
49
+ ...(expiresAt.length ? { nextExpiresAt: expiresAt[0], expiresAt } : {}),
50
+ };
51
+ }
52
+ }
53
+ return { usage: Object.keys(usage).length ? usage : null, ...(accountCredits ? { accountCredits } : {}) };
54
+ }
55
+
56
+ function accountIdentity(account) {
57
+ const email = account?.type === 'chatgpt' && typeof account.email === 'string' && account.email.trim()
58
+ ? account.email.trim() : undefined;
59
+ const planType = account?.type === 'chatgpt' && PLAN_TYPES.has(account.planType) ? account.planType : undefined;
60
+ return { email, planType, authenticated: !!account };
61
+ }
62
+
63
+ class CodexAccountManager {
64
+ constructor({ dataDirectory, requestOfficial, emit, acquireServer = CodexAppServer.acquire }) {
65
+ this.root = path.resolve(dataDirectory, 'codex-accounts');
66
+ this.profilesRoot = path.join(this.root, 'profiles');
67
+ this.registryFile = path.join(this.root, 'accounts.json');
68
+ this.requestOfficial = requestOfficial;
69
+ this.emit = emit;
70
+ this.acquireServer = acquireServer;
71
+ this.logins = new Map();
72
+ this.registry = this.#load();
73
+ }
74
+
75
+ #load() {
76
+ try {
77
+ const value = JSON.parse(fs.readFileSync(this.registryFile, 'utf8'));
78
+ const seen = new Set();
79
+ const accounts = (Array.isArray(value.accounts) ? value.accounts : []).filter(entry => {
80
+ if (!entry || !ACCOUNT_ID.test(entry.accountId) || typeof entry.label !== 'string' || !entry.label.trim() || entry.accountId === OFFICIAL_ACCOUNT_ID || seen.has(entry.accountId)) return false;
81
+ seen.add(entry.accountId);
82
+ entry.label = entry.label.trim().slice(0, 256);
83
+ return true;
84
+ }).slice(0, 127);
85
+ const activeAccountId = value.activeAccountId === OFFICIAL_ACCOUNT_ID || accounts.some(entry => entry.accountId === value.activeAccountId)
86
+ ? value.activeAccountId : OFFICIAL_ACCOUNT_ID;
87
+ return { version: 1, activeAccountId, accounts };
88
+ } catch (error) {
89
+ if (error.code !== 'ENOENT') throw error;
90
+ return { version: 1, activeAccountId: OFFICIAL_ACCOUNT_ID, accounts: [] };
91
+ }
92
+ }
93
+
94
+ #save() {
95
+ fs.mkdirSync(this.root, { recursive: true });
96
+ const temp = `${this.registryFile}.${process.pid}.tmp`;
97
+ fs.writeFileSync(temp, JSON.stringify(this.registry, null, 2));
98
+ fs.renameSync(temp, this.registryFile);
99
+ }
100
+
101
+ #entry(accountId) {
102
+ if (accountId === OFFICIAL_ACCOUNT_ID) return { accountId, label: 'Codex 官方账号', codexHome: process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), native: true };
103
+ const entry = this.registry.accounts.find(candidate => candidate.accountId === accountId);
104
+ if (!entry) throw new Error('Unknown Codex account');
105
+ const codexHome = path.resolve(this.profilesRoot, entry.accountId);
106
+ if (!codexHome.startsWith(`${path.resolve(this.profilesRoot)}${path.sep}`)) throw new Error('Invalid Codex account directory');
107
+ return { ...entry, codexHome, native: false };
108
+ }
109
+
110
+ async #request(entry, method, params) {
111
+ if (entry.native) {
112
+ if (!this.requestOfficial) throw new Error('Official Codex account service is unavailable');
113
+ return this.requestOfficial(method, params);
114
+ }
115
+ const server = await this.acquireServer(undefined, entry.codexHome);
116
+ try { return await server.request(method, params); }
117
+ finally { server.release(); }
118
+ }
119
+
120
+ async #summary(entry, refreshToken = false) {
121
+ let raw = null;
122
+ try { raw = await this.#request(entry, 'account/read', { refreshToken }); }
123
+ catch { /* A signed-out profile is still a useful selectable Account slot. */ }
124
+ const identity = accountIdentity(raw?.account);
125
+ return {
126
+ accountId: entry.accountId,
127
+ label: identity.email || entry.label,
128
+ ...(identity.email ? { email: identity.email } : {}),
129
+ ...(identity.planType ? { planType: identity.planType } : {}),
130
+ codexHome: entry.codexHome,
131
+ active: this.registry.activeAccountId === entry.accountId,
132
+ isDefault: entry.native,
133
+ authenticated: identity.authenticated,
134
+ management: entry.native ? 'native' : 'isolated',
135
+ };
136
+ }
137
+
138
+ async list(refresh = false) {
139
+ const entries = [this.#entry(OFFICIAL_ACCOUNT_ID), ...this.registry.accounts.map(entry => this.#entry(entry.accountId))];
140
+ return { accounts: await Promise.all(entries.map(entry => this.#summary(entry, refresh))) };
141
+ }
142
+
143
+ async create(label) {
144
+ if (this.registry.accounts.length >= 127) throw new Error('Codex Account limit reached (128 including the official account)');
145
+ const accountId = `account-${randomUUID().slice(0, 8)}`;
146
+ const entry = { accountId, label: typeof label === 'string' && label.trim() ? label.trim().slice(0, 256) : `Codex 账号 ${this.registry.accounts.length + 2}`, createdAt: Date.now() };
147
+ fs.mkdirSync(path.join(this.profilesRoot, accountId), { recursive: true });
148
+ this.registry.accounts.push(entry);
149
+ this.#save();
150
+ return { account: await this.#summary(this.#entry(accountId)) };
151
+ }
152
+
153
+ async activate(accountId) {
154
+ const entry = this.#entry(accountId);
155
+ this.registry.activeAccountId = entry.accountId;
156
+ this.#save();
157
+ return { account: await this.#summary(entry) };
158
+ }
159
+
160
+ async delete(accountId) {
161
+ const entry = this.#entry(accountId);
162
+ if (entry.native) throw new Error('The official Codex account cannot be deleted');
163
+ if ([...this.logins.values()].some(login => login.accountId === accountId)) throw new Error('Cancel account sign-in before deleting it');
164
+ const resolved = path.resolve(entry.codexHome);
165
+ const root = path.resolve(this.profilesRoot);
166
+ if (!resolved.startsWith(`${root}${path.sep}`)) throw new Error('Refusing to delete an account outside the managed profile directory');
167
+ fs.rmSync(resolved, { recursive: true, force: true });
168
+ this.registry.accounts = this.registry.accounts.filter(candidate => candidate.accountId !== accountId);
169
+ if (this.registry.activeAccountId === accountId) this.registry.activeAccountId = OFFICIAL_ACCOUNT_ID;
170
+ this.#save();
171
+ return { deletedAccountId: accountId };
172
+ }
173
+
174
+ async startLogin(accountId) {
175
+ const entry = this.#entry(accountId);
176
+ if (entry.native) {
177
+ const result = await this.#request(entry, 'account/login/start', { type: 'chatgptDeviceCode' });
178
+ return { accountId, loginId: result.loginId, verificationUrl: result.verificationUrl, userCode: result.userCode };
179
+ }
180
+ const server = await this.acquireServer(undefined, entry.codexHome);
181
+ let unwatch;
182
+ try {
183
+ unwatch = server.onNotification(message => {
184
+ if (message?.method !== 'account/login/completed') return;
185
+ const pending = this.logins.get(message.params?.loginId);
186
+ if (!pending || pending.server !== server) return;
187
+ this.logins.delete(message.params.loginId);
188
+ pending.unwatch?.();
189
+ pending.server.release();
190
+ this.emit?.({ method: 'codexhost/account/login/completed', params: {
191
+ accountId: pending.accountId,
192
+ loginId: message.params.loginId,
193
+ success: message.params.success === true,
194
+ error: typeof message.params.error === 'string' ? message.params.error : null,
195
+ } });
196
+ });
197
+ const result = await server.request('account/login/start', { type: 'chatgptDeviceCode' });
198
+ this.logins.set(result.loginId, { accountId, server, unwatch });
199
+ return { accountId, loginId: result.loginId, verificationUrl: result.verificationUrl, userCode: result.userCode };
200
+ } catch (error) {
201
+ unwatch?.();
202
+ server.release();
203
+ throw error;
204
+ }
205
+ }
206
+
207
+ async cancelLogin(loginId) {
208
+ const pending = this.logins.get(loginId);
209
+ if (!pending) {
210
+ if (!this.requestOfficial) return { cancelled: false };
211
+ const result = await this.requestOfficial('account/login/cancel', { loginId });
212
+ return { cancelled: ['canceled', 'cancelled'].includes(result?.status) };
213
+ }
214
+ const result = await pending.server.request('account/login/cancel', { loginId });
215
+ this.logins.delete(loginId);
216
+ pending.unwatch?.();
217
+ pending.server.release();
218
+ return { cancelled: ['canceled', 'cancelled'].includes(result?.status) };
219
+ }
220
+
221
+ async usage(accountId) {
222
+ const entry = this.#entry(accountId);
223
+ return { accountId, ...rateLimitView(await this.#request(entry, 'account/rateLimits/read', {})) };
224
+ }
225
+
226
+ async consumeReset(accountId, idempotencyKey) {
227
+ const entry = this.#entry(accountId);
228
+ const result = await this.#request(entry, 'account/rateLimitResetCredit/consume', { idempotencyKey: idempotencyKey || randomUUID() });
229
+ const refreshed = rateLimitView(await this.#request(entry, 'account/rateLimits/read', {}));
230
+ return { accountId, outcome: result.outcome, ...(refreshed.accountCredits ? { accountCredits: refreshed.accountCredits } : {}) };
231
+ }
232
+
233
+ signedOutOfficial() {
234
+ const entry = this.#entry(OFFICIAL_ACCOUNT_ID);
235
+ return {
236
+ account: {
237
+ accountId: entry.accountId,
238
+ label: entry.label,
239
+ codexHome: entry.codexHome,
240
+ active: this.registry.activeAccountId === entry.accountId,
241
+ isDefault: true,
242
+ authenticated: false,
243
+ management: 'native',
244
+ },
245
+ };
246
+ }
247
+
248
+ executionContext(accountId) {
249
+ const entry = this.#entry(accountId);
250
+ if (entry.native) throw new Error('The official account stays on the native Codex route');
251
+ return { accountId: entry.accountId, codexHome: entry.codexHome };
252
+ }
253
+
254
+ close() {
255
+ for (const pending of this.logins.values()) { pending.unwatch?.(); pending.server.release(); }
256
+ this.logins.clear();
257
+ }
258
+ }
259
+
260
+ module.exports = { CodexAccountManager, OFFICIAL_ACCOUNT_ID, rateLimitView };
@@ -148,6 +148,6 @@ async function runNativeHost() {
148
148
  official.on('error', error => { console.error(error); void close(); });
149
149
  official.on('exit', () => { void close(); });
150
150
  await ready;
151
- console.error('[Harness Mix] native protocol -> local HostRuntime -> local ProtocolCore');
151
+ console.error('[Harness Mix] official Codex passthrough + external Harness routes via HostRuntime/ProtocolCore');
152
152
  }
153
153
  module.exports = { runNativeHost };
@@ -120,7 +120,7 @@ async function launch(args = []) {
120
120
  const paths = nativePaths();
121
121
  for (const file of Object.values(paths)) if (!fs.existsSync(file)) throw new Error(`Missing ${file}; run npm run build:native`);
122
122
  if (flags.has('--check')) {
123
- console.log(`platform=${process.platform}\narchitecture=${process.arch}\ndesktop_version=${installation.version}\ndesktop_compatibility=${compatibility.state}\ndesktop_evidence=${compatibility.evidence?.level || 'none'}\nexecutable_codex_cli=${installation.stock}\nlauncher=${paths.cli}\nshim=${paths.shim}\nruntime=${paths.runtime}\nrenderer=${paths.renderer}\ncore=src/main/protocol-core`);
123
+ console.log(`platform=${process.platform}\narchitecture=${process.arch}\ndesktop_version=${installation.version}\ndesktop_compatibility=${compatibility.state}\ndesktop_evidence=${compatibility.evidence?.level || 'none'}\nexecutable_codex_cli=${installation.stock}\ncodex_mode=official-passthrough\ncodex_managed_route=codex-harness\nlauncher=${paths.cli}\nshim=${paths.shim}\nruntime=${paths.runtime}\nrenderer=${paths.renderer}\ncore=src/main/protocol-core`);
124
124
  return;
125
125
  }
126
126
  const skipUpdate = flags.has('--no-update') || process.env.HARNESS_MIX_AUTO_UPDATE === '0';
@@ -151,7 +151,7 @@ async function launch(args = []) {
151
151
  CODEXHOST_DATA_DIR: env.CODEXHOST_DATA_DIR, HARNESS_MIX_NODE_PATH: process.execPath,
152
152
  CODEXHOST_DEFAULT_AGENT: 'codex' };
153
153
  const block = Buffer.from(Object.entries(overrides).map(([k, v]) => `${k}=${v}`).join('\0') + '\0\0', 'utf16le').toString('base64');
154
- console.log('[Harness Mix] Restarting Codex Desktop with the local Shim and ProtocolCore.');
154
+ console.log('[Harness Mix] Restarting Codex Desktop: official Codex passthrough + Harness Mix routes.');
155
155
  stopDesktopProcesses(installation);
156
156
  await new Promise(resolve => setTimeout(resolve, 1000));
157
157
  sweepLeftovers(root, dataDir);
@@ -20,7 +20,8 @@ const {
20
20
  compareVersions,
21
21
  readState,
22
22
  } = require('./update-state');
23
- const { nativeEnvironment } = require('./config');
23
+ const { nativeEnvironment } = require('./config');
24
+ const { CodexAccountManager } = require('./codex-accounts');
24
25
 
25
26
  const REPO_ROOT = path.resolve(__dirname, '../../..');
26
27
  const PACKAGE_JSON_PATH = path.join(REPO_ROOT, 'package.json');
@@ -31,9 +32,8 @@ const modelRef = model => ({ id: Buffer.from(JSON.stringify({ id: model.id, prov
31
32
  const routeModel = harnessId => ['pi', 'claude-code', 'deepseek-harness', 'antigravity', 'omp', 'opencode', 'grok'].includes(harnessId)
32
33
  ? `codexhost/${harnessId}-native`
33
34
  : `codexhost/plugin-v1@${Buffer.from(JSON.stringify({ harnessId })).toString('hex')}`;
34
- const terminal = status => ['completed', 'cancelled', 'error', 'failed'].includes(status);
35
- const turnStatus = status => ({ cancelled: 'interrupted', error: 'failed', failed: 'failed', completed: 'completed' }[status] || 'inProgress');
36
-
35
+ const terminal = status => ['completed', 'cancelled', 'error', 'failed'].includes(status);
36
+ const turnStatus = status => ({ cancelled: 'interrupted', error: 'failed', failed: 'failed', completed: 'completed' }[status] || 'inProgress');
37
37
  const HARNESS_INSTALL_COMMANDS = {
38
38
  qoder: { win32: 'npm install -g @qoder-ai/qodercli', default: 'npm install -g @qoder-ai/qodercli' },
39
39
  codex: { win32: 'npm install -g @openai/codex', default: 'npm install -g @openai/codex' },
@@ -189,10 +189,15 @@ function projectItem(item) {
189
189
  }
190
190
 
191
191
  class NativeProtocol {
192
- constructor(runtime, emit, requestOfficial = null) {
192
+ constructor(runtime, emit, requestOfficial = null) {
193
193
  this.runtime = runtime;
194
194
  this.emit = emit;
195
- this.requestOfficial = requestOfficial;
195
+ this.requestOfficial = requestOfficial;
196
+ this.codexAccounts = new CodexAccountManager({
197
+ dataDirectory: nativeEnvironment().CODEXHOST_DATA_DIR,
198
+ requestOfficial,
199
+ emit,
200
+ });
196
201
  this.approvals = new Map();
197
202
  this.published = new Map();
198
203
  this.steering = new Map(); // threadId -> in-flight steer promise
@@ -317,13 +322,34 @@ class NativeProtocol {
317
322
  if (!model) throw new Error('Selected model is no longer in the native catalog');
318
323
  return model;
319
324
  }
320
- async request(method, params = {}) {
325
+ async request(method, params = {}) {
326
+ if (method === 'codexhost/integrations/catalog') return this.runtime.integrations.catalog();
327
+ if (method === 'codexhost/integrations/list') return this.runtime.integrations.list(params);
328
+ if (method === 'codexhost/integrations/mcp/save') return this.runtime.integrations.save(params);
329
+ if (method === 'codexhost/integrations/mcp/remove') return this.runtime.integrations.remove(params);
330
+ if (method === 'codexhost/integrations/skill/change') return this.runtime.integrations.skillChange(params);
321
331
  if (method === 'codexhost/harness/session-import/sources') return this.runtime.history.sources();
322
332
  if (method === 'codexhost/harness/session-import/list') return this.runtime.history.list(params);
323
333
  if (method === 'codexhost/harness/session-import/import') return this.runtime.history.import(params);
324
334
  if (method === 'codexhost/collaboration/agents') return [...this.runtime.adapters.values()].map(a => ({ id: externalId(a.manifest.id), name: a.manifest.name, available: !!this.runtime.status[a.manifest.id]?.available, lead: a.manifest.capabilities?.collaborationTools === true }));
325
335
  const thread = this.thread(params.threadId);
326
- if (method === 'harness-mix/runtime/inspect') return { owner: 'harness-mix', runtime: 'src/main/host/runtime.js', core: 'src/main/protocol-core/protocol-core.js', threads: this.runtime.threads.length };
336
+ if (method === 'harness-mix/runtime/inspect') return {
337
+ owner: 'harness-mix',
338
+ runtime: 'src/main/host/runtime.js',
339
+ core: 'src/main/protocol-core/protocol-core.js',
340
+ threads: this.runtime.threads.length,
341
+ codex: {
342
+ mode: 'official-passthrough',
343
+ // `codex` remains owned by the stock app-server. Only the explicit
344
+ // `codex-harness` route creates a HostRuntime-managed Codex worker.
345
+ managedRoute: 'codex-harness',
346
+ },
347
+ };
348
+ if (method === 'harness-mix/runtime/version') {
349
+ let version = '0.0.0';
350
+ try { version = JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, 'utf8')).version || version; } catch {}
351
+ return { version };
352
+ }
327
353
  // Updates
328
354
  if (method === 'codexhost/update/check') {
329
355
  const dataDir = nativeEnvironment().CODEXHOST_DATA_DIR;
@@ -429,8 +455,22 @@ class NativeProtocol {
429
455
  return { status: null };
430
456
  }
431
457
 
432
- // No additional managed accounts: native Codex keeps its own signed-in account.
433
- if (method === 'codexhost/account/list' || method === 'codexhost/account/refresh') return { accounts: [] };
458
+ // Each extra Account is an isolated native CODEX_HOME. Harness Mix stores
459
+ // only the profile label/path; auth.json and token refresh remain owned by Codex.
460
+ if (method === 'codexhost/account/list') return this.codexAccounts.list(false);
461
+ if (method === 'codexhost/account/refresh') return this.codexAccounts.list(true);
462
+ if (method === 'codexhost/account/create') return this.codexAccounts.create(params.label);
463
+ if (method === 'codexhost/account/delete') return this.codexAccounts.delete(params.accountId);
464
+ if (method === 'codexhost/account/activate') return this.codexAccounts.activate(params.accountId);
465
+ if (method === 'codexhost/account/usage/inspect') return this.codexAccounts.usage(params.accountId);
466
+ if (method === 'codexhost/account/login/start') return this.codexAccounts.startLogin(params.accountId);
467
+ if (method === 'codexhost/account/login/cancel') return this.codexAccounts.cancelLogin(params.loginId);
468
+ if (method === 'codexhost/account/logout') {
469
+ if (!this.requestOfficial) throw new Error('Official Codex logout is unavailable');
470
+ await this.requestOfficial('account/logout', undefined);
471
+ return this.codexAccounts.signedOutOfficial();
472
+ }
473
+ if (method === 'codexhost/account/rate-limit-reset/consume') return this.codexAccounts.consumeReset(params.accountId, params.idempotencyKey);
434
474
  if (method === 'codexhost/harness/account/login') {
435
475
  const extId = params.harnessId;
436
476
  const meta = HARNESS_AUTH_INFO[extId] || HARNESS_AUTH_INFO[ALIASES[extId]];
@@ -569,17 +609,24 @@ class NativeProtocol {
569
609
  ...(usage ? { usage: projectUsage(usage) } : {}),
570
610
  ...(credits ? { accountCredits: credits } : {}) };
571
611
  }
572
- if (method === 'thread/start') {
573
- const route = decodeRoute(params.model);
574
- if (!route) return undefined;
575
- const id = ALIASES[route.harnessId] || route.harnessId;
576
- if (!this.runtime.adapters.has(id)) throw new Error(`Unsupported Harness: ${id}`);
612
+ if (method === 'thread/start') {
613
+ const route = decodeRoute(params.model);
614
+ const accountContext = typeof params.__codexhostAccountId === 'string'
615
+ ? this.codexAccounts.executionContext(params.__codexhostAccountId) : null;
616
+ if (!route && !accountContext) return undefined;
617
+ const effectiveRoute = route || { harnessId: 'codex-harness', ...(typeof params.model === 'string' ? { model: { id: params.model } } : {}) };
618
+ const id = ALIASES[effectiveRoute.harnessId] || effectiveRoute.harnessId;
619
+ if (!this.runtime.adapters.has(id)) throw new Error(`Unsupported Harness: ${id}`);
577
620
  if (params.ephemeral && params.threadSource) throw new Error('Ephemeral background thread is not supported');
578
- const isWorktree = params.worktree === true || params.options?.worktree === true;
579
- const created = await this.runtime.createThread({ harnessId: id, cwd: params.cwd, ephemeral: params.ephemeral === true,
621
+ const isWorktree = params.worktree === true || params.options?.worktree === true;
622
+ const selectedModel = accountContext && typeof params.model === 'string'
623
+ ? (await this.runtime.describe(id)).models.find(model => model.id === params.model)
624
+ : await this.resolveModel(id, effectiveRoute.model);
625
+ const created = await this.runtime.createThread({ harnessId: id, cwd: params.cwd, ephemeral: params.ephemeral === true,
580
626
  worktree: isWorktree,
581
627
  options: {
582
- model: await this.resolveModel(id, route.model), thinking: route.thinkingOptionId, permissionMode: route.permissionModeId,
628
+ model: selectedModel, thinking: effectiveRoute.thinkingOptionId, permissionMode: effectiveRoute.permissionModeId,
629
+ ...(accountContext || {}),
583
630
  ...(isWorktree ? { worktree: true } : {}),
584
631
  } });
585
632
  if (created.error) throw new Error(created.error);
@@ -696,10 +743,14 @@ class NativeProtocol {
696
743
  return this.configuration(thread);
697
744
  }
698
745
  // 原地切换 Harness:会话历史保留,下条消息携带一次性上下文信封(/switch 指令的 RPC 等价物)
699
- if (method === 'codexhost/thread/harness/switch') {
700
- await this.runtime.switchHarness(thread.id, ALIASES[params.harnessId] || params.harnessId, { note: typeof params.note === 'string' ? params.note : undefined });
701
- return { threadId: thread.id };
702
- }
746
+ if (method === 'codexhost/thread/harness/switch') {
747
+ await this.runtime.switchHarness(thread.id, ALIASES[params.harnessId] || params.harnessId, {
748
+ note: typeof params.note === 'string' ? params.note : undefined,
749
+ intent: params.intent,
750
+ includes: params.includes,
751
+ });
752
+ return { threadId: thread.id, checkpointId: thread.pendingHandoff.checkpointId };
753
+ }
703
754
  if (method === 'thread/fork' || method === 'codexhost/thread/fork') {
704
755
  if (params.ephemeral || params.threadSource || params.excludeTurns) {
705
756
  throw new Error('Ephemeral fork is not supported');
@@ -831,6 +882,6 @@ class NativeProtocol {
831
882
  this.approvals.delete(message.id);
832
883
  return true;
833
884
  }
834
- close() { this.unsubscribe(); this.unsubscribeRuntime(); }
885
+ close() { this.codexAccounts.close(); this.unsubscribe(); this.unsubscribeRuntime(); }
835
886
  }
836
887
  module.exports = { NativeProtocol, decodeRoute, projectItem, externalId, routeModel };
@@ -8,7 +8,7 @@ const PATTERNS = [
8
8
  /xox[abpsr]-[A-Za-z0-9-]{10,}/g,
9
9
  /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, // JWT
10
10
  ];
11
- const SECRET_KEY = /(token|secret|password|passwd|api[_-]?key|authorization|credential|cookie)/i;
11
+ const SECRET_KEY = /(token|secret|password|passwd|api[_-]?key|authorization|credential|cookie|userCode|loginId|verificationUrl)/i;
12
12
  const REDACTED = '[redacted]';
13
13
 
14
14
  function redactText(value) {
@@ -1,8 +1,8 @@
1
1
  // Harness Mix executable bridge, built from this source by build-native.cjs.
2
2
  // Rust port of the original shim.cs: identical routing and stdio contract.
3
3
  //
4
- // - `... app-server ...` -> node scripts/native-host.cjs (Harness Mix kernel)
5
- // - anything else -> stock codex.exe passthrough
4
+ // - a plain `app-server` invocation -> node scripts/native-host.cjs
5
+ // - management subcommands and every other invocation -> stock Codex passthrough
6
6
  use std::env;
7
7
  use std::fs;
8
8
  use std::io::{self, Write};
@@ -103,12 +103,97 @@ fn rotate_log(dir: &Path) {
103
103
  let _ = fs::rename(&base, dir.join("shim-invocations.log.1"));
104
104
  }
105
105
 
106
- fn setting(env_name: &str, file: &Path) -> io::Result<String> {
107
- match env::var(env_name) {
108
- Ok(value) if !value.is_empty() => Ok(value),
109
- _ => read_trimmed(file),
110
- }
111
- }
106
+ fn setting(env_name: &str, file: &Path) -> io::Result<String> {
107
+ match env::var(env_name) {
108
+ Ok(value) if !value.is_empty() => Ok(value),
109
+ _ => read_trimmed(file),
110
+ }
111
+ }
112
+
113
+ // Locate the real Codex subcommand without mistaking a prompt or config value
114
+ // for one. Unknown global options deliberately fall back to the stock CLI, so
115
+ // native Codex remains usable when a newer CLI adds syntax unknown to the Shim.
116
+ fn app_server_subcommand_index(args: &[String]) -> Option<usize> {
117
+ const VALUE_OPTIONS: &[&str] = &[
118
+ "-c",
119
+ "--config",
120
+ "--enable",
121
+ "--disable",
122
+ "--profile",
123
+ ];
124
+ const FLAG_OPTIONS: &[&str] = &["--search", "--no-alt-screen", "--oss"];
125
+ let mut index = 0;
126
+ while let Some(arg) = args.get(index).map(String::as_str) {
127
+ if arg == "app-server" {
128
+ return Some(index);
129
+ }
130
+ if arg == "--" {
131
+ return None;
132
+ }
133
+ if VALUE_OPTIONS.contains(&arg) {
134
+ args.get(index + 1)?;
135
+ index += 2;
136
+ continue;
137
+ }
138
+ if VALUE_OPTIONS
139
+ .iter()
140
+ .any(|option| arg.strip_prefix(option).is_some_and(|rest| rest.starts_with('=')))
141
+ || FLAG_OPTIONS.contains(&arg)
142
+ {
143
+ index += 1;
144
+ continue;
145
+ }
146
+ return None;
147
+ }
148
+ None
149
+ }
150
+
151
+ // Only the app-server form used as the Desktop protocol endpoint belongs to
152
+ // Harness Mix. `proxy`, `daemon`, and future management forms stay entirely
153
+ // with the official CLI. Unknown app-server options also pass through.
154
+ fn should_start_host_runtime(args: &[String]) -> bool {
155
+ const VALUE_OPTIONS: &[&str] = &[
156
+ "-c",
157
+ "--config",
158
+ "--enable",
159
+ "--disable",
160
+ "--listen",
161
+ "--ws-auth",
162
+ "--ws-token-file",
163
+ "--ws-token-sha256",
164
+ "--ws-shared-secret-file",
165
+ "--ws-issuer",
166
+ "--ws-audience",
167
+ "--ws-max-clock-skew-seconds",
168
+ ];
169
+ const FLAG_OPTIONS: &[&str] = &[
170
+ "--strict-config",
171
+ "--stdio",
172
+ "--analytics-default-enabled",
173
+ ];
174
+ let Some(mut index) = app_server_subcommand_index(args).map(|index| index + 1) else {
175
+ return false;
176
+ };
177
+ while let Some(arg) = args.get(index).map(String::as_str) {
178
+ if VALUE_OPTIONS.contains(&arg) {
179
+ if args.get(index + 1).is_none() {
180
+ return false;
181
+ }
182
+ index += 2;
183
+ continue;
184
+ }
185
+ if VALUE_OPTIONS
186
+ .iter()
187
+ .any(|option| arg.strip_prefix(option).is_some_and(|rest| rest.starts_with('=')))
188
+ || FLAG_OPTIONS.contains(&arg)
189
+ {
190
+ index += 1;
191
+ continue;
192
+ }
193
+ return false;
194
+ }
195
+ true
196
+ }
112
197
 
113
198
  #[cfg(not(unix))]
114
199
  fn pump<R: Read + Send + 'static, W: Write + Send + 'static>(
@@ -129,7 +214,7 @@ fn run() -> io::Result<i32> {
129
214
  // Path.GetFullPath equivalent: normalize without resolving symlinks or adding \\?\.
130
215
  let root = std::path::absolute(exe_dir.join("../.."))?;
131
216
  let args: Vec<String> = env::args().skip(1).collect();
132
- let server = args.iter().any(|arg| arg == "app-server");
217
+ let server = should_start_host_runtime(&args);
133
218
 
134
219
  rotate_log(exe_dir);
135
220
  // Kill-on-close job: the node host, every harness CLI it spawns and any
@@ -246,12 +331,70 @@ fn supervise(mut command: Command, exe_dir: &Path) -> io::Result<i32> {
246
331
  Ok(code)
247
332
  }
248
333
 
249
- fn main() {
334
+ fn main() {
250
335
  match run() {
251
336
  Ok(code) => std::process::exit(code),
252
337
  Err(error) => {
253
338
  eprintln!("Harness Mix Shim: {error}");
254
339
  std::process::exit(1);
255
340
  }
256
- }
257
- }
341
+ }
342
+ }
343
+
344
+ #[cfg(test)]
345
+ mod tests {
346
+ use super::{app_server_subcommand_index, should_start_host_runtime};
347
+
348
+ fn args(values: &[&str]) -> Vec<String> {
349
+ values.iter().map(|value| (*value).to_string()).collect()
350
+ }
351
+
352
+ #[test]
353
+ fn finds_only_the_real_app_server_subcommand() {
354
+ assert_eq!(app_server_subcommand_index(&args(&["app-server"])), Some(0));
355
+ assert_eq!(
356
+ app_server_subcommand_index(&args(&[
357
+ "-c",
358
+ "features.code_mode_host=true",
359
+ "app-server",
360
+ "--stdio",
361
+ ])),
362
+ Some(2)
363
+ );
364
+ assert_eq!(
365
+ app_server_subcommand_index(&args(&["exec", "say app-server"])),
366
+ None
367
+ );
368
+ assert_eq!(
369
+ app_server_subcommand_index(&args(&["-c", "app-server", "exec"])),
370
+ None
371
+ );
372
+ assert_eq!(app_server_subcommand_index(&args(&["--", "app-server"])), None);
373
+ }
374
+
375
+ #[test]
376
+ fn routes_only_plain_desktop_app_server_to_the_host() {
377
+ assert!(should_start_host_runtime(&args(&["app-server", "--stdio"])));
378
+ assert!(should_start_host_runtime(&args(&[
379
+ "-c",
380
+ "features.code_mode_host=true",
381
+ "app-server",
382
+ "--listen",
383
+ "stdio://",
384
+ "--analytics-default-enabled",
385
+ ])));
386
+ assert!(should_start_host_runtime(&args(&[
387
+ "app-server",
388
+ "--listen=ws://127.0.0.1:0",
389
+ "--ws-auth",
390
+ "token",
391
+ ])));
392
+ assert!(!should_start_host_runtime(&args(&["app-server", "proxy"])));
393
+ assert!(!should_start_host_runtime(&args(&["app-server", "daemon"])));
394
+ assert!(!should_start_host_runtime(&args(&[
395
+ "app-server",
396
+ "--future-option",
397
+ ])));
398
+ assert!(!should_start_host_runtime(&args(&["exec", "app-server"])));
399
+ }
400
+ }