forked from nexu-io/open-design
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathenv.ts
More file actions
365 lines (347 loc) · 14 KB
/
Copy pathenv.ts
File metadata and controls
365 lines (347 loc) · 14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { mergeProxyAwareEnv, resolveSystemProxyEnv } from '@open-design/platform';
import { readAppConfigSync } from '../app-config.js';
import { resolveProjectRelativePath } from '../home-expansion.js';
import { expandConfiguredEnv } from './paths.js';
import { resolveAmrOpenCodeExecutable } from './executables.js';
import { amrVelaProfileEnv } from '../integrations/vela-profile.js';
import { resolveProjectRootFromNestedModule } from '../project-root.js';
import {
applySandboxRuntimeEnv,
isSandboxModeEnabled,
resolveSandboxRuntimeConfig,
type SandboxRuntimeConfig,
} from '../sandbox-mode.js';
type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>;
type SpawnEnvOptions = {
resolvedBin?: string | null;
};
const RUNTIME_MODULE_PROJECT_ROOT = resolveProjectRootFromNestedModule(
path.dirname(fileURLToPath(import.meta.url)),
);
// Build the env passed to spawn() for a given agent adapter.
//
// Auth/config precedence for Local CLI launches:
//
// 1. Provider BYOK is separate. It is used by OpenDesign's direct provider
// API calls and is not automatically mapped into Local CLI launches.
// 2. The inherited launch env represents the user's local CLI setup
// (OAuth/login files, CLI homes, or user-owned API-key env). Preserve it
// so Claude Code/Codex behave like they do in the user's terminal.
// 3. `configuredEnv` comes from Settings -> Local CLI ->
// "Advanced: proxy & custom paths". It is an explicit low-level CLI env
// override, so it wins over inherited env, including API-key variables.
// BASE_URL is optional: when omitted, the underlying CLI uses its own
// official default endpoint.
// When the daemon launches the vela (amr) CLI, forward this installation's id
// so vela's analytics can be correlated back to it. spawnEnvForAgent is
// synchronous, so this uses readAppConfigSync — the synchronous mirror of
// readAppConfig — to resolve consent and the installationId through the exact
// same parsing/validation/defaulting the daemon and web analytics config use.
// That keeps vela's correlation in lockstep with what the web side already
// emits: telemetry defaults to on (opt-out), the id is withheld only when the
// user has explicitly opted out (metrics !== true) or no id exists, and an
// unreadable config simply omits the env (vela reports without it).
function amrAnalyticsIdentityEnv(
env: NodeJS.ProcessEnv,
): Record<string, string> {
const dataDir = env.OD_DATA_DIR?.trim();
if (!dataDir) return {};
let cfg: { telemetry?: { metrics?: boolean }; installationId?: string | null };
try {
cfg = readAppConfigSync(dataDir);
} catch {
return {};
}
// Matches the analytics gate in analytics.ts (`telemetry?.metrics !== true`).
if (cfg.telemetry?.metrics !== true) return {};
const installationId = cfg.installationId;
if (typeof installationId !== 'string' || installationId.length === 0) {
return {};
}
return { OD_INSTALLATION_ID: installationId };
}
export function spawnEnvForAgent(
agentId: string,
baseEnv: RuntimeEnvMap,
configuredEnv: unknown = {},
systemProxyEnv: RuntimeEnvMap = resolveSystemProxyEnv(),
_options: SpawnEnvOptions = {},
): NodeJS.ProcessEnv {
const sandboxRuntime = sandboxRuntimeConfigForBaseEnv(baseEnv);
const expandedConfiguredEnv = expandConfiguredEnv(configuredEnv);
const env = mergeProxyAwareEnv(
process.platform,
systemProxyEnv,
baseEnv,
expandedConfiguredEnv,
);
if (agentId === 'amr') {
Object.assign(env, amrVelaProfileEnv(env));
Object.assign(env, amrAnalyticsIdentityEnv(env));
// `execAgentFile` REPLACES the child environment (execFile with `env`
// set), so anything missing here is genuinely absent for vela. `vela model
// list` resolves its config home up front and exits non-zero with
// "$HOME is not defined" when HOME is unset — while `vela model preset`
// and `vela --version` do not need it. A packaged daemon spawned with a
// stripped env (or any caller that did not forward HOME) would therefore
// detect AMR and seed the picker from preset, yet fail every run's remote
// catalog probe. Backfill HOME from the OS so the authoritative catalog
// call is never silently decapitated by a missing home dir.
if (!env.HOME?.trim()) {
const home = os.homedir();
if (home) env.HOME = home;
}
// Identify OpenDesign as the host so the vela CLI tags its command +
// model_request analytics with source=open_design (revenue attribution).
// Not PII (unlike the installation id above), so set it regardless of the
// telemetry-consent gate that amrAnalyticsIdentityEnv applies.
if (!env.AMR_CLIENT_SOURCE?.trim()) {
env.AMR_CLIENT_SOURCE = 'open_design';
}
// AMR runs through Vela's private OpenCode server. The server inherits
// this flag, which enables OpenCode's built-in, keyless Exa websearch
// tool for AMR without changing the standalone Vela CLI default.
if (!env.OPENCODE_ENABLE_EXA?.trim()) {
env.OPENCODE_ENABLE_EXA = '1';
}
// Vela owns the private OpenCode config and intentionally discards a
// parent OPENCODE_CONFIG_CONTENT. Its explicit opt-in lets AMR mount the
// keyless Parallel Search MCP (web_search + web_fetch) alongside Exa.
if (!env.VELA_ENABLE_PARALLEL_MCP?.trim()) {
env.VELA_ENABLE_PARALLEL_MCP = '1';
}
if (!env.OPENCODE_TEST_HOME?.trim() && env.OD_DATA_DIR?.trim()) {
env.OPENCODE_TEST_HOME = path.join(
env.OD_DATA_DIR.trim(),
'amr',
'opencode-home',
);
}
if (!env.VELA_OPENCODE_BIN?.trim()) {
const opencodeBin = resolveAmrOpenCodeExecutable(env);
if (opencodeBin) env.VELA_OPENCODE_BIN = opencodeBin;
}
return finalizeRuntimeEnv(env, sandboxRuntime);
}
if (agentId === 'claude') {
return finalizeRuntimeEnv(env, sandboxRuntime);
}
if (agentId === 'codex') {
// Name the rollout root the codex CLI is about to write into. Child
// evidence is read back from `<CODEX_HOME>/sessions/.../rollout-*.jsonl`,
// and `collectCodexChildEvidence` deliberately refuses a homedir fallback
// so it can never attribute one install's sessions to another. That leaves
// the caller owing it an explicit root — which nothing supplied, so the
// collector's `CODEX_HOME` guard was false on every default install and a
// complex Run's native Children went unobserved. The plan still locked
// complex, then failed certification for evidence the daemon simply never
// looked for.
if (!env.CODEX_HOME?.trim()) {
const home = os.homedir();
if (home) env.CODEX_HOME = path.join(home, '.codex');
}
return finalizeRuntimeEnv(env, sandboxRuntime);
}
if (agentId === 'opencode' || agentId === 'byok-opencode') {
stripKeysCaseInsensitive(env, [
'OPENCODE',
'OPENCODE_PID',
'OPENCODE_RUN_ID',
'OPENCODE_SERVER_PASSWORD',
]);
// OpenCode is bun-based and, left to its defaults, walks up from its cwd to
// the nearest project root and runs `bun install` there at startup to set up
// local plugins. When that root is a pnpm workspace (the daemon's own repo,
// or a project nested inside it), the install replaces the pnpm `.pnpm` store
// with a bun `node_modules/.bun` + `bun.lock` and breaks the workspace.
// Disable project-config discovery (and its install) so OpenCode only honors
// the config the daemon injects via OPENCODE_CONFIG_CONTENT — this is exactly
// what the AMR path already does for its private OpenCode server.
if (!env.OPENCODE_DISABLE_PROJECT_CONFIG?.trim()) {
env.OPENCODE_DISABLE_PROJECT_CONFIG = 'true';
}
return finalizeRuntimeEnv(env, sandboxRuntime);
}
if (agentId === 'mimo') {
stripKeysCaseInsensitive(env, [
'MIMOCODE',
'MIMOCODE_PID',
'MIMOCODE_RUN_ID',
'MIMOCODE_SERVER_PASSWORD',
]);
// MiMo builds on the same toolchain as OpenCode and has the same
// workspace-corruption risk when project-config discovery walks up from
// cwd to a pnpm workspace root and runs its own install. Disable it so
// MiMo only honors the config injected through MIMOCODE_CONFIG_CONTENT.
if (!env.MIMOCODE_DISABLE_PROJECT_CONFIG?.trim()) {
env.MIMOCODE_DISABLE_PROJECT_CONFIG = 'true';
}
return finalizeRuntimeEnv(env, sandboxRuntime);
}
return finalizeRuntimeEnv(env, sandboxRuntime);
}
export function openDesignAmrRunAttempt(input: {
retryAttemptCount?: number | null;
manualResumeAttemptCount?: number | null;
}): number {
const normalizedCount = (value: number | null | undefined): number =>
typeof value === 'number' && Number.isFinite(value) && value >= 0
? Math.floor(value)
: 0;
return (
normalizedCount(input.retryAttemptCount) +
normalizedCount(input.manualResumeAttemptCount)
);
}
export function openDesignAmrTraceEnv(input: {
agentId: string;
runId: string;
conversationId?: string | null;
runAttempt: number;
// The exact persisted Workspace binding for this run's project, whether
// Team or Personal. Never derive it from an account-level current/active
// selection. Omission means the historical project is genuinely unbound;
// Vela/AMR owns the resulting wallet and membership decision.
workspaceId?: string | null;
externalPluginAnalytics?: Record<string, unknown> | null;
}): NodeJS.ProcessEnv {
if (input.agentId !== 'amr') return {};
const runId = input.runId.trim();
if (!runId) {
throw new Error('OPEN_DESIGN_RUN_ID requires a non-empty run id for AMR runs');
}
if (!Number.isFinite(input.runAttempt) || input.runAttempt < 0) {
throw new Error('OPEN_DESIGN_RUN_ATTEMPT requires a non-negative finite attempt index');
}
const conversationId = input.conversationId?.trim();
const workspaceId = input.workspaceId?.trim();
const plugin = input.externalPluginAnalytics;
const bounded = (key: string, max = 128): string | null => {
const value = plugin?.[key];
return typeof value === 'string'
&& value.length > 0
&& value.length <= max
&& /^[A-Za-z0-9._:@/-]+$/u.test(value)
? value
: null;
};
const digest = bounded('logicalRequestDigest', 64);
const digestVersion =
plugin?.logicalRequestDigestVersion === 1 ? '1' : null;
return {
OPEN_DESIGN_RUN_ID: runId,
OPEN_DESIGN_RUN_ATTEMPT: String(Math.floor(input.runAttempt)),
...(conversationId ? { OPEN_DESIGN_SESSION_ID: conversationId } : {}),
...(workspaceId ? { OPEN_DESIGN_WORKSPACE_ID: workspaceId } : {}),
...(bounded('pluginWorkflowId')
? { OPEN_DESIGN_PLUGIN_WORKFLOW_ID: bounded('pluginWorkflowId')! }
: {}),
...(digest
? { OPEN_DESIGN_LOGICAL_REQUEST_DIGEST: digest }
: {}),
...(digestVersion
? { OPEN_DESIGN_LOGICAL_REQUEST_DIGEST_VERSION: digestVersion }
: {}),
...(bounded('externalPluginId')
? { OPEN_DESIGN_EXTERNAL_PLUGIN_ID: bounded('externalPluginId')! }
: {}),
...(bounded('externalPluginVersion', 64)
? {
OPEN_DESIGN_EXTERNAL_PLUGIN_VERSION:
bounded('externalPluginVersion', 64)!,
}
: {}),
...(bounded('distributionMechanism', 64)
? {
OPEN_DESIGN_DISTRIBUTION_MECHANISM:
bounded('distributionMechanism', 64)!,
}
: {}),
...(bounded('publisherClass', 32)
? {
OPEN_DESIGN_PUBLISHER_CLASS: bounded('publisherClass', 32)!,
}
: {}),
};
}
function sandboxRuntimeConfigForBaseEnv(
baseEnv: RuntimeEnvMap,
): SandboxRuntimeConfig | null {
if (!isSandboxModeEnabled(baseEnv)) return null;
const dataDir = baseEnv.OD_DATA_DIR?.trim();
if (!dataDir) return null;
const resolvedDataDir = resolveProjectRelativePath(
dataDir,
RUNTIME_MODULE_PROJECT_ROOT,
);
return resolveSandboxRuntimeConfig(true, resolvedDataDir);
}
function reapplySandboxRuntimeEnv(
env: NodeJS.ProcessEnv,
sandboxRuntime: SandboxRuntimeConfig | null,
): NodeJS.ProcessEnv {
if (!sandboxRuntime) return env;
return applySandboxRuntimeEnv(env, sandboxRuntime);
}
function finalizeRuntimeEnv(
env: NodeJS.ProcessEnv,
sandboxRuntime: SandboxRuntimeConfig | null,
): NodeJS.ProcessEnv {
const finalizedEnv = reapplySandboxRuntimeEnv(env, sandboxRuntime);
applyWindowsUserCacheEnv(finalizedEnv);
return finalizedEnv;
}
function stripKeysCaseInsensitive(
env: NodeJS.ProcessEnv,
keysToStrip: readonly string[],
): void {
const keysUpper = new Set(keysToStrip.map((key) => key.toUpperCase()));
for (const key of Object.keys(env)) {
if (keysUpper.has(key.toUpperCase())) delete env[key];
}
}
function applyWindowsUserCacheEnv(env: NodeJS.ProcessEnv): void {
if (process.platform !== 'win32') return;
// GUI-launched Windows daemons can inherit enough PATH to resolve a CLI
// while still missing the profile/cache variables CLIs use at startup.
const userProfile =
envValue(env, 'USERPROFILE') ||
envValue(env, 'HOME') ||
os.homedir();
if (!userProfile) return;
setEnvIfMissing(env, 'USERPROFILE', userProfile);
const localAppData =
envValue(env, 'LOCALAPPDATA') ||
path.win32.join(userProfile, 'AppData', 'Local');
setEnvIfMissing(env, 'LOCALAPPDATA', localAppData);
setEnvIfMissing(
env,
'APPDATA',
path.win32.join(userProfile, 'AppData', 'Roaming'),
);
const tempDir = path.win32.join(localAppData, 'Temp');
setEnvIfMissing(env, 'TEMP', tempDir);
setEnvIfMissing(env, 'TMP', tempDir);
}
function envValue(env: NodeJS.ProcessEnv, key: string): string | null {
const existingKey = Object.keys(env).find(
(candidate) => candidate.toUpperCase() === key.toUpperCase(),
);
const value = existingKey ? env[existingKey] : undefined;
const trimmed = typeof value === 'string' ? value.trim() : '';
return trimmed ? (value as string) : null;
}
function setEnvIfMissing(
env: NodeJS.ProcessEnv,
key: string,
value: string,
): void {
if (envValue(env, key)) return;
const existingKey = Object.keys(env).find(
(candidate) => candidate.toUpperCase() === key.toUpperCase(),
);
env[existingKey ?? key] = value;
}