typeclaw 0.36.1 → 0.36.2

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.
@@ -11,10 +11,22 @@ import {
11
11
 
12
12
  import { createPluginContext, createPluginLogger, type SpawnSubagentFn } from './context'
13
13
  import { createHookBus, type HookBus } from './hooks'
14
- import { loadPluginEntry, type LoadPluginEntryFn, PluginNotFoundError, type ResolvedPlugin } from './loader'
14
+ import { loadPluginEntry, type LoadPluginEntryFn, PluginSecurityError, type ResolvedPlugin } from './loader'
15
15
  import { discardRegistrationsBy, emptyRegistry, type PluginRegistry, registerContributions } from './registry'
16
16
  import type { PluginExports } from './types'
17
17
 
18
+ export type FailedPlugin = { entry: string; phase: 'resolve' | 'config' | 'factory' | 'register'; error: string }
19
+
20
+ // A user (typeclaw.json / local / npm) plugin that fails to load must not brick
21
+ // the agent: the agent can edit its own plugins, and a self-introduced bug would
22
+ // otherwise leave the container unable to boot and repair itself. Such failures
23
+ // are isolated (skip + warn, keep the rest). Bundled plugins are part of the
24
+ // trusted runtime, so their failure stays fatal — and PluginSecurityError stays
25
+ // fatal for everyone.
26
+ function isToleratedUserError(err: unknown): boolean {
27
+ return !(err instanceof PluginSecurityError)
28
+ }
29
+
18
30
  export type LoadPluginsOptions = {
19
31
  entries: string[]
20
32
  agentDir: string
@@ -36,6 +48,7 @@ export type LoadPluginsResult = {
36
48
  permissions: PermissionService
37
49
  declaredPermissions: readonly string[]
38
50
  loadedPlugins: { name: string; version: string | undefined; source: string }[]
51
+ failedPlugins: FailedPlugin[]
39
52
  markBooted: () => void
40
53
  setSpawnSubagent: (fn: SpawnSubagentFn) => void
41
54
  }
@@ -51,113 +64,117 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
51
64
  throw new Error('plugin: spawnSubagent is not yet wired')
52
65
  }
53
66
 
54
- // Non-fatal: a single unresolvable entry (uninstalled package, typo) must
55
- // not abort boot for every other plugin -- warn and skip it. Only genuine
56
- // resolution failures (PluginNotFoundError) are swallowed; path-escape,
57
- // import-time throws, and invalid definitions stay fatal so a broken or
58
- // malicious plugin still hard-fails boot.
67
+ const failed: FailedPlugin[] = []
68
+
69
+ // A user entry that fails to resolve OR throws at import time (typo,
70
+ // uninstalled package, syntax error in a local plugin the agent just edited)
71
+ // is isolated: warn, record, skip — the rest still boot. PluginSecurityError
72
+ // (path escape) is the one exception and stays fatal.
59
73
  const resolvedEntries = await Promise.all(
60
74
  opts.entries.map(async (entry) => {
61
75
  try {
62
76
  return { entry, resolved: await loadEntry(entry, opts.agentDir) }
63
77
  } catch (err) {
64
- if (!(err instanceof PluginNotFoundError)) throw err
65
- console.warn(`[plugin] failed to load "${entry}", ignoring: ${err.message}`)
78
+ if (!isToleratedUserError(err)) throw err
79
+ const message = err instanceof Error ? err.message : String(err)
80
+ console.warn(`[plugin] failed to load "${entry}", skipping: ${message}`)
81
+ failed.push({ entry, phase: 'resolve', error: message })
66
82
  return null
67
83
  }
68
84
  }),
69
85
  )
70
- const allPlugins: { entry: string; resolved: ResolvedPlugin }[] = [
71
- ...(opts.bundled?.map((resolved) => ({ entry: `<bundled:${resolved.name}>`, resolved })) ?? []),
72
- ...resolvedEntries.filter((e): e is { entry: string; resolved: ResolvedPlugin } => e !== null),
86
+ const allPlugins: { entry: string; resolved: ResolvedPlugin; isBundled: boolean }[] = [
87
+ ...(opts.bundled?.map((resolved) => ({ entry: `<bundled:${resolved.name}>`, resolved, isBundled: true })) ?? []),
88
+ ...resolvedEntries
89
+ .filter((e): e is { entry: string; resolved: ResolvedPlugin } => e !== null)
90
+ .map((e) => ({ ...e, isBundled: false })),
73
91
  ]
74
92
 
75
- const declaredPermissions = collectDeclaredPermissions(allPlugins)
76
- const ownerWildcardExclusions = collectOwnerWildcardExclusions(allPlugins)
93
+ // Seed the permission service from BUNDLED plugins only. A user plugin's
94
+ // declared permissions / owner-wildcard exclusions must not enter the live
95
+ // service until the plugin actually survives registration — otherwise a
96
+ // plugin reported as disabled could still widen the allowed set or (worse)
97
+ // strip an owner-wildcard bypass. Bundled plugins are always survivors:
98
+ // their failure is fatal, so the boot aborts before this service is used.
99
+ const bundledPlugins = allPlugins.filter((p) => p.isBundled)
77
100
  const permissions = createPermissionService({
78
101
  ...(opts.roles !== undefined ? { roles: opts.roles } : {}),
79
- pluginPermissions: declaredPermissions,
80
- ownerWildcardExclusions,
102
+ pluginPermissions: collectDeclaredPermissions(bundledPlugins),
103
+ ownerWildcardExclusions: collectOwnerWildcardExclusions(bundledPlugins),
81
104
  })
82
105
 
83
- // Non-fatal: surface user-declared `permissions[]` strings that aren't in
84
- // the known set, so a typo like `security.bypass.secretExfilBach` is
85
- // visible at boot rather than silently failing to bypass the matching
86
- // guard. We log instead of throw because the runtime still functions --
87
- // the unknown string just never matches anything.
88
- for (const warning of findUnknownPermissions(opts.roles, declaredPermissions)) {
89
- console.warn(
90
- `[permissions] role "${warning.role}" declares unknown permission "${warning.permission}" — ${warning.hint}`,
91
- )
92
- }
106
+ const survivors: { entry: string; resolved: ResolvedPlugin; isBundled: boolean }[] = []
93
107
 
94
- for (const { entry, resolved } of allPlugins) {
108
+ for (const plugin of allPlugins) {
109
+ const { entry, resolved, isBundled } = plugin
110
+ // Name conflict is a global invariant (two plugins claiming one name make
111
+ // every later name-keyed lookup ambiguous), so it stays fatal regardless of
112
+ // origin — never demoted to a per-plugin skip.
95
113
  if (loaded.find((l) => l.name === resolved.name)) {
96
114
  throw new Error(`plugin name conflict: ${resolved.name} (entry ${entry}) already loaded`)
97
115
  }
98
116
 
99
- let validatedConfig: unknown = undefined
100
- if (resolved.defined.configSchema) {
101
- const raw = opts.configsByName[resolved.name]
102
- const parsed = (resolved.defined.configSchema as z.ZodType<unknown>).safeParse(raw ?? {})
103
- if (!parsed.success) {
104
- throw new Error(`plugin ${resolved.name}: config invalid: ${formatZodIssues(parsed.error)}`)
105
- }
106
- validatedConfig = parsed.data
107
- } else if (opts.configsByName[resolved.name] !== undefined) {
108
- throw new Error(
109
- `plugin ${resolved.name}: config block "${resolved.name}" present in typeclaw.json but plugin declares no configSchema`,
110
- )
111
- }
112
-
113
- const logger = createPluginLogger(resolved.name)
114
- const ctx = createPluginContext({
115
- name: resolved.name,
116
- version: resolved.version,
117
- agentDir: opts.agentDir,
118
- config: validatedConfig as never,
119
- logger,
120
- permissions,
121
- resolveGithubTokenForRepo: opts.resolveGithubTokenForRepo,
122
- hasGithubAppTokenResolver: opts.hasGithubAppTokenResolver,
123
- spawnSubagent: (name, payload, options) => spawnSubagentImpl(name, payload, options),
124
- isBooted: () => booted,
125
- })
126
-
127
- let exports: PluginExports
128
- try {
129
- exports = await resolved.defined.plugin(ctx)
130
- } catch (err) {
131
- discardRegistrationsBy(resolved.name, registry, hooks)
132
- const message = err instanceof Error ? err.message : String(err)
133
- throw new Error(`plugin ${resolved.name}: factory threw: ${message}`)
134
- }
135
-
136
117
  try {
137
- registerContributions({
138
- pluginName: resolved.name,
139
- logger,
140
- exports,
141
- ...(resolved.defined.commands !== undefined ? { commands: resolved.defined.commands } : {}),
118
+ await registerOnePlugin({
119
+ resolved,
120
+ config: opts.configsByName[resolved.name],
121
+ agentDir: opts.agentDir,
142
122
  registry,
143
123
  hooks,
144
- agentDir: opts.agentDir,
145
- pluginConfig: validatedConfig,
124
+ permissions,
125
+ ctxDeps: {
126
+ resolveGithubTokenForRepo: opts.resolveGithubTokenForRepo,
127
+ hasGithubAppTokenResolver: opts.hasGithubAppTokenResolver,
128
+ spawnSubagent: (name, payload, options) => spawnSubagentImpl(name, payload, options),
129
+ isBooted: () => booted,
130
+ },
146
131
  })
147
132
  } catch (err) {
133
+ const phase = err instanceof PluginPhaseError ? err.phase : 'factory'
134
+ const message = err instanceof PluginPhaseError ? err.detail : err instanceof Error ? err.message : String(err)
135
+ // Bundled/core plugin failures are typeclaw bugs (or a compromised
136
+ // runtime) — fail loud. Only user plugin failures are isolated.
137
+ if (isBundled || !isToleratedUserError(err instanceof PluginPhaseError ? err.original : err)) {
138
+ throw err instanceof PluginPhaseError ? err.original : err
139
+ }
148
140
  discardRegistrationsBy(resolved.name, registry, hooks)
149
- throw err
141
+ console.warn(`[plugin] failed to load "${entry}", skipping: ${message}`)
142
+ failed.push({ entry, phase, error: message })
143
+ continue
150
144
  }
151
145
 
146
+ survivors.push(plugin)
152
147
  loaded.push({ name: resolved.name, version: resolved.version, source: resolved.source })
153
148
  }
154
149
 
150
+ // Finalize the permission model from the survivor set only. Plugin factories
151
+ // captured `permissions` by reference (their hooks read it at request time),
152
+ // so we mutate that same object in place rather than returning a new one.
153
+ const declaredPermissions = collectDeclaredPermissions(survivors)
154
+ permissions.replacePluginPermissions?.({
155
+ pluginPermissions: declaredPermissions,
156
+ ownerWildcardExclusions: collectOwnerWildcardExclusions(survivors),
157
+ })
158
+
159
+ // Non-fatal: surface user-declared `permissions[]` strings that aren't in
160
+ // the known set, so a typo like `security.bypass.secretExfilBach` is
161
+ // visible at boot rather than silently failing to bypass the matching
162
+ // guard. We log instead of throw because the runtime still functions --
163
+ // the unknown string just never matches anything. Run AFTER finalization so
164
+ // a failed plugin's declarations don't make a role's permission look known.
165
+ for (const warning of findUnknownPermissions(opts.roles, declaredPermissions)) {
166
+ console.warn(
167
+ `[permissions] role "${warning.role}" declares unknown permission "${warning.permission}" — ${warning.hint}`,
168
+ )
169
+ }
170
+
155
171
  return {
156
172
  registry,
157
173
  hooks,
158
174
  permissions,
159
175
  declaredPermissions,
160
176
  loadedPlugins: loaded,
177
+ failedPlugins: failed,
161
178
  markBooted: () => {
162
179
  booted = true
163
180
  },
@@ -167,6 +184,93 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
167
184
  }
168
185
  }
169
186
 
187
+ // Tags WHICH sub-phase failed (for the failedPlugins report) while preserving
188
+ // the ORIGINAL error so the caller can keep PluginSecurityError fatal even when
189
+ // it surfaces deep inside registration.
190
+ class PluginPhaseError extends Error {
191
+ readonly phase: FailedPlugin['phase']
192
+ readonly detail: string
193
+ readonly original: unknown
194
+ constructor(phase: FailedPlugin['phase'], detail: string, original: unknown) {
195
+ super(detail)
196
+ this.name = 'PluginPhaseError'
197
+ this.phase = phase
198
+ this.detail = detail
199
+ this.original = original
200
+ }
201
+ }
202
+
203
+ type RegisterOnePluginArgs = {
204
+ resolved: ResolvedPlugin
205
+ config: unknown
206
+ agentDir: string
207
+ registry: PluginRegistry
208
+ hooks: HookBus
209
+ permissions: PermissionService
210
+ ctxDeps: {
211
+ resolveGithubTokenForRepo?: ResolveGithubTokenForRepo
212
+ hasGithubAppTokenResolver?: () => boolean
213
+ spawnSubagent: SpawnSubagentFn
214
+ isBooted: () => boolean
215
+ }
216
+ }
217
+
218
+ async function registerOnePlugin(args: RegisterOnePluginArgs): Promise<void> {
219
+ const { resolved, registry, hooks } = args
220
+
221
+ let validatedConfig: unknown = undefined
222
+ if (resolved.defined.configSchema) {
223
+ const parsed = (resolved.defined.configSchema as z.ZodType<unknown>).safeParse(args.config ?? {})
224
+ if (!parsed.success) {
225
+ const message = `plugin ${resolved.name}: config invalid: ${formatZodIssues(parsed.error)}`
226
+ throw new PluginPhaseError('config', message, new Error(message))
227
+ }
228
+ validatedConfig = parsed.data
229
+ } else if (args.config !== undefined) {
230
+ const message = `plugin ${resolved.name}: config block "${resolved.name}" present in typeclaw.json but plugin declares no configSchema`
231
+ throw new PluginPhaseError('config', message, new Error(message))
232
+ }
233
+
234
+ const logger = createPluginLogger(resolved.name)
235
+ const ctx = createPluginContext({
236
+ name: resolved.name,
237
+ version: resolved.version,
238
+ agentDir: args.agentDir,
239
+ config: validatedConfig as never,
240
+ logger,
241
+ permissions: args.permissions,
242
+ resolveGithubTokenForRepo: args.ctxDeps.resolveGithubTokenForRepo,
243
+ hasGithubAppTokenResolver: args.ctxDeps.hasGithubAppTokenResolver,
244
+ spawnSubagent: args.ctxDeps.spawnSubagent,
245
+ isBooted: args.ctxDeps.isBooted,
246
+ })
247
+
248
+ let exports: PluginExports
249
+ try {
250
+ exports = await resolved.defined.plugin(ctx)
251
+ } catch (err) {
252
+ discardRegistrationsBy(resolved.name, registry, hooks)
253
+ const message = `plugin ${resolved.name}: factory threw: ${err instanceof Error ? err.message : String(err)}`
254
+ throw new PluginPhaseError('factory', message, err)
255
+ }
256
+
257
+ try {
258
+ registerContributions({
259
+ pluginName: resolved.name,
260
+ logger,
261
+ exports,
262
+ ...(resolved.defined.commands !== undefined ? { commands: resolved.defined.commands } : {}),
263
+ registry,
264
+ hooks,
265
+ agentDir: args.agentDir,
266
+ pluginConfig: validatedConfig,
267
+ })
268
+ } catch (err) {
269
+ discardRegistrationsBy(resolved.name, registry, hooks)
270
+ throw new PluginPhaseError('register', err instanceof Error ? err.message : String(err), err)
271
+ }
272
+ }
273
+
170
274
  function collectDeclaredPermissions(
171
275
  plugins: readonly { entry: string; resolved: ResolvedPlugin }[],
172
276
  ): readonly string[] {
@@ -25,9 +25,29 @@ export type CodexFetchObserverOptions = {
25
25
  ttfbMs?: number
26
26
  // Override the sliding inter-chunk idle deadline applied to the SSE body
27
27
  // reader. Resets on every chunk; if no bytes arrive within this window the
28
- // body stream errors. Default: 300_000 ms, matches `openai/codex`'s Rust CLI
29
- // `DEFAULT_STREAM_IDLE_TIMEOUT_MS`. Set to 0 to disable just this timer.
28
+ // body stream errors. Like the overall deadline, this doubles as a recovery
29
+ // bound: on a silent stall the user waits this long before the retry fires,
30
+ // so it should not exceed the overall ceiling. Default 120_000 ms (was
31
+ // 300_000, which matched `openai/codex`'s Rust CLI but is 5min of dead air
32
+ // before recovery). 120s is loose enough for OpenAI's keepalive-less
33
+ // reasoning pauses (the Responses API sends no SSE heartbeats, so a quiet
34
+ // reasoning window is genuinely byte-silent) while bounded by the overall
35
+ // cap. Set to 0 to disable just this timer.
30
36
  idleMs?: number
37
+ // Override the absolute wall-clock ceiling on a single Codex request,
38
+ // measured from fetch start to body completion. Unlike `idleMs`, it does NOT
39
+ // reset on chunk arrival, so it catches a "slow-trickle" stream that emits
40
+ // bytes inside every idle window yet never reaches a terminal SSE event —
41
+ // the failure mode behind issue #394's multi-minute hang (one observed
42
+ // request occupied 901s before Bun's OS socket deadline fired). On expiry the
43
+ // request is aborted with a retryable error, so this also bounds how long a
44
+ // user waits before the retry fires — keeping it low is a UX requirement, not
45
+ // just a safety net. Default 120_000 ms: across 96 observed requests the
46
+ // slowest *healthy* (completed) one was 45s and p99 was ~30s, with a clean
47
+ // gap up to the 901s hang — so 120s is ~2.7x the healthy max (ample headroom
48
+ // for PoP/TLS outliers) while capping a real hang at ~2min instead of ~15min.
49
+ // Set to 0 to disable just this timer.
50
+ overallMs?: number
31
51
  // Schedule fn for tests. Receives (delayMs, callback) and returns a handle
32
52
  // the wrapper can pass to `clear`. Default: `setTimeout`/`clearTimeout`.
33
53
  scheduler?: TimeoutScheduler
@@ -44,8 +64,10 @@ const ENV_DISABLE_OBSERVER = 'TYPECLAW_CODEX_FETCH_OBSERVER'
44
64
  const ENV_DISABLE_TIMEOUTS = 'TYPECLAW_CODEX_TIMEOUTS'
45
65
  const ENV_TTFB_MS = 'TYPECLAW_CODEX_TTFB_MS'
46
66
  const ENV_IDLE_MS = 'TYPECLAW_CODEX_IDLE_MS'
67
+ const ENV_OVERALL_MS = 'TYPECLAW_CODEX_OVERALL_MS'
47
68
  const DEFAULT_TTFB_MS = 15_000
48
- const DEFAULT_IDLE_MS = 300_000
69
+ const DEFAULT_IDLE_MS = 120_000
70
+ const DEFAULT_OVERALL_MS = 120_000
49
71
  const LOG_PREFIX = '[codex-fetch]'
50
72
 
51
73
  const defaultScheduler: TimeoutScheduler = {
@@ -126,6 +148,7 @@ function readEnvMs(name: string, fallback: number): number {
126
148
 
127
149
  type BodyTapConfig = {
128
150
  idleMs: number
151
+ overallMs: number
129
152
  scheduler: TimeoutScheduler
130
153
  }
131
154
 
@@ -193,17 +216,44 @@ function attachBodyTimingTap(
193
216
 
194
217
  const piped = response.body.pipeThrough(tap, { preventCancel: false })
195
218
 
196
- const idleController = config.idleMs > 0 ? new AbortController() : null
219
+ const idleController = config.idleMs > 0 || config.overallMs > 0 ? new AbortController() : null
197
220
  let idleHandle: unknown = null
198
221
  const armIdleTimer = () => {
199
- if (idleController === null) return
222
+ if (idleController === null || config.idleMs <= 0) return
200
223
  if (idleHandle !== null) config.scheduler.clear(idleHandle)
201
224
  idleHandle = config.scheduler.set(config.idleMs, () => {
202
225
  cause = 'idle_timeout'
203
226
  idleController.abort(new Error(`Codex SSE body idle for ${config.idleMs}ms (typeclaw observer timeout)`))
204
227
  })
205
228
  }
229
+
230
+ // Absolute ceiling on the whole request, armed once and never reset. The
231
+ // budget is measured from `start` (before originalFetch), so the time already
232
+ // spent waiting for headers is subtracted here — otherwise a slow-headers
233
+ // request would get a fresh full `overallMs` for its body on top of the
234
+ // headers wait, doubling the intended ceiling. A non-positive remainder means
235
+ // the budget is already spent, so we schedule at 0 to abort on the next tick.
236
+ // Aborts the shared controller so the existing reader race tears the stream
237
+ // down on the first deadline to fire — idle or overall, whichever comes first.
238
+ let overallHandle: unknown = null
239
+ if (idleController !== null && config.overallMs > 0) {
240
+ const remainingOverallMs = Math.max(0, config.overallMs - (now() - start))
241
+ overallHandle = config.scheduler.set(remainingOverallMs, () => {
242
+ cause = 'overall_timeout'
243
+ idleController.abort(
244
+ new Error(`Codex SSE body exceeded overall deadline of ${config.overallMs}ms (typeclaw observer timeout)`),
245
+ )
246
+ })
247
+ }
248
+ const disarmOverallTimer = () => {
249
+ if (overallHandle !== null) {
250
+ config.scheduler.clear(overallHandle)
251
+ overallHandle = null
252
+ }
253
+ }
254
+
206
255
  const disarmIdleTimer = () => {
256
+ disarmOverallTimer()
207
257
  if (idleHandle !== null) {
208
258
  config.scheduler.clear(idleHandle)
209
259
  idleHandle = null
@@ -295,6 +345,7 @@ export function installCodexFetchObserver(opts: CodexFetchObserverOptions = {}):
295
345
  const timeoutsEnabled = process.env[ENV_DISABLE_TIMEOUTS] !== 'off'
296
346
  const ttfbMs = timeoutsEnabled ? (opts.ttfbMs ?? readEnvMs(ENV_TTFB_MS, DEFAULT_TTFB_MS)) : 0
297
347
  const idleMs = timeoutsEnabled ? (opts.idleMs ?? readEnvMs(ENV_IDLE_MS, DEFAULT_IDLE_MS)) : 0
348
+ const overallMs = timeoutsEnabled ? (opts.overallMs ?? readEnvMs(ENV_OVERALL_MS, DEFAULT_OVERALL_MS)) : 0
298
349
  const originalFetch = globalThis.fetch
299
350
 
300
351
  const wrappedImpl = async (
@@ -352,6 +403,7 @@ export function installCodexFetchObserver(opts: CodexFetchObserverOptions = {}):
352
403
  const requestId = response.headers.get('x-request-id')
353
404
  return attachBodyTimingTap(response, start, headersMs, response.status, retryAfter, requestId, now, logger, {
354
405
  idleMs,
406
+ overallMs,
355
407
  scheduler,
356
408
  })
357
409
  }
package/src/run/index.ts CHANGED
@@ -321,6 +321,7 @@ export async function startAgent({
321
321
  ? { originatingSessionFile: ctx.originatingSessionFile }
322
322
  : {}),
323
323
  handoffOrigin: ctx.handoffOrigin,
324
+ ...(ctx.triggeringAuthorId !== undefined ? { triggeringAuthorId: ctx.triggeringAuthorId } : {}),
324
325
  }
325
326
  : {}),
326
327
  })
@@ -701,6 +702,10 @@ export async function startAgent({
701
702
  console.log(`[plugin] loaded ${summarizeLoaded(pluginsLoaded.loadedPlugins, pluginRegistry)}`)
702
703
  }
703
704
 
705
+ for (const f of pluginsLoaded.failedPlugins) {
706
+ console.warn(`[plugin] DEGRADED: "${f.entry}" disabled (${f.phase}): ${f.error}`)
707
+ }
708
+
704
709
  // Container-side portbroker is instantiated only when the host plumbed a
705
710
  // broker token in via env var. Outside the container (tests, ad-hoc dev
706
711
  // runs), the env var is absent and the broker stays off — same fence as
@@ -142,8 +142,19 @@ export type SandboxPolicy = {
142
142
  // guard: the container env holds FIREWORKS_API_KEY and GH_TOKEN, and env
143
143
  // inheritance is the single highest-risk exfil path for prompt-injected bash.
144
144
  // HOME points at /tmp because the sandbox mounts /tmp as a fresh tmpfs.
145
+ //
146
+ // BUN_TMPDIR / BUN_INSTALL both point under /tmp because `--clearenv` strips
147
+ // the host's TMPDIR, and bun refuses to run without a writable scratch dir it
148
+ // can discover: `bunx`, `bun add`, and `bun run <pkg-bin>` abort with
149
+ // "Unexpected accessing temporary directory. Please set $BUN_TMPDIR or
150
+ // $BUN_INSTALL". /tmp is always writable inside the sandbox (fresh tmpfs, or
151
+ // the per-session bind that overrides it), so both are safe targets. Without
152
+ // these, every sandboxed bun invocation — the core subagent install path —
153
+ // fails before it starts.
145
154
  export const DEFAULT_SANDBOX_ENV: Record<string, string> = {
146
155
  PATH: '/usr/local/bin:/usr/bin:/bin',
147
156
  HOME: '/tmp',
148
157
  LANG: 'C.UTF-8',
158
+ BUN_TMPDIR: '/tmp',
159
+ BUN_INSTALL: '/tmp/.bun',
149
160
  }