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.
- package/package.json +2 -2
- package/src/agent/index.ts +11 -0
- package/src/agent/restart/index.ts +6 -0
- package/src/agent/restart-handoff/index.ts +10 -0
- package/src/agent/tools/restart.ts +9 -0
- package/src/bundled-plugins/backup/README.md +11 -2
- package/src/bundled-plugins/backup/git-auth.ts +58 -0
- package/src/bundled-plugins/backup/index.ts +54 -0
- package/src/bundled-plugins/backup/runner.ts +82 -12
- package/src/channels/adapters/discord-bot-reactions.ts +1 -0
- package/src/channels/manager.ts +15 -3
- package/src/channels/router.ts +67 -16
- package/src/cli/hostd.ts +37 -4
- package/src/cli/ui.ts +6 -0
- package/src/container/start.ts +6 -0
- package/src/init/reconcile-plugin-deps.ts +45 -15
- package/src/init/restart-deps-preflight.ts +155 -0
- package/src/permissions/permissions.ts +24 -4
- package/src/plugin/loader.ts +16 -4
- package/src/plugin/manager.ts +175 -71
- package/src/run/codex-fetch-observer.ts +57 -5
- package/src/run/index.ts +5 -0
- package/src/sandbox/policy.ts +11 -0
package/src/plugin/manager.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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
|
|
65
|
-
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
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:
|
|
80
|
-
ownerWildcardExclusions,
|
|
102
|
+
pluginPermissions: collectDeclaredPermissions(bundledPlugins),
|
|
103
|
+
ownerWildcardExclusions: collectOwnerWildcardExclusions(bundledPlugins),
|
|
81
104
|
})
|
|
82
105
|
|
|
83
|
-
|
|
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
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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.
|
|
29
|
-
//
|
|
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 =
|
|
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
|
package/src/sandbox/policy.ts
CHANGED
|
@@ -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
|
}
|