orizu 0.5.9 → 0.5.10

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.
@@ -23,6 +23,7 @@
23
23
  * branch → approve → apply via the API, then destroy the sandbox.
24
24
  */
25
25
  import { WORKSPACE_SCHEMA_VERSION } from './workspace.js';
26
+ import { sweepForTokenResidue } from './sandbox-hygiene.js';
26
27
  const REPO_DIR = 'repo';
27
28
  const DEFAULT_GENERATED_FILE = 'orizu-slice-note.md';
28
29
  const STEP_PLAN = [
@@ -118,57 +119,6 @@ async function mintRepoToken(fetcher, workspaceId, purpose, sessionId) {
118
119
  expiresAt: typeof body.expiresAt === 'string' ? body.expiresAt : null,
119
120
  };
120
121
  }
121
- /**
122
- * The list of places a short-lived git token can leak inside a sandbox after a
123
- * transient clone+push. List-driven so it is trivial to extend. `${repo}` is
124
- * substituted with the repo path. Ordered from the repo-local stores outward to
125
- * global config, the credential cache, the reflog, shell history, and the live
126
- * process listing / argv — the exact surfaces the Daytona-support question names.
127
- */
128
- export function tokenLeakProbes(repoPath) {
129
- const R = repoPath;
130
- return [
131
- { location: `${R}/.git/config`, command: `cat ${R}/.git/config 2>/dev/null || true` },
132
- { location: `${R}/.git/logs (reflog)`, command: `find ${R}/.git/logs -type f -exec cat {} + 2>/dev/null || true` },
133
- { location: '~/.git-credentials', command: 'cat "$HOME/.git-credentials" 2>/dev/null || true' },
134
- { location: '~/.gitconfig (global)', command: 'cat "$HOME/.gitconfig" 2>/dev/null || true' },
135
- { location: 'git credential cache/store', command: 'cat "$HOME/.cache/git/credential/"* "${XDG_CACHE_HOME:-$HOME/.cache}/git/credential/"* 2>/dev/null || true' },
136
- { location: 'shell history', command: 'cat "$HOME/.bash_history" "$HOME/.zsh_history" 2>/dev/null || true' },
137
- { location: 'process listing / argv', command: 'ps -eo args 2>/dev/null || ps aux 2>/dev/null || true' },
138
- { location: 'process env', command: 'env' },
139
- ];
140
- }
141
- /**
142
- * Grep the sandbox filesystem + process table for token values and the
143
- * `x-access-token` marker across every known leak surface (see `tokenLeakProbes`).
144
- * Returns every finding; an empty array means the transient operations left no
145
- * residue. This is the load-bearing check the whole slice exists to make.
146
- *
147
- * NOTE (local-sim): the reflog/config/credential-store/history probes run
148
- * meaningfully in local-sim, but `ps`/argv coverage is only decisive on the live
149
- * Daytona run (local-sim runs the git clone via a child process on the host, not
150
- * inside an isolated PID namespace). The WS-F memo marks that probe live-only.
151
- */
152
- export async function sweepForTokenResidue(session, opts) {
153
- const markers = opts.markers ?? ['x-access-token'];
154
- const tokens = opts.tokens.filter(token => token.length > 0);
155
- const probes = opts.probes ?? tokenLeakProbes(opts.repoPath);
156
- const findings = [];
157
- for (const probe of probes) {
158
- const { stdout } = await session.exec(probe.command);
159
- for (const token of tokens) {
160
- if (stdout.includes(token)) {
161
- findings.push({ location: probe.location, marker: 'token-value' });
162
- }
163
- }
164
- for (const marker of markers) {
165
- if (stdout.includes(marker)) {
166
- findings.push({ location: probe.location, marker });
167
- }
168
- }
169
- }
170
- return findings;
171
- }
172
122
  // -- The workflow ------------------------------------------------------------
173
123
  /**
174
124
  * Run a command in the sandbox and throw on a non-zero (or missing) exit code,
@@ -0,0 +1,536 @@
1
+ /**
2
+ * `orizu internal hosted-boot` — the in-sandbox BOOT ENTRYPOINT for the DO-path
3
+ * hosted agent (ALI-1057). The session coordinator's Durable Object launches
4
+ * this DETACHED (`ORIZU_SANDBOX_ENTRYPOINT`, via nohup) once it has verified the
5
+ * pre-baked runtime marker; from here the sandbox bootstraps itself under the
6
+ * PULL model (ALI-1055): no operator writes its credentials, and no bearer or
7
+ * connector secret ever rests in Cloudflare.
8
+ *
9
+ * WHAT DIFFERS FROM THE OPERATOR PATH (packages/cli/src/hosted-session-cli.ts +
10
+ * hosted-bootstrap.ts) — and ONLY this:
11
+ * (a) credentials are PULLED over HTTP at boot instead of the operator writing
12
+ * a 0600 bearer file: the boot secret is exchanged for a fresh Orizu agent
13
+ * bearer at ORIZU_AGENT_TOKEN_URL, and the connector env comes from
14
+ * ORIZU_ENV_BUNDLE_URL (the 5A contract — connectors only, NO model key);
15
+ * (b) the git credential helper runs in PULL MODE (hosted-runtime-assets.ts):
16
+ * it GETs a fresh bearer per git op from the agent-token URL (boot-secret
17
+ * auth) rather than reading a host-rotated 0600 file.
18
+ * Everything else — the clone via the credential helper, the loop, the event
19
+ * sink, redaction — is REUSED, not reimplemented.
20
+ *
21
+ * FROZEN ENV CONTRACT (workers/session-coordinator/src/bootstrap.ts
22
+ * `planSandboxEnv`). Required (fail-fast if any is missing):
23
+ * ORIZU_BOOT_SECRET the per-sandbox durable bootstrap secret;
24
+ * ORIZU_AGENT_TOKEN_URL {coordinator}/sessions/:id/agent-token (bearer pull);
25
+ * ORIZU_ENV_BUNDLE_URL {orizu}/api/coordinator/sessions/:id/env-bundle;
26
+ * ORIZU_BASE_URL the Orizu control-plane origin;
27
+ * ORIZU_SESSION_ID this session id.
28
+ * Present-but-optional in the contract, resolved from the control plane when
29
+ * absent: ORIZU_RUN_ID, ORIZU_WORKSPACE_ID (read back from the session), and the
30
+ * non-secret ANTHROPIC_API_KEY dummy placeholder (firewall brokers the real key).
31
+ */
32
+ import { spawnSync } from 'child_process';
33
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, writeSync } from 'fs';
34
+ import { AGENT_GIT_IDENTITY, BEARER_BASENAME, BOOT_CONTEXT_BASENAME, DEFAULT_CACHE_REFRESH_BUFFER_MS, HELPER_SCRIPT_BASENAME, REPO_CRED_CACHE_BASENAME, renderCredentialHelperScript, serializeBootContext, } from './hosted-runtime-assets.js';
35
+ import { INJECTED_ENV_VARS_ENV, runHostedLoop, } from './hosted-loop.js';
36
+ import { DEFAULT_EGRESS_CANARY_HOST } from './hosted-loop-lifecycle.js';
37
+ /** Non-secret placeholder model key (firewall brokers the real org key on
38
+ * egress). Byte-for-byte the operator path's constant
39
+ * (packages/cli/src/hosted-session-cli.ts `ANTHROPIC_DUMMY_KEY` and the
40
+ * DO-path `workers/session-coordinator/src/bootstrap.ts`); kept in sync by
41
+ * grep. Inlined (not imported) to avoid a hosted-session-cli import cycle. */
42
+ const ANTHROPIC_DUMMY_KEY = 'sk-ant-orizu-proxy-broker-placeholder';
43
+ /** Default hosted model — mirrors hosted-session-cli.ts `DEFAULT_HOSTED_MODEL`. */
44
+ const DEFAULT_HOSTED_MODEL = 'anthropic/claude-opus-4-8';
45
+ // -- Frozen env contract ------------------------------------------------------
46
+ export const REQUIRED_BOOT_ENV_VARS = [
47
+ 'ORIZU_BOOT_SECRET',
48
+ 'ORIZU_AGENT_TOKEN_URL',
49
+ 'ORIZU_ENV_BUNDLE_URL',
50
+ 'ORIZU_BASE_URL',
51
+ 'ORIZU_SESSION_ID',
52
+ ];
53
+ /** Validate the frozen env contract. A list of missing REQUIRED names means the
54
+ * boot must FAIL FAST with a clear error (the DO records the non-zero exit). */
55
+ export function resolveHostedBootEnv(env) {
56
+ const missing = [];
57
+ const req = (name) => {
58
+ const value = env[name]?.trim();
59
+ if (!value)
60
+ missing.push(name);
61
+ return value ?? '';
62
+ };
63
+ const bootSecret = req('ORIZU_BOOT_SECRET');
64
+ const agentTokenUrl = req('ORIZU_AGENT_TOKEN_URL');
65
+ const envBundleUrl = req('ORIZU_ENV_BUNDLE_URL');
66
+ const baseUrl = req('ORIZU_BASE_URL');
67
+ const sessionId = req('ORIZU_SESSION_ID');
68
+ if (missing.length > 0)
69
+ return { ok: false, missing };
70
+ return {
71
+ ok: true,
72
+ value: {
73
+ bootSecret,
74
+ agentTokenUrl,
75
+ envBundleUrl,
76
+ baseUrl: baseUrl.replace(/\/+$/, ''),
77
+ sessionId,
78
+ runId: env.ORIZU_RUN_ID?.trim() || null,
79
+ workspaceId: env.ORIZU_WORKSPACE_ID?.trim() || null,
80
+ anthropicDummyKey: env.ANTHROPIC_API_KEY?.trim() || null,
81
+ },
82
+ };
83
+ }
84
+ /**
85
+ * Exchange the boot secret for a fresh Orizu agent bearer at the coordinator's
86
+ * agent-token route (`GET`, `Authorization: Bearer <boot secret>` → 200
87
+ * `{token, tokenId, expiresAt}`). Retries with exponential backoff: the DO
88
+ * persists the boot-secret digest and serves this route DURING bootstrap, but a
89
+ * transient mint failure (control-plane blip) should not abort the whole boot.
90
+ * The secret is NEVER logged.
91
+ */
92
+ export async function pullAgentBearer(opts) {
93
+ const attempts = Math.max(1, opts.attempts ?? 5);
94
+ const baseBackoff = opts.backoffMs ?? 500;
95
+ const sleep = opts.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
96
+ let lastDetail = 'no attempt made';
97
+ for (let attempt = 0; attempt < attempts; attempt++) {
98
+ try {
99
+ const res = await opts.fetchImpl(opts.agentTokenUrl, {
100
+ method: 'GET',
101
+ headers: { Authorization: `Bearer ${opts.bootSecret}` },
102
+ });
103
+ if (res.ok) {
104
+ const data = (await res.json().catch(() => null));
105
+ const token = data && typeof data.token === 'string' ? data.token : '';
106
+ if (token) {
107
+ const expiry = data && typeof data.expiresAt === 'string' ? Date.parse(data.expiresAt) : NaN;
108
+ return { token, expiresAtMs: Number.isFinite(expiry) ? expiry : null };
109
+ }
110
+ lastDetail = 'agent-token response carried no token';
111
+ }
112
+ else {
113
+ lastDetail = `agent-token pull returned ${res.status}`;
114
+ }
115
+ }
116
+ catch (error) {
117
+ lastDetail = error instanceof Error ? error.message : String(error);
118
+ }
119
+ if (attempt < attempts - 1) {
120
+ const delay = baseBackoff * 2 ** attempt;
121
+ opts.log?.(`agent-token pull attempt ${attempt + 1}/${attempts} failed (${lastDetail}); retrying in ${delay}ms`);
122
+ await sleep(delay);
123
+ }
124
+ }
125
+ throw new Error(`agent-token pull failed after ${attempts} attempts: ${lastDetail}`);
126
+ }
127
+ /**
128
+ * Pull the connector env bundle (5A contract): `GET` with the agent bearer → 200
129
+ * `{sessionId, connectors:[{ref,envVar,value}], redactEnvVars:[...]}`. The model
130
+ * key is DELIBERATELY absent (brokered at the firewall). Never logs a value.
131
+ */
132
+ export async function fetchEnvBundle(opts) {
133
+ const res = await opts.fetchImpl(opts.envBundleUrl, {
134
+ method: 'GET',
135
+ headers: { Authorization: `Bearer ${opts.bearer}` },
136
+ });
137
+ if (!res.ok)
138
+ throw new Error(`env-bundle pull returned ${res.status}`);
139
+ const data = (await res.json().catch(() => null));
140
+ if (!data || typeof data !== 'object')
141
+ throw new Error('env-bundle response was not a JSON object');
142
+ const connectors = Array.isArray(data.connectors)
143
+ ? data.connectors.filter((c) => !!c && typeof c === 'object' && typeof c.envVar === 'string' && typeof c.value === 'string')
144
+ : [];
145
+ const redactEnvVars = Array.isArray(data.redactEnvVars)
146
+ ? data.redactEnvVars.filter((v) => typeof v === 'string')
147
+ : connectors.map(c => c.envVar);
148
+ return {
149
+ sessionId: typeof data.sessionId === 'string' ? data.sessionId : null,
150
+ connectors,
151
+ redactEnvVars,
152
+ };
153
+ }
154
+ /**
155
+ * Export each connector `{envVar: value}` into the environment the loop/agent
156
+ * inherits, and REGISTER every redacted var on `ORIZU_INJECTED_ENV_VARS` — the
157
+ * exact hook the in-sandbox loop reads (`redactionListFromEnv`) to scrub these
158
+ * values from run events. Returns the merged redaction-var list.
159
+ */
160
+ export function applyEnvBundle(bundle, env) {
161
+ for (const connector of bundle.connectors) {
162
+ env[connector.envVar] = connector.value;
163
+ }
164
+ const existing = (env[INJECTED_ENV_VARS_ENV] ?? '')
165
+ .split(',')
166
+ .map(name => name.trim())
167
+ .filter(name => name.length > 0);
168
+ // Redact EVERY exported connector var, not only the server's redactEnvVars
169
+ // list (review F4 defense-in-depth): if a compromised/misbehaving bundle
170
+ // returned connectors with a partial/empty redactEnvVars, their values would
171
+ // otherwise reach run events unscrubbed. We export the value, so we redact it.
172
+ const connectorVars = bundle.connectors.map(c => c.envVar);
173
+ const merged = Array.from(new Set([...existing, ...bundle.redactEnvVars, ...connectorVars]));
174
+ env[INJECTED_ENV_VARS_ENV] = merged.join(',');
175
+ return merged;
176
+ }
177
+ async function bearerJson(fetchImpl, url, bearer, init = {}) {
178
+ const res = await fetchImpl(url, {
179
+ ...init,
180
+ headers: { ...(init.headers || {}), Authorization: `Bearer ${bearer}` },
181
+ });
182
+ if (!res.ok) {
183
+ throw new Error(`${init.method ?? 'GET'} ${new URL(url).pathname} failed (${res.status})`);
184
+ }
185
+ return (await res.json().catch(() => ({})));
186
+ }
187
+ function asString(value) {
188
+ return typeof value === 'string' && value.length > 0 ? value : null;
189
+ }
190
+ /** GET the session (agent-capable, RLS-scoped) to learn its workspace, branch,
191
+ * task, and any existing run — the DO start body carries none of these. */
192
+ export async function resolveSession(opts) {
193
+ const body = await bearerJson(opts.fetchImpl, `${opts.baseUrl}/api/cli/sessions/${encodeURIComponent(opts.sessionId)}`, opts.bearer);
194
+ const session = (body.session ?? {});
195
+ const workspaceId = asString(session.workspaceId);
196
+ if (!workspaceId)
197
+ throw new Error('session response carried no workspaceId');
198
+ const repoBranch = asString(session.repoBranch);
199
+ if (!repoBranch)
200
+ throw new Error('session response carried no repoBranch (branch not provisioned)');
201
+ const clientInfo = (session.clientInfo ?? {});
202
+ const task = asString(clientInfo.task);
203
+ if (!task)
204
+ throw new Error('session client_info carried no task prompt');
205
+ const runs = Array.isArray(session.runs) ? session.runs : [];
206
+ const runId = runs.length > 0 ? asString(runs[0].id) : null;
207
+ return {
208
+ workspaceId,
209
+ repoBranch,
210
+ task,
211
+ model: asString(clientInfo.model),
212
+ reasoningEffort: asString(clientInfo.reasoningEffort),
213
+ runId,
214
+ };
215
+ }
216
+ /** Reuse an existing run or start one with the AGENT bearer (actor_type='agent'),
217
+ * exactly like the operator path's run-start step. */
218
+ export async function ensureRun(opts) {
219
+ if (opts.existingRunId)
220
+ return opts.existingRunId;
221
+ const body = await bearerJson(opts.fetchImpl, `${opts.baseUrl}/api/cli/workbench-runs`, opts.bearer, {
222
+ method: 'POST',
223
+ headers: { 'Content-Type': 'application/json' },
224
+ body: JSON.stringify({
225
+ workspaceSessionId: opts.sessionId,
226
+ title: opts.title ?? `Hosted session ${opts.sessionId}`,
227
+ }),
228
+ });
229
+ const run = (body.run ?? {});
230
+ const runId = asString(run.id);
231
+ if (!runId)
232
+ throw new Error('workbench-run start returned no run id');
233
+ return runId;
234
+ }
235
+ /** Mint a session_read repo token to learn the repo full name, then build the
236
+ * GitHub clone URL (mirrors the operator path's `defaultResolveRepo`). The
237
+ * minted token is best-effort revoked — the credential helper mints its own. */
238
+ export async function resolveRepo(opts) {
239
+ const url = `${opts.baseUrl}/api/cli/workspaces/${encodeURIComponent(opts.workspaceId)}/repo-token`;
240
+ const minted = await bearerJson(opts.fetchImpl, url, opts.bearer, {
241
+ method: 'POST',
242
+ headers: { 'Content-Type': 'application/json' },
243
+ body: JSON.stringify({ purpose: 'session_read', sessionId: opts.sessionId }),
244
+ });
245
+ const repoFullName = asString(minted.repo);
246
+ if (!repoFullName)
247
+ throw new Error('repo-token response carried no repo');
248
+ const token = asString(minted.token);
249
+ const mintId = asString(minted.mintId);
250
+ if (token && mintId) {
251
+ try {
252
+ await opts.fetchImpl(url, {
253
+ method: 'DELETE',
254
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${opts.bearer}` },
255
+ body: JSON.stringify({ mintId, token }),
256
+ });
257
+ }
258
+ catch {
259
+ // best-effort revoke — the short TTL is the backstop.
260
+ }
261
+ }
262
+ return { repoFullName, cloneUrl: `https://github.com/${repoFullName}.git` };
263
+ }
264
+ const defaultExec = (cmd, args, opts) => {
265
+ const res = spawnSync(cmd, args, { cwd: opts?.cwd, encoding: 'utf8' });
266
+ return { status: res.status ?? 1, stdout: res.stdout ?? '', stderr: res.stderr ?? '' };
267
+ };
268
+ /** Values interpolated into git's credential.helper string / clone args must be
269
+ * benign. We pass everything via execFile-style arg arrays (no shell), but
270
+ * still reject anything with newlines/control chars as defense in depth. */
271
+ const SAFE_GIT_VALUE = /^[A-Za-z0-9._/:@+ -]+$/;
272
+ function assertSafeGitValue(name, value) {
273
+ if (!SAFE_GIT_VALUE.test(value)) {
274
+ throw new Error(`unsafe characters in ${name}; refusing to pass to git`);
275
+ }
276
+ }
277
+ function writeSecretFile(path, contents, write) {
278
+ write(path, contents.endsWith('\n') ? contents : `${contents}\n`);
279
+ }
280
+ /**
281
+ * The full in-sandbox boot: validate → pull bearer → pull+apply env bundle →
282
+ * resolve session/run/repo → write the pull-mode credential assets → clone the
283
+ * session branch → launch the hosted loop (reused, in-process) with a bearer
284
+ * provider fed by the same pull-mode source. Returns the loop's terminal status.
285
+ */
286
+ export async function runHostedBoot(opts) {
287
+ const env = opts.env;
288
+ const processEnv = opts.processEnv ?? process.env;
289
+ const root = opts.root ?? process.cwd();
290
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
291
+ const exec = opts.exec ?? defaultExec;
292
+ const mkdirp = opts.mkdirp ?? ((path) => void mkdirSync(path, { recursive: true }));
293
+ const writeFile = opts.writeFile ??
294
+ ((path, contents) => {
295
+ // Atomic write (review F2): write a 0600 temp then rename, so a reader
296
+ // (the loop reading the bearer file per request, or the credential
297
+ // helper) never observes a truncated file mid-rotation. Single writer
298
+ // per path (the rotation timer), so a fixed .tmp suffix is safe.
299
+ const tmp = `${path}.tmp`;
300
+ const fd = openSync(tmp, 'w', 0o600);
301
+ try {
302
+ writeSync(fd, contents);
303
+ }
304
+ finally {
305
+ closeSync(fd);
306
+ }
307
+ renameSync(tmp, path);
308
+ });
309
+ const readFile = opts.readFile ?? ((path) => readFileSync(path, 'utf8'));
310
+ const log = opts.log ?? (() => { });
311
+ const now = opts.now ?? (() => Date.now());
312
+ // 1 — Pull the agent bearer (retry/backoff — the DO may still be arming).
313
+ const bearer = await pullAgentBearer({
314
+ agentTokenUrl: env.agentTokenUrl,
315
+ bootSecret: env.bootSecret,
316
+ fetchImpl,
317
+ attempts: opts.bearerAttempts,
318
+ backoffMs: opts.bearerBackoffMs,
319
+ sleep: opts.sleep,
320
+ log,
321
+ });
322
+ log('agent bearer pulled');
323
+ // 2 — Pull the connector env bundle and export it (+ register redaction).
324
+ const bundle = await fetchEnvBundle({ envBundleUrl: env.envBundleUrl, bearer: bearer.token, fetchImpl });
325
+ const redacted = applyEnvBundle(bundle, processEnv);
326
+ log(`env bundle applied (${bundle.connectors.length} connectors, ${redacted.length} redacted vars)`);
327
+ // 3 — Resolve the session (workspace/branch/task) + run + repo from the plane.
328
+ const session = await resolveSession({ baseUrl: env.baseUrl, sessionId: env.sessionId, bearer: bearer.token, fetchImpl });
329
+ const workspaceId = env.workspaceId ?? session.workspaceId;
330
+ const runId = await ensureRun({
331
+ baseUrl: env.baseUrl,
332
+ sessionId: env.sessionId,
333
+ bearer: bearer.token,
334
+ fetchImpl,
335
+ existingRunId: env.runId ?? session.runId,
336
+ });
337
+ const repo = await resolveRepo({ baseUrl: env.baseUrl, workspaceId, sessionId: env.sessionId, bearer: bearer.token, fetchImpl });
338
+ log(`resolved run ${runId} on ${repo.repoFullName}@${session.repoBranch}`);
339
+ // 4 — Write the run-scoped assets: boot secret + initial bearer (0600), the
340
+ // PULL-MODE credential helper + its boot context. The bearer file feeds the
341
+ // loop's event sink; the boot secret feeds the credential helper's per-op pull.
342
+ const runDirRel = `.orizu-run/${runId}`;
343
+ const runDirAbs = `${root}/${runDirRel}`;
344
+ const workspaceDir = `${root}/repo`;
345
+ assertSafeGitValue('sessionBranch', session.repoBranch);
346
+ assertSafeGitValue('cloneUrl', repo.cloneUrl);
347
+ mkdirp(runDirAbs);
348
+ const bootSecretFileAbs = `${runDirAbs}/boot-secret`;
349
+ const bearerFileAbs = `${runDirAbs}/${BEARER_BASENAME}`;
350
+ const helperScriptAbs = `${runDirAbs}/${HELPER_SCRIPT_BASENAME}`;
351
+ const bootContextAbs = `${runDirAbs}/${BOOT_CONTEXT_BASENAME}`;
352
+ const cacheFileAbs = `${runDirAbs}/${REPO_CRED_CACHE_BASENAME}`;
353
+ const taskFileAbs = `${runDirAbs}/task.txt`;
354
+ writeSecretFile(bootSecretFileAbs, env.bootSecret, writeFile);
355
+ writeSecretFile(bearerFileAbs, bearer.token, writeFile);
356
+ writeFile(taskFileAbs, session.task);
357
+ writeFile(helperScriptAbs, renderCredentialHelperScript());
358
+ const bootContext = {
359
+ apiBaseUrl: env.baseUrl,
360
+ workspaceId,
361
+ sessionId: env.sessionId,
362
+ runId,
363
+ sessionBranch: session.repoBranch,
364
+ repoFullName: repo.repoFullName,
365
+ host: 'github.com',
366
+ bearerFile: bearerFileAbs,
367
+ cacheFile: cacheFileAbs,
368
+ // PULL MODE: source the bearer over HTTP from the coordinator per git op.
369
+ agentTokenUrl: env.agentTokenUrl,
370
+ bootSecretFile: bootSecretFileAbs,
371
+ tokenPurposes: { primary: 'session_write', fallback: 'session_read' },
372
+ cacheBufferMs: DEFAULT_CACHE_REFRESH_BUFFER_MS,
373
+ };
374
+ writeFile(bootContextAbs, serializeBootContext(bootContext));
375
+ // 5 — Clone the session branch VIA the pull-mode credential helper (same
376
+ // invocation the operator path makes; no token in the URL/config).
377
+ const helperValue = `!node ${helperScriptAbs} ${bootContextAbs}`;
378
+ const clone = exec('git', [
379
+ 'clone',
380
+ '--depth',
381
+ '1',
382
+ '--branch',
383
+ session.repoBranch,
384
+ '-c',
385
+ `credential.helper=${helperValue}`,
386
+ '-c',
387
+ 'credential.useHttpPath=true',
388
+ repo.cloneUrl,
389
+ workspaceDir,
390
+ ]);
391
+ if (clone.status !== 0) {
392
+ const detail = (clone.stderr || clone.stdout || `exit ${clone.status}`).trim();
393
+ throw new Error(`git clone failed: ${detail}`);
394
+ }
395
+ // Persist the helper + agent identity repo-LOCAL for subsequent fetch/push.
396
+ exec('git', ['-C', workspaceDir, 'config', 'credential.helper', helperValue]);
397
+ exec('git', ['-C', workspaceDir, 'config', 'credential.useHttpPath', 'true']);
398
+ exec('git', ['-C', workspaceDir, 'config', 'user.name', AGENT_GIT_IDENTITY.name]);
399
+ exec('git', ['-C', workspaceDir, 'config', 'user.email', AGENT_GIT_IDENTITY.email]);
400
+ log(`cloned ${session.repoBranch}`);
401
+ // 6 — Launch the hosted loop, reused in-process. The bearer PROVIDER keeps the
402
+ // event sink's bearer fresh from the same pull-mode source; the DO-provisioned
403
+ // sandbox always runs the pre-baked image under an enforced egress policy, so
404
+ // the loop's startup egress canary + deferred setup hook are both armed.
405
+ const model = session.model ?? DEFAULT_HOSTED_MODEL;
406
+ const loopContext = {
407
+ apiBaseUrl: env.baseUrl,
408
+ runId,
409
+ bearerFile: bearerFileAbs,
410
+ taskFile: taskFileAbs,
411
+ workspaceDir,
412
+ model,
413
+ reasoningEffort: session.reasoningEffort ?? undefined,
414
+ messageId: `${runId}:task`,
415
+ author: AGENT_GIT_IDENTITY,
416
+ anthropicDummyKey: env.anthropicDummyKey ?? ANTHROPIC_DUMMY_KEY,
417
+ prebaked: true,
418
+ egressCanaryHost: DEFAULT_EGRESS_CANARY_HOST,
419
+ runSetupHook: true,
420
+ };
421
+ const rotation = startBearerRotation({
422
+ agentTokenUrl: env.agentTokenUrl,
423
+ bootSecret: env.bootSecret,
424
+ bearerFileAbs,
425
+ fetchImpl,
426
+ writeFile,
427
+ initialExpiresAtMs: bearer.expiresAtMs,
428
+ now,
429
+ log,
430
+ });
431
+ try {
432
+ const runLoop = opts.runLoop ??
433
+ ((input) => runHostedLoop({ context: input.context, taskPrompt: input.taskPrompt, bearerProvider: input.bearerProvider }));
434
+ const result = await runLoop({
435
+ context: loopContext,
436
+ taskPrompt: session.task,
437
+ // Read the bearer FILE fresh per request (review F1): `startBearerRotation`
438
+ // rewrites `bearerFileAbs` before the current bearer expires, so a run
439
+ // exceeding the ~60-min TTL (DO cap is 24h) keeps a valid bearer. A frozen
440
+ // closure over the boot-time token would silently stop recording events at
441
+ // ~TTL. The initial token was written to this file at boot (writeSecretFile
442
+ // above); the write is atomic (F2), so this never reads a torn file.
443
+ bearerProvider: () => readFile(bearerFileAbs).trim(),
444
+ });
445
+ log(`hosted-loop finished: ${result.status}`);
446
+ return { ok: !result.error, runId, loopStatus: result.status, error: result.error };
447
+ }
448
+ finally {
449
+ rotation.stop();
450
+ }
451
+ }
452
+ /**
453
+ * Background refresher: before the current bearer expires, pull a fresh one from
454
+ * the coordinator (boot-secret auth) and atomically rewrite the 0600 bearer file
455
+ * the loop reads per request. This is the DO-path analogue of the operator's
456
+ * host-side rotation loop — moved in-sandbox because no operator is attached.
457
+ * Best-effort: a failed refresh is logged and retried. The loop's bearerProvider
458
+ * reads this file fresh per request (see runHostedBoot), so a rewritten file is
459
+ * picked up on the next event/request. No-ops when there is no expiry signal.
460
+ */
461
+ function startBearerRotation(opts) {
462
+ // Refresh ~5 min before expiry; if the server gives no expiry, fall back to a
463
+ // conservative fixed cadence just under a typical 60-min TTL.
464
+ const REFRESH_BUFFER_MS = 5 * 60 * 1000;
465
+ const FALLBACK_INTERVAL_MS = 50 * 60 * 1000;
466
+ // Cap the delay well under setTimeout's 32-bit ms limit: a far-future (or
467
+ // implausible) expiry just means we re-verify the bearer every few hours.
468
+ const MAX_SCHEDULE_MS = 6 * 60 * 60 * 1000;
469
+ let stopped = false;
470
+ let timer = null;
471
+ const scheduleFrom = (expiresAtMs) => {
472
+ if (stopped)
473
+ return;
474
+ const raw = expiresAtMs ? expiresAtMs - opts.now() - REFRESH_BUFFER_MS : FALLBACK_INTERVAL_MS;
475
+ const delay = Math.min(MAX_SCHEDULE_MS, Math.max(30_000, raw));
476
+ timer = setTimeout(refresh, delay);
477
+ if (typeof timer.unref === 'function')
478
+ timer.unref();
479
+ };
480
+ const refresh = async () => {
481
+ if (stopped)
482
+ return;
483
+ try {
484
+ const fresh = await pullAgentBearer({
485
+ agentTokenUrl: opts.agentTokenUrl,
486
+ bootSecret: opts.bootSecret,
487
+ fetchImpl: opts.fetchImpl,
488
+ attempts: 3,
489
+ });
490
+ const contents = fresh.token.endsWith('\n') ? fresh.token : `${fresh.token}\n`;
491
+ opts.writeFile(opts.bearerFileAbs, contents);
492
+ opts.log('rotated agent bearer (0600 file rewritten)');
493
+ scheduleFrom(fresh.expiresAtMs);
494
+ }
495
+ catch (error) {
496
+ opts.log(`agent bearer rotation failed: ${error instanceof Error ? error.message : String(error)}`);
497
+ scheduleFrom(opts.now() + REFRESH_BUFFER_MS); // retry soon
498
+ }
499
+ };
500
+ scheduleFrom(opts.initialExpiresAtMs);
501
+ return {
502
+ stop: () => {
503
+ stopped = true;
504
+ if (timer)
505
+ clearTimeout(timer);
506
+ },
507
+ };
508
+ }
509
+ /**
510
+ * Read the frozen env contract from the process env and run the boot. Fails fast
511
+ * (exit 1) with a clear message naming every missing required var. The DO records
512
+ * the exit code; run failures beyond the boot itself are recorded server-side.
513
+ */
514
+ export async function hostedBootCommand(io) {
515
+ const resolved = resolveHostedBootEnv(process.env);
516
+ if (!resolved.ok) {
517
+ io.printErr?.(`hosted-boot: missing required env: ${resolved.missing.join(', ')} ` +
518
+ '(the DO sandbox env contract — see planSandboxEnv)');
519
+ return 1;
520
+ }
521
+ try {
522
+ const result = await runHostedBoot({
523
+ env: resolved.value,
524
+ log: line => io.printErr?.(`[hosted-boot] ${line}`),
525
+ });
526
+ io.print(io.json
527
+ ? JSON.stringify({ ok: result.ok, runId: result.runId, status: result.loopStatus, error: result.error })
528
+ : `hosted-boot finished: ${result.loopStatus ?? 'unknown'}${result.error ? ` (${result.error})` : ''}`);
529
+ return result.ok ? 0 : 1;
530
+ }
531
+ catch (error) {
532
+ const message = error instanceof Error ? error.message : String(error);
533
+ io.printErr?.(`hosted-boot failed: ${message}`);
534
+ return 1;
535
+ }
536
+ }
@@ -22,7 +22,7 @@
22
22
  * build ON TOP of this — `bootstrapHostedSandbox` is the single writer for the
23
23
  * run at this stage and returns the run cursor so a follow-on can continue it.
24
24
  */
25
- import { sweepForTokenResidue } from './daytona-slice.js';
25
+ import { sweepForTokenResidue } from './sandbox-hygiene.js';
26
26
  import { redactSecrets } from './secret-redaction.js';
27
27
  import { AGENT_GIT_IDENTITY, BEARER_BASENAME, BOOT_CONTEXT_BASENAME, DEFAULT_CACHE_REFRESH_BUFFER_MS, HELPER_SCRIPT_BASENAME, PREBAKED_MARKER_PATH, REPO_CRED_CACHE_BASENAME, SETUP_HOOK_RELATIVE_PATH, parsePrebakedMarker, renderCredentialHelperScript, serializeBootContext, } from './hosted-runtime-assets.js';
28
28
  function newEventId() {