@bitkyc08/opencodex 2.26.0 → 2.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/{index-RL6b1bTV.js → index-D2sP-biU.js} +14 -14
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +60 -1
- package/src/adapters/base.ts +16 -2
- package/src/adapters/command-code.ts +4 -3
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/live-transport.ts +14 -1
- package/src/adapters/google-antigravity-replay.ts +16 -8
- package/src/adapters/google.ts +22 -5
- package/src/adapters/openai-chat.ts +189 -60
- package/src/adapters/openai-responses.ts +37 -0
- package/src/adapters/tool-catalog-nudge.ts +1 -1
- package/src/bridge.ts +11 -5
- package/src/cli/doctor.ts +76 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +19 -6
- package/src/cli/models.ts +13 -6
- package/src/codex/account-usability.ts +3 -0
- package/src/codex/app-server-processes.ts +269 -37
- package/src/codex/auth-api.ts +22 -5
- package/src/codex/auth-context.ts +108 -3
- package/src/codex/catalog/aggregation.ts +3 -0
- package/src/codex/catalog/metadata.ts +17 -3
- package/src/codex/catalog/native-models.ts +22 -14
- package/src/codex/catalog/parsing.ts +20 -3
- package/src/codex/catalog/provider-fetch.ts +8 -0
- package/src/codex/catalog/sync.ts +63 -15
- package/src/codex/convergence.ts +61 -13
- package/src/codex/log-guard/path-safety.ts +52 -3
- package/src/codex/model-entitlements.ts +353 -0
- package/src/codex/native-profile-startup.ts +100 -2
- package/src/codex/quota.ts +28 -3
- package/src/codex/routing.ts +14 -8
- package/src/codex/user-identity.ts +21 -1
- package/src/config/provider-name.ts +24 -0
- package/src/config.ts +11 -24
- package/src/generated/compatibility-version.json +110 -70
- package/src/images/loop.ts +11 -4
- package/src/lib/destination-policy.ts +47 -0
- package/src/lib/shadow-call.ts +15 -0
- package/src/lib/state-store-registrations.ts +8 -2
- package/src/oauth/index.ts +33 -5
- package/src/oauth/store.ts +11 -5
- package/src/providers/antigravity-models.ts +70 -5
- package/src/providers/derive.ts +12 -2
- package/src/providers/fastwire.ts +39 -8
- package/src/providers/quota.ts +9 -2
- package/src/providers/registry.ts +120 -6
- package/src/providers/service-tier.ts +50 -15
- package/src/responses/parser.ts +59 -11
- package/src/responses/state.ts +162 -5
- package/src/responses/tool-search-compat.ts +301 -0
- package/src/router.ts +17 -3
- package/src/routing/capability.ts +26 -9
- package/src/routing/compatibility/behavior.ts +44 -6
- package/src/routing/profile.ts +1 -1
- package/src/server/chat-native.ts +11 -2
- package/src/server/index.ts +59 -8
- package/src/server/management/agent-settings-routes.ts +16 -2
- package/src/server/management/shared.ts +3 -1
- package/src/server/request-log.ts +31 -0
- package/src/server/responses/collaboration.ts +34 -9
- package/src/server/responses/compact.ts +54 -7
- package/src/server/responses/core.ts +259 -43
- package/src/server/responses/input-admission.ts +7 -2
- package/src/server/responses/responses-field-backfill.ts +88 -6
- package/src/server/responses/terminal-guard.ts +10 -0
- package/src/server/responses-tool-search-repair.ts +217 -0
- package/src/server/system-env.ts +74 -5
- package/src/service-manager-probe.ts +99 -0
- package/src/service.ts +86 -6
- package/src/tray/windows.ts +25 -5
- package/src/types/accounts.ts +37 -0
- package/src/types/config.ts +818 -0
- package/src/types/provider.ts +521 -0
- package/src/types/request.ts +358 -0
- package/src/types/tools.ts +131 -0
- package/src/types/wire.ts +80 -0
- package/src/types.ts +103 -1883
- package/src/usage/cost.ts +37 -1
- package/src/usage/log.ts +4 -0
- package/src/web-search/loop.ts +11 -4
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isTranslatorBudgetExceededError,
|
|
3
|
+
type TranslatorBudget,
|
|
4
|
+
} from "../lib/translator-budget";
|
|
5
|
+
import {
|
|
6
|
+
restoreRoutedToolSearchCalls,
|
|
7
|
+
} from "../responses/tool-search-compat";
|
|
8
|
+
import {
|
|
9
|
+
replaceSseDataPayload,
|
|
10
|
+
sseDataPayload,
|
|
11
|
+
type SseBlockRewrite,
|
|
12
|
+
} from "./sse-payload-rewrite";
|
|
13
|
+
|
|
14
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
15
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type PendingArgumentBlock = {
|
|
19
|
+
block: string;
|
|
20
|
+
itemId?: string;
|
|
21
|
+
outputIndex?: number;
|
|
22
|
+
retainedBytes: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const MAX_PENDING_ARGUMENT_FRAMES = 256;
|
|
26
|
+
const MAX_PENDING_ARGUMENT_BYTES = 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Public Responses gateways stream a lowered search as a normal function lifecycle. Codex expects
|
|
30
|
+
* only `tool_search_call` items, so classify each item before dropping its function-argument
|
|
31
|
+
* frames. Unknown early argument frames stay bounded until their item arrives.
|
|
32
|
+
*/
|
|
33
|
+
export function createRoutedToolSearchRestoreBlockRewrite(
|
|
34
|
+
names: ReadonlySet<string>,
|
|
35
|
+
budget?: TranslatorBudget,
|
|
36
|
+
): SseBlockRewrite {
|
|
37
|
+
const routedItemIds = new Set<string>();
|
|
38
|
+
const ordinaryItemIds = new Set<string>();
|
|
39
|
+
let pendingArguments: PendingArgumentBlock[] = [];
|
|
40
|
+
let pendingArgumentBytes = 0;
|
|
41
|
+
let passthrough = false;
|
|
42
|
+
let disposed = false;
|
|
43
|
+
|
|
44
|
+
const releaseAll = (): void => {
|
|
45
|
+
if (disposed) return;
|
|
46
|
+
disposed = true;
|
|
47
|
+
if (pendingArgumentBytes > 0) {
|
|
48
|
+
budget?.releaseRetained(pendingArgumentBytes, { kind: "retained_collectors" });
|
|
49
|
+
}
|
|
50
|
+
pendingArguments = [];
|
|
51
|
+
pendingArgumentBytes = 0;
|
|
52
|
+
routedItemIds.clear();
|
|
53
|
+
ordinaryItemIds.clear();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const retainPending = (
|
|
57
|
+
block: string,
|
|
58
|
+
itemId: string | undefined,
|
|
59
|
+
outputIndex: number | undefined,
|
|
60
|
+
): readonly string[] | null => {
|
|
61
|
+
const retainedBytes = Buffer.byteLength(block, "utf8");
|
|
62
|
+
const overflow = pendingArguments.length >= MAX_PENDING_ARGUMENT_FRAMES
|
|
63
|
+
|| pendingArgumentBytes + retainedBytes > MAX_PENDING_ARGUMENT_BYTES;
|
|
64
|
+
if (overflow) {
|
|
65
|
+
const flushed = [...pendingArguments.map(pending => pending.block), block];
|
|
66
|
+
if (pendingArgumentBytes > 0) {
|
|
67
|
+
budget?.releaseRetained(pendingArgumentBytes, { kind: "retained_collectors" });
|
|
68
|
+
}
|
|
69
|
+
pendingArguments = [];
|
|
70
|
+
pendingArgumentBytes = 0;
|
|
71
|
+
passthrough = true;
|
|
72
|
+
// Deliberately KEEP routedItemIds. Overflow means we stop buffering UNKNOWN frames, not
|
|
73
|
+
// that we forget what we already classified: an item restored to `tool_search_call`
|
|
74
|
+
// upstream of here would otherwise start emitting `function_call_arguments.*` again and
|
|
75
|
+
// the client would see a mixed private/public lifecycle for one call.
|
|
76
|
+
ordinaryItemIds.clear();
|
|
77
|
+
return flushed;
|
|
78
|
+
}
|
|
79
|
+
if (retainedBytes > 0) {
|
|
80
|
+
try {
|
|
81
|
+
budget?.chargeRetained(retainedBytes, { kind: "retained_collectors" });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (!isTranslatorBudgetExceededError(error)) throw error;
|
|
84
|
+
const flushed = [...pendingArguments.map(pending => pending.block), block];
|
|
85
|
+
if (pendingArgumentBytes > 0) {
|
|
86
|
+
budget?.releaseRetained(pendingArgumentBytes, { kind: "retained_collectors" });
|
|
87
|
+
}
|
|
88
|
+
pendingArguments = [];
|
|
89
|
+
pendingArgumentBytes = 0;
|
|
90
|
+
passthrough = true;
|
|
91
|
+
// Same reasoning as the frame/byte overflow above: an already-restored routed item
|
|
92
|
+
// must keep its frames suppressed even once buffering stops.
|
|
93
|
+
ordinaryItemIds.clear();
|
|
94
|
+
return flushed;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
pendingArguments.push({ block, itemId, outputIndex, retainedBytes });
|
|
98
|
+
pendingArgumentBytes += retainedBytes;
|
|
99
|
+
return null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const takePending = (
|
|
103
|
+
itemId: string | undefined,
|
|
104
|
+
outputIndex: number | undefined,
|
|
105
|
+
): string[] => {
|
|
106
|
+
const matched: PendingArgumentBlock[] = [];
|
|
107
|
+
const remaining: PendingArgumentBlock[] = [];
|
|
108
|
+
for (const pending of pendingArguments) {
|
|
109
|
+
const matches = pending.itemId !== undefined
|
|
110
|
+
? itemId !== undefined && pending.itemId === itemId
|
|
111
|
+
: outputIndex !== undefined && pending.outputIndex === outputIndex;
|
|
112
|
+
(matches ? matched : remaining).push(pending);
|
|
113
|
+
}
|
|
114
|
+
pendingArguments = remaining;
|
|
115
|
+
const retainedBytes = matched.reduce((total, pending) => total + pending.retainedBytes, 0);
|
|
116
|
+
if (retainedBytes > 0) {
|
|
117
|
+
budget?.releaseRetained(retainedBytes, { kind: "retained_collectors" });
|
|
118
|
+
pendingArgumentBytes = Math.max(0, pendingArgumentBytes - retainedBytes);
|
|
119
|
+
}
|
|
120
|
+
return matched.map(pending => {
|
|
121
|
+
if (pending.itemId !== undefined || itemId === undefined) return pending.block;
|
|
122
|
+
const payload = sseDataPayload(pending.block);
|
|
123
|
+
if (payload === null) return pending.block;
|
|
124
|
+
try {
|
|
125
|
+
const parsed: unknown = JSON.parse(payload);
|
|
126
|
+
return isPlainObject(parsed)
|
|
127
|
+
? replaceSseDataPayload(pending.block, JSON.stringify({ ...parsed, item_id: itemId }))
|
|
128
|
+
: pending.block;
|
|
129
|
+
} catch {
|
|
130
|
+
return pending.block;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const rewrite: SseBlockRewrite = (block: string): readonly string[] => {
|
|
136
|
+
if (disposed) return [block];
|
|
137
|
+
const payload = sseDataPayload(block);
|
|
138
|
+
if (payload === null || payload === "[DONE]") return [block];
|
|
139
|
+
let parsed: unknown;
|
|
140
|
+
try {
|
|
141
|
+
parsed = JSON.parse(payload);
|
|
142
|
+
} catch {
|
|
143
|
+
return [block];
|
|
144
|
+
}
|
|
145
|
+
if (!isPlainObject(parsed)) return [block];
|
|
146
|
+
|
|
147
|
+
const type = typeof parsed.type === "string" ? parsed.type : "";
|
|
148
|
+
// After overflow we stop BUFFERING unknown frames, but an item already restored to
|
|
149
|
+
// `tool_search_call` must keep its public argument frames suppressed — otherwise the client
|
|
150
|
+
// receives a private item followed by `function_call_arguments.*` for the same id, which is
|
|
151
|
+
// exactly the mixed lifecycle this rewrite exists to prevent. Everything else passes through.
|
|
152
|
+
if (passthrough) {
|
|
153
|
+
const passthroughItemId = typeof parsed.item_id === "string" ? parsed.item_id : undefined;
|
|
154
|
+
const isArgumentEvent = type === "response.function_call_arguments.delta"
|
|
155
|
+
|| type === "response.function_call_arguments.done";
|
|
156
|
+
if (isArgumentEvent && passthroughItemId && routedItemIds.has(passthroughItemId)) return [];
|
|
157
|
+
return [block];
|
|
158
|
+
}
|
|
159
|
+
const outputIndex = typeof parsed.output_index === "number"
|
|
160
|
+
&& Number.isInteger(parsed.output_index)
|
|
161
|
+
&& parsed.output_index >= 0
|
|
162
|
+
? parsed.output_index
|
|
163
|
+
: undefined;
|
|
164
|
+
if (
|
|
165
|
+
(type === "response.output_item.added" || type === "response.output_item.done")
|
|
166
|
+
&& isPlainObject(parsed.item)
|
|
167
|
+
&& parsed.item.type === "function_call"
|
|
168
|
+
&& typeof parsed.item.name === "string"
|
|
169
|
+
) {
|
|
170
|
+
const itemId = typeof parsed.item.id === "string" ? parsed.item.id : undefined;
|
|
171
|
+
const routed = names.has(parsed.item.name);
|
|
172
|
+
if (itemId) {
|
|
173
|
+
if (routed) {
|
|
174
|
+
routedItemIds.add(itemId);
|
|
175
|
+
ordinaryItemIds.delete(itemId);
|
|
176
|
+
} else {
|
|
177
|
+
ordinaryItemIds.add(itemId);
|
|
178
|
+
routedItemIds.delete(itemId);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const pending = takePending(itemId, outputIndex);
|
|
182
|
+
const restored = routed ? restoreRoutedToolSearchCalls(parsed, names) : { value: parsed, changed: false };
|
|
183
|
+
const restoredBlock = restored.changed
|
|
184
|
+
? replaceSseDataPayload(block, JSON.stringify(restored.value))
|
|
185
|
+
: block;
|
|
186
|
+
// Classification is retained past `output_item.done` for BOTH kinds, until the terminal
|
|
187
|
+
// event releases everything.
|
|
188
|
+
//
|
|
189
|
+
// `done` ends the item, not the id's relevance. Forgetting a ROUTED id let a trailing
|
|
190
|
+
// `function_call_arguments.*` — which some upstreams emit after done — fall through to
|
|
191
|
+
// the unknown-id branch and reach the client as a public frame for an item the client
|
|
192
|
+
// was told is a private `tool_search_call`: the mixed lifecycle this rewrite exists to
|
|
193
|
+
// prevent. Forgetting an ORDINARY id is not leak-shaped but is not free either, because
|
|
194
|
+
// the same fall-through buffers its trailing frames as unknown and delays them until an
|
|
195
|
+
// item that will never arrive. Neither id is dropped early.
|
|
196
|
+
return routed ? [restoredBlock] : [...pending, restoredBlock];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const itemId = typeof parsed.item_id === "string" ? parsed.item_id : undefined;
|
|
200
|
+
const argumentEvent = type === "response.function_call_arguments.delta"
|
|
201
|
+
|| type === "response.function_call_arguments.done";
|
|
202
|
+
if (argumentEvent && (!itemId || (!routedItemIds.has(itemId) && !ordinaryItemIds.has(itemId)))) {
|
|
203
|
+
return retainPending(block, itemId, outputIndex) ?? [];
|
|
204
|
+
}
|
|
205
|
+
if (argumentEvent && itemId && routedItemIds.has(itemId)) return [];
|
|
206
|
+
|
|
207
|
+
const terminal = type === "response.completed" || type === "response.failed" || type === "response.incomplete";
|
|
208
|
+
if (!terminal) return [block];
|
|
209
|
+
const restored = restoreRoutedToolSearchCalls(parsed, names);
|
|
210
|
+
releaseAll();
|
|
211
|
+
return restored.changed
|
|
212
|
+
? [replaceSseDataPayload(block, JSON.stringify(restored.value))]
|
|
213
|
+
: [block];
|
|
214
|
+
};
|
|
215
|
+
rewrite.dispose = releaseAll;
|
|
216
|
+
return rewrite;
|
|
217
|
+
}
|
package/src/server/system-env.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
2
|
+
import { accessSync, constants, readFileSync, writeFileSync, unlinkSync, mkdirSync, statSync } from "node:fs";
|
|
3
|
+
import { delimiter, join } from "node:path";
|
|
4
4
|
import { getConfigDir } from "../config";
|
|
5
5
|
import { resolveAutoContext, type AutoContextMode } from "../claude/context-windows";
|
|
6
6
|
import { PROXY_MARKER, defaultAuthDetectDeps, detectClaudeAuth, ownAdmissionTokens } from "../claude/auth-detect";
|
|
@@ -118,15 +118,84 @@ export function uninstallShellHook(): { removed: boolean; reason?: string } {
|
|
|
118
118
|
try {
|
|
119
119
|
const content = readFileSync(zshrcPath, "utf8");
|
|
120
120
|
if (!content.includes(SHELL_HOOK_MARKER)) return { removed: false, reason: "not installed" };
|
|
121
|
-
//
|
|
122
|
-
|
|
121
|
+
// Match CR?LF, not LF alone. A .zshrc with CRLF line endings — ordinary on a home
|
|
122
|
+
// directory an editor or another OS has touched — did not match, so the file was
|
|
123
|
+
// rewritten unchanged and the caller was told the hook was removed. Reporting success
|
|
124
|
+
// while the hook still sources on every new shell is the worse of the two failures.
|
|
125
|
+
const cleaned = content.replace(/\r?\n?# opencodex claude-env hook\r?\n\[.*claude-env\.sh.*(?:\r?\n)?/g, "\n");
|
|
126
|
+
// Verify instead of assuming: if the marker survives, the block is shaped in a way this
|
|
127
|
+
// pattern does not own, and the honest answer is failure rather than a silent no-op.
|
|
128
|
+
if (cleaned.includes(SHELL_HOOK_MARKER)) {
|
|
129
|
+
return { removed: false, reason: "hook block present but not in the expected shape; remove it manually" };
|
|
130
|
+
}
|
|
123
131
|
writeFileSync(zshrcPath, cleaned, { encoding: "utf8", mode: 0o644 });
|
|
124
132
|
return { removed: true };
|
|
125
|
-
} catch {
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error && typeof error === "object" && (error as { code?: unknown }).code === "ENOENT") {
|
|
135
|
+
return { removed: false, reason: "not installed" };
|
|
136
|
+
}
|
|
126
137
|
return { removed: false, reason: "read/write failed" };
|
|
127
138
|
}
|
|
128
139
|
}
|
|
129
140
|
|
|
141
|
+
/** Whether a real `claude` executable is discoverable from this process's PATH. */
|
|
142
|
+
export function claudeCodeCliInstalled(pathValue = process.env.PATH): boolean {
|
|
143
|
+
if (!pathValue) return false;
|
|
144
|
+
for (const directory of pathValue.split(delimiter)) {
|
|
145
|
+
// An empty PATH segment means the current directory. Do not let the proxy treat a
|
|
146
|
+
// workspace-local file as a durable user installation.
|
|
147
|
+
if (!directory) continue;
|
|
148
|
+
const candidate = join(directory, "claude");
|
|
149
|
+
try {
|
|
150
|
+
if (!statSync(candidate).isFile()) continue;
|
|
151
|
+
accessSync(candidate, constants.X_OK);
|
|
152
|
+
return true;
|
|
153
|
+
} catch {
|
|
154
|
+
// Keep scanning PATH after missing, non-file, and non-executable entries.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Keep the shell hook aligned with the integration that can actually consume it.
|
|
162
|
+
* Claude Desktop uses its own profile and does not source `.zshrc`; this hook exists
|
|
163
|
+
* only for plain Claude Code CLI launches.
|
|
164
|
+
*
|
|
165
|
+
* Reconciliation is PATH-sensitive by construction: "Claude Code is installed" is answered
|
|
166
|
+
* from the PATH of whichever process calls this. A launchd/service context with a stripped
|
|
167
|
+
* PATH can therefore fail to see a `claude` the user's interactive shell finds, and this will
|
|
168
|
+
* remove the hook. That is the intended failure direction — removing an OpenCodex-owned block
|
|
169
|
+
* is reversible on the next foreground `ocx start`, whereas leaving a hook pointing at an
|
|
170
|
+
* uninstalled CLI is the stale state this reconciliation exists to clear. Only the block
|
|
171
|
+
* carrying our own marker is ever touched; user lines are preserved.
|
|
172
|
+
*/
|
|
173
|
+
export function reconcileShellHook(systemEnvInjected: boolean): {
|
|
174
|
+
changed: boolean;
|
|
175
|
+
state: "installed" | "absent" | "failed";
|
|
176
|
+
reason?: string;
|
|
177
|
+
} {
|
|
178
|
+
if (process.platform !== "darwin") return { changed: false, state: "absent", reason: "not macOS" };
|
|
179
|
+
if (systemEnvInjected && claudeCodeCliInstalled()) {
|
|
180
|
+
const result = installShellHook();
|
|
181
|
+
if (result.installed) return { changed: true, state: "installed" };
|
|
182
|
+
if (result.reason === "already installed") {
|
|
183
|
+
return { changed: false, state: "installed", reason: result.reason };
|
|
184
|
+
}
|
|
185
|
+
return { changed: false, state: "failed", reason: result.reason ?? "install failed" };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const result = uninstallShellHook();
|
|
189
|
+
if (!result.removed && result.reason !== "not installed") {
|
|
190
|
+
return { changed: false, state: "failed", reason: result.reason ?? "remove failed" };
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
changed: result.removed,
|
|
194
|
+
state: "absent",
|
|
195
|
+
reason: systemEnvInjected ? "Claude Code not installed" : "system environment inactive",
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
130
199
|
const SYSTEM_ENV_NAMES = [
|
|
131
200
|
"ANTHROPIC_BASE_URL",
|
|
132
201
|
"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY",
|
|
@@ -179,6 +179,86 @@ function unitEnvValue(body: string, key: string): string | null {
|
|
|
179
179
|
return null;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Did `systemctl --user` fail because the session bus could not be reached at all?
|
|
184
|
+
*
|
|
185
|
+
* These are the shapes reported on #2114 and #1939. The distinction that matters is
|
|
186
|
+
* "the question never left the machine" versus "systemd answered and said no" — only
|
|
187
|
+
* the former licenses reading the disk instead.
|
|
188
|
+
*
|
|
189
|
+
* **Locale caveat, stated rather than hidden:** systemd localizes these strings, so a
|
|
190
|
+
* non-English host will not match and keeps the old `unknown`. That is the safe
|
|
191
|
+
* direction — it fences rather than admits — but it does mean the fix does not reach
|
|
192
|
+
* every affected user. Forcing `LC_ALL=C` on the probe would remove the caveat and is
|
|
193
|
+
* the obvious follow-up; it is not done here because it changes every systemctl call
|
|
194
|
+
* this module makes, not just this branch.
|
|
195
|
+
*/
|
|
196
|
+
function busUnreachable(stderr: string): boolean {
|
|
197
|
+
const err = stderr.trim();
|
|
198
|
+
return err.includes("Failed to connect to bus")
|
|
199
|
+
|| err.includes("Failed to connect to user scope bus")
|
|
200
|
+
|| err.includes("Failed to get D-Bus connection")
|
|
201
|
+
|| err.includes("DBUS_SESSION_BUS_ADDRESS")
|
|
202
|
+
|| err.includes("System has not been booted with systemd");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Ownership from the unit file alone, for when the bus cannot answer (#2114).
|
|
207
|
+
*
|
|
208
|
+
* A unit file is proof of installation that does not require a running bus, and the homes
|
|
209
|
+
* it names are what ownership is actually decided on. What the disk cannot tell us is
|
|
210
|
+
* whether systemd has the unit LOADED, so this reports `registration: "absent"` — the
|
|
211
|
+
* honest reading of "no running manager has it" — rather than inventing a live state.
|
|
212
|
+
*
|
|
213
|
+
* A foreign home therefore still blocks, which is the whole reason this consults the disk
|
|
214
|
+
* instead of widening the exit code.
|
|
215
|
+
*/
|
|
216
|
+
function systemdUserUnitSearchPaths(home: string): string[] {
|
|
217
|
+
// systemd's user search path is not one directory. Checking only the canonical one and
|
|
218
|
+
// calling the rest absent is a fail-open: with the bus down a foreign unit in any other
|
|
219
|
+
// search dir is invisible, and "no answer" would be read as "no owner".
|
|
220
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME?.trim();
|
|
221
|
+
const xdgData = process.env.XDG_DATA_HOME?.trim();
|
|
222
|
+
const dirs = [
|
|
223
|
+
xdgConfig ? join(xdgConfig, "systemd", "user") : join(home, ".config", "systemd", "user"),
|
|
224
|
+
join(home, ".config", "systemd", "user"),
|
|
225
|
+
xdgData ? join(xdgData, "systemd", "user") : join(home, ".local", "share", "systemd", "user"),
|
|
226
|
+
join(home, ".local", "share", "systemd", "user"),
|
|
227
|
+
];
|
|
228
|
+
return [...new Set(dirs)].map(dir => join(dir, `${TASK}.service`));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function inspectSystemdOffline(home: string): ServiceManagerInstallation {
|
|
232
|
+
const candidates = systemdUserUnitSearchPaths(home);
|
|
233
|
+
const found = candidates.filter(path => artifactPresence(path) === "present");
|
|
234
|
+
if (candidates.some(path => artifactPresence(path) === "unreadable")) {
|
|
235
|
+
return unknown("the session bus is unreachable and a systemd unit could not be read");
|
|
236
|
+
}
|
|
237
|
+
if (found.length === 0) return { kind: "absent" };
|
|
238
|
+
if (found.length > 1) {
|
|
239
|
+
return unknown("the session bus is unreachable and more than one systemd unit file claims this proxy");
|
|
240
|
+
}
|
|
241
|
+
const definitionPath = found[0]!;
|
|
242
|
+
let body: string;
|
|
243
|
+
try {
|
|
244
|
+
body = readFileSync(definitionPath, "utf-8");
|
|
245
|
+
} catch (error) {
|
|
246
|
+
return unknown(`the session bus is unreachable and the systemd unit could not be read: ${String(error)}`);
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
kind: "present",
|
|
250
|
+
claims: [{
|
|
251
|
+
backend: "systemd",
|
|
252
|
+
definitionPath,
|
|
253
|
+
homes: {
|
|
254
|
+
codexHome: unitEnvValue(body, "CODEX_HOME"),
|
|
255
|
+
opencodexHome: unitEnvValue(body, "OPENCODEX_HOME"),
|
|
256
|
+
},
|
|
257
|
+
registration: "absent",
|
|
258
|
+
}],
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
182
262
|
function inspectLaunchd(deps: Required<Pick<ProbeDeps, "run" | "uid" | "home">>): ServiceManagerInstallation {
|
|
183
263
|
const definitionPath = join(deps.home, "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
184
264
|
|
|
@@ -269,6 +349,15 @@ function inspectSystemd(deps: Required<Pick<ProbeDeps, "run" | "home">>): Servic
|
|
|
269
349
|
if (shown.status !== 0) {
|
|
270
350
|
// A missing unit still exits ZERO and says not-found; a non-zero status means
|
|
271
351
|
// the question never reached the bus.
|
|
352
|
+
//
|
|
353
|
+
// That is evidence about the BUS, not evidence that a foreign service owns this home
|
|
354
|
+
// (#2114). Calling it `unknown` fences native-main for the whole process, so a laptop
|
|
355
|
+
// with no session bus answers every native request with a 503 until `ocx restart`.
|
|
356
|
+
//
|
|
357
|
+
// Widening on the exit code alone would fail open, because with the bus down systemctl
|
|
358
|
+
// cannot see a foreign unit either. So ask the disk, which needs no bus, and fall back
|
|
359
|
+
// to `unknown` for every other non-zero exit.
|
|
360
|
+
if (busUnreachable(shown.stderr)) return inspectSystemdOffline(deps.home);
|
|
272
361
|
return unknown(`systemctl show exited ${String(shown.status)}: ${shown.stderr.trim()}`);
|
|
273
362
|
}
|
|
274
363
|
|
|
@@ -729,6 +818,16 @@ function walkWinswChain(
|
|
|
729
818
|
const registration = probeWinswRegistration(deps);
|
|
730
819
|
|
|
731
820
|
if (xml === "absent" && exe === "absent" && registration === "absent") return { kind: "absent" };
|
|
821
|
+
// A query we could not ask is a question about a service that cannot exist: WinSW is an
|
|
822
|
+
// optional backend, and with neither its XML nor its exe on disk there is nothing for a
|
|
823
|
+
// registration to belong to. Fencing here on an `sc.exe` timeout is one of the two
|
|
824
|
+
// triggers behind #2108, where a scheduler-only install answers 503 until `ocx restart`.
|
|
825
|
+
//
|
|
826
|
+
// The disk outranks the unaskable query only when BOTH assets are gone. Either one
|
|
827
|
+
// present means a real install may be there and the old `unknown` still holds.
|
|
828
|
+
if (registration === "unknown" && xml === "absent" && exe === "absent") {
|
|
829
|
+
return { kind: "absent" };
|
|
830
|
+
}
|
|
732
831
|
if (registration === "unknown") {
|
|
733
832
|
return unknown("the native WinSW service registration could not be verified");
|
|
734
833
|
}
|
package/src/service.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from
|
|
|
19
19
|
import type { BunRuntimeSource } from "./lib/bun-runtime";
|
|
20
20
|
import { isProcessAlive, stopProxy } from "./lib/process-control";
|
|
21
21
|
import { serviceApiTokenFilePath } from "./lib/service-secrets";
|
|
22
|
+
import { PROXY_ENV_KEYS } from "./lib/proxy-env";
|
|
22
23
|
import { randomUUID } from "node:crypto";
|
|
23
24
|
import {
|
|
24
25
|
ELEVATION_REQUEST_TIMEOUT_MS,
|
|
@@ -389,7 +390,7 @@ function writeServiceApiTokenFile(): string | null {
|
|
|
389
390
|
return path;
|
|
390
391
|
}
|
|
391
392
|
|
|
392
|
-
export function buildPlist(): string {
|
|
393
|
+
export function buildPlist(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string {
|
|
393
394
|
const { bun, bunRuntimeSource, cli } = cliEntry();
|
|
394
395
|
const log = logPath();
|
|
395
396
|
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
@@ -404,6 +405,8 @@ export function buildPlist(): string {
|
|
|
404
405
|
codexHome ? ` <key>CODEX_HOME</key><string>${plistString(codexHome)}</string>` : null,
|
|
405
406
|
codexSqliteHome ? ` <key>CODEX_SQLITE_HOME</key><string>${plistString(codexSqliteHome)}</string>` : null,
|
|
406
407
|
opencodexHome ? ` <key>OPENCODEX_HOME</key><string>${plistString(opencodexHome)}</string>` : null,
|
|
408
|
+
...proxyEnv.map(({ name, value }) =>
|
|
409
|
+
` <key>${name}</key><string>${plistString(value)}</string>`),
|
|
407
410
|
].filter((line): line is string => Boolean(line)).join("\n");
|
|
408
411
|
const command = buildServiceShellCommand(bun, cli);
|
|
409
412
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
@@ -640,6 +643,31 @@ function systemdEnvironmentAssignment(name: string, value: string | undefined):
|
|
|
640
643
|
return `Environment=${systemdQuote(`${name}=${value}`)}`;
|
|
641
644
|
}
|
|
642
645
|
|
|
646
|
+
/**
|
|
647
|
+
* Outbound proxy settings the installing shell had, resolved for baking into a service
|
|
648
|
+
* definition.
|
|
649
|
+
*
|
|
650
|
+
* A service manager does not inherit the environment of the shell that installed it, and
|
|
651
|
+
* `ExecStart=/bin/sh -lc` is dash on Ubuntu/WSL — login dash reads `.profile`, not
|
|
652
|
+
* `.bashrc`, which is where proxy exports usually live. So a user who needs a proxy to
|
|
653
|
+
* reach the upstream got a service that dialed direct: the socket was reset, the retry
|
|
654
|
+
* budget drained, and the request surfaced as `502 Provider unreachable` (#2107). The
|
|
655
|
+
* same install driven through `ocx codex-shim` worked, because that path spawns with
|
|
656
|
+
* `{ ...process.env }`.
|
|
657
|
+
*
|
|
658
|
+
* Lower-case variants are honored because curl-style tooling sets them and the runtime's
|
|
659
|
+
* own `applyProxyEnv` already treats both cases as equivalent. Only the canonical
|
|
660
|
+
* upper-case name is baked, so a definition never carries two spellings of one setting.
|
|
661
|
+
*/
|
|
662
|
+
export function resolvedProxyEnv(env: NodeJS.ProcessEnv = process.env): { name: string; value: string }[] {
|
|
663
|
+
const resolved: { name: string; value: string }[] = [];
|
|
664
|
+
for (const key of PROXY_ENV_KEYS) {
|
|
665
|
+
const value = env[key]?.trim() || env[key.toLowerCase()]?.trim();
|
|
666
|
+
if (value) resolved.push({ name: key, value });
|
|
667
|
+
}
|
|
668
|
+
return resolved;
|
|
669
|
+
}
|
|
670
|
+
|
|
643
671
|
function systemdOutputTarget(value: string): string {
|
|
644
672
|
// StandardOutput/StandardError use output specifiers such as append:/path.
|
|
645
673
|
// Quoting the full specifier makes systemd reject it as an invalid output target.
|
|
@@ -1513,7 +1541,11 @@ function taskXmlRunLevelAcceptable(principal: string): boolean {
|
|
|
1513
1541
|
return value === "leastprivilege" || value === "highestavailable";
|
|
1514
1542
|
}
|
|
1515
1543
|
|
|
1516
|
-
export function buildWindowsServiceScript(
|
|
1544
|
+
export function buildWindowsServiceScript(
|
|
1545
|
+
entry = cliEntry(),
|
|
1546
|
+
port = resolveServiceListenPort(),
|
|
1547
|
+
proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(),
|
|
1548
|
+
): string {
|
|
1517
1549
|
// Provenance rides along with the entry: a second durableBunRuntime() call here could
|
|
1518
1550
|
// resolve differently from the binary the caller actually baked.
|
|
1519
1551
|
const { bun, bunRuntimeSource, cli } = entry;
|
|
@@ -1531,6 +1563,7 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ
|
|
|
1531
1563
|
windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"),
|
|
1532
1564
|
windowsBatchSet("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute("windows"), "path"),
|
|
1533
1565
|
windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"),
|
|
1566
|
+
...proxyEnv.map(({ name, value }) => windowsBatchSet(name, value)),
|
|
1534
1567
|
windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath(), "path"),
|
|
1535
1568
|
windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath(), "path"),
|
|
1536
1569
|
windowsBatchSet("OCX_BUN", bun, "path"),
|
|
@@ -1852,7 +1885,7 @@ function installLaunchd(): void {
|
|
|
1852
1885
|
// Capture this BEFORE writing: the write below makes the plist exist unconditionally,
|
|
1853
1886
|
// so a post-write existsSync would call every fresh install an "installed" service.
|
|
1854
1887
|
const wasInstalled = existsSync(p);
|
|
1855
|
-
|
|
1888
|
+
writeServiceDefinitionFile(p, buildPlist(), "utf8");
|
|
1856
1889
|
// Best-effort: an absent job is fine here, and a failed unload is caught by the
|
|
1857
1890
|
// load verification below with a better message than a raw unload error.
|
|
1858
1891
|
runLaunchctl(["unload", p]);
|
|
@@ -1915,6 +1948,52 @@ function uninstallLaunchd(): void {
|
|
|
1915
1948
|
if (existsSync(p)) unlinkSync(p);
|
|
1916
1949
|
}
|
|
1917
1950
|
|
|
1951
|
+
/**
|
|
1952
|
+
* Write a service definition with owner-only permissions.
|
|
1953
|
+
*
|
|
1954
|
+
* These files carry the outbound proxy environment (#2107), and a proxy URL routinely
|
|
1955
|
+
* carries `user:password`. `writeFileSync` without a mode lands at 0644 under the default
|
|
1956
|
+
* umask, so the credential would be world-readable on a shared host. Every other
|
|
1957
|
+
* secret-bearing write in this file already uses 0600 — the service API token and the
|
|
1958
|
+
* install state — and a service definition holding a proxy credential belongs in the same
|
|
1959
|
+
* class.
|
|
1960
|
+
*
|
|
1961
|
+
* The explicit `chmodSync` is not redundant: `mode` only applies when the file is
|
|
1962
|
+
* created, so an install over a definition left at 0644 by an earlier version would keep
|
|
1963
|
+
* the loose mode.
|
|
1964
|
+
*
|
|
1965
|
+
* On Windows the POSIX bits are advisory, so the ACL is the real boundary — and whether it
|
|
1966
|
+
* may soft-fail depends on what the definition actually contains. A definition carrying a
|
|
1967
|
+
* proxy credential is a secret publication and fails closed like the API token and the
|
|
1968
|
+
* install state do; one carrying only paths and a port is not worth refusing an install
|
|
1969
|
+
* over, since before #2107 these files had no hardening at all and a failure here would
|
|
1970
|
+
* regress a user who has no credential to protect.
|
|
1971
|
+
*/
|
|
1972
|
+
export function writeServiceDefinitionFile(path: string, content: string, encoding: "utf8" | "utf16le"): void {
|
|
1973
|
+
writeFileSync(path, content, { encoding, mode: 0o600 });
|
|
1974
|
+
try { chmodSync(path, 0o600); } catch { /* superseded by the Windows ACL below */ }
|
|
1975
|
+
if (process.platform === "win32") {
|
|
1976
|
+
hardenSecretPath(path, { required: definitionCarriesCredential(content) });
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
/**
|
|
1981
|
+
* Does this service definition embed a credential-bearing proxy URL?
|
|
1982
|
+
*
|
|
1983
|
+
* Only the userinfo form leaks something: `http://user:pass@host` in any of the four proxy
|
|
1984
|
+
* variables. A bare `http://127.0.0.1:7890` is not a secret, and treating it as one would
|
|
1985
|
+
* make an icacls stall fail an install that had nothing to protect.
|
|
1986
|
+
*
|
|
1987
|
+
* The scan is over any URL in the rendered definition rather than over a `KEY=value` shape,
|
|
1988
|
+
* because the three formats render differently — systemd writes `Environment="K=V"`, the
|
|
1989
|
+
* plist writes `<key>K</key><string>V</string>`, and the Windows wrapper writes
|
|
1990
|
+
* `set "K=V"`. Keying on the assignment syntax silently missed the plist.
|
|
1991
|
+
*/
|
|
1992
|
+
export function definitionCarriesCredential(content: string): boolean {
|
|
1993
|
+
// A userinfo authority: scheme, then anything that is not a delimiter, then '@'.
|
|
1994
|
+
return /[a-z][a-z0-9+.-]*:\/\/[^\s"'<>/@]+@/i.test(content);
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1918
1997
|
// ── Windows (Task Scheduler) ──
|
|
1919
1998
|
/**
|
|
1920
1999
|
* In-place service-asset write that tolerates the transient EBUSY/EPERM/EACCES Windows
|
|
@@ -1923,7 +2002,7 @@ function uninstallLaunchd(): void {
|
|
|
1923
2002
|
function writeServiceAssetWithRetry(path: string, content: string, encoding: "utf8" | "utf16le"): void {
|
|
1924
2003
|
for (let attempt = 0; ; attempt++) {
|
|
1925
2004
|
try {
|
|
1926
|
-
|
|
2005
|
+
writeServiceDefinitionFile(path, content, encoding);
|
|
1927
2006
|
return;
|
|
1928
2007
|
} catch (err) {
|
|
1929
2008
|
const code = (err as NodeJS.ErrnoException).code;
|
|
@@ -2415,7 +2494,7 @@ function unitPath(): string {
|
|
|
2415
2494
|
return join(unitDir(), `${TASK}.service`);
|
|
2416
2495
|
}
|
|
2417
2496
|
|
|
2418
|
-
export function buildUnit(): string {
|
|
2497
|
+
export function buildUnit(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string {
|
|
2419
2498
|
const { bun, bunRuntimeSource, cli } = cliEntry();
|
|
2420
2499
|
const log = logPath();
|
|
2421
2500
|
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
@@ -2430,6 +2509,7 @@ export function buildUnit(): string {
|
|
|
2430
2509
|
codexHome,
|
|
2431
2510
|
codexSqliteHome,
|
|
2432
2511
|
opencodexHome,
|
|
2512
|
+
...proxyEnv.map(({ name, value }) => systemdEnvironmentAssignment(name, value)),
|
|
2433
2513
|
].filter((line): line is string => Boolean(line)).join("\n");
|
|
2434
2514
|
return `[Unit]
|
|
2435
2515
|
Description=OpenCodex Proxy Server
|
|
@@ -2490,7 +2570,7 @@ function installSystemd(): void {
|
|
|
2490
2570
|
recordOwnedConfigPath(getConfigDir(), serviceStatePath());
|
|
2491
2571
|
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
|
|
2492
2572
|
writeServiceApiTokenFile();
|
|
2493
|
-
|
|
2573
|
+
writeServiceDefinitionFile(unitPath(), buildUnit(), "utf8");
|
|
2494
2574
|
sh("systemctl --user daemon-reload");
|
|
2495
2575
|
sh(`systemctl --user enable ${TASK}`);
|
|
2496
2576
|
sh(`systemctl --user restart ${TASK}`);
|
package/src/tray/windows.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { BunRuntimeSource } from "../lib/bun-runtime";
|
|
|
9
9
|
import { forgetEphemeralSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
|
|
10
10
|
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
11
11
|
import { renameAtomicFile } from "../lib/windows-atomic-replace";
|
|
12
|
+
import { decodeWindowsTextBytes } from "../lib/windows-text";
|
|
12
13
|
|
|
13
14
|
const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
|
14
15
|
const RUN_PARENT_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion";
|
|
@@ -117,12 +118,31 @@ function registryExe(): string {
|
|
|
117
118
|
return existsSync(candidate) ? candidate : "reg.exe";
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Decode `reg.exe` output the way the rest of the product decodes Windows console
|
|
123
|
+
* output.
|
|
124
|
+
*
|
|
125
|
+
* `reg.exe` writes the console ANSI code page when its output is redirected, not
|
|
126
|
+
* UTF-8. Reading it as utf8 corrupts every non-ASCII byte, so a profile path such
|
|
127
|
+
* as `C:\Users\M<o-umlaut>tz` came back with replacement characters, the
|
|
128
|
+
* comparison against the value we wrote could never match, `registrationOwned`
|
|
129
|
+
* went false, and the CLI reported the tray registration as
|
|
130
|
+
* "foreign, stale, or points to missing package files" over an entry that was
|
|
131
|
+
* correct and owned (#1933).
|
|
132
|
+
*
|
|
133
|
+
* `decodeWindowsTextBytes` already solves this for `schtasks` (#1573). The tray
|
|
134
|
+
* reader was the site that class fix missed.
|
|
135
|
+
*/
|
|
136
|
+
function decodeRegistryOutput(stdout: Buffer | string): string {
|
|
137
|
+
const bytes = typeof stdout === "string" ? Buffer.from(stdout, "binary") : stdout;
|
|
138
|
+
return decodeWindowsTextBytes(bytes).trim();
|
|
139
|
+
}
|
|
140
|
+
|
|
120
141
|
function runRegistry(args: string[]): string {
|
|
121
|
-
return execFileSync(registryExe(), args, {
|
|
122
|
-
encoding: "utf8",
|
|
142
|
+
return decodeRegistryOutput(execFileSync(registryExe(), args, {
|
|
123
143
|
stdio: ["ignore", "pipe", "pipe"],
|
|
124
144
|
windowsHide: true,
|
|
125
|
-
})
|
|
145
|
+
}));
|
|
126
146
|
}
|
|
127
147
|
|
|
128
148
|
function safePath(value: string): string {
|
|
@@ -335,13 +355,13 @@ function readOwnedRunValue(runValue = windowsTrayRunValue(getConfigDir())): stri
|
|
|
335
355
|
function runRegistryAsync(args: string[]): Promise<string> {
|
|
336
356
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
337
357
|
execFile(registryExe(), args, {
|
|
338
|
-
encoding: "
|
|
358
|
+
encoding: "buffer",
|
|
339
359
|
timeout: 2_000,
|
|
340
360
|
windowsHide: true,
|
|
341
361
|
maxBuffer: 64 * 1024,
|
|
342
362
|
}, (error, stdout) => {
|
|
343
363
|
if (error) rejectPromise(error);
|
|
344
|
-
else resolvePromise(stdout
|
|
364
|
+
else resolvePromise(decodeRegistryOutput(stdout));
|
|
345
365
|
});
|
|
346
366
|
});
|
|
347
367
|
}
|