typeclaw 0.29.0 → 0.30.1
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 +1 -1
- package/scripts/verify-realproc-sandbox.sh +58 -0
- package/src/agent/index.ts +6 -0
- package/src/agent/live-subagents.ts +5 -0
- package/src/agent/plugin-tools.ts +79 -10
- package/src/agent/subagent-drain.ts +150 -0
- package/src/agent/subagents.ts +34 -3
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tools/spawn-subagent.ts +13 -1
- package/src/bundled-plugins/bun-hygiene/README.md +12 -11
- package/src/bundled-plugins/bun-hygiene/policy.ts +8 -3
- package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +116 -35
- package/src/bundled-plugins/github-cli-auth/effective-approval.ts +14 -9
- package/src/bundled-plugins/github-cli-auth/index.ts +3 -3
- package/src/bundled-plugins/planner/planner.ts +2 -1
- package/src/bundled-plugins/researcher/researcher.ts +9 -2
- package/src/bundled-plugins/reviewer/reviewer.ts +2 -1
- package/src/channels/adapters/discord-bot-format.ts +191 -0
- package/src/channels/adapters/discord-bot.ts +2 -1
- package/src/channels/adapters/github/inbound.ts +88 -30
- package/src/channels/adapters/github/review-state.ts +27 -0
- package/src/channels/github-review-claim.ts +15 -3
- package/src/channels/outbound-flood-filter.ts +70 -3
- package/src/channels/router.ts +53 -0
- package/src/compose/discover.ts +5 -1
- package/src/config/config.ts +38 -0
- package/src/container/start.ts +14 -0
- package/src/migrations/index.ts +35 -0
- package/src/migrations/secrets-v1-to-v2.ts +344 -0
- package/src/run/index.ts +13 -0
- package/src/sandbox/availability.ts +12 -0
- package/src/sandbox/build.ts +53 -9
- package/src/sandbox/index.ts +1 -1
- package/src/sandbox/policy.ts +17 -1
- package/typeclaw.schema.json +24 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { chmodSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import lockfile from 'proper-lockfile'
|
|
5
|
+
|
|
6
|
+
import { parseSecretsFile, SECRETS_FILE_VERSION } from '@/secrets/schema'
|
|
7
|
+
|
|
8
|
+
// PR #638 removed the in-memory v1->v2 upgrade that `parseSecretsFile` used to
|
|
9
|
+
// perform, so a `secrets.json` still in v1 now fails to parse:
|
|
10
|
+
// `hydrateChannelEnvFromSecrets` swallows the failure as `{}`, no token env vars
|
|
11
|
+
// are injected, and channel adapters (Discord, Slack, Telegram) never connect.
|
|
12
|
+
// This is the one-shot on-disk replacement, run once at boot rather than on
|
|
13
|
+
// every parse, so the v2-only runtime keeps working without a read-time shim.
|
|
14
|
+
|
|
15
|
+
const SCHEMA_REL = './node_modules/typeclaw/secrets.schema.json'
|
|
16
|
+
const FILE_MODE = 0o600
|
|
17
|
+
|
|
18
|
+
const LEGACY_FILENAME = 'auth.json'
|
|
19
|
+
const TARGET_FILENAME = 'secrets.json'
|
|
20
|
+
|
|
21
|
+
// Frozen, migration-local reverse map (env-var name -> { adapterId, field }).
|
|
22
|
+
// Intentionally a private copy rather than an inversion of
|
|
23
|
+
// `CHANNEL_FIELD_ENV` in src/secrets/defaults.ts: re-importing live runtime
|
|
24
|
+
// defaults would (a) re-couple current code to deleted legacy surface area,
|
|
25
|
+
// and (b) let a future change to the runtime env-var names silently rewrite
|
|
26
|
+
// the semantics of this historical migration. A v1 file written years ago must
|
|
27
|
+
// migrate the same way regardless of what the live adapters key off today.
|
|
28
|
+
const LEGACY_CHANNEL_ENV_TO_FIELD: Record<string, { adapterId: string; field: string }> = {
|
|
29
|
+
DISCORD_BOT_TOKEN: { adapterId: 'discord-bot', field: 'token' },
|
|
30
|
+
SLACK_BOT_TOKEN: { adapterId: 'slack-bot', field: 'botToken' },
|
|
31
|
+
SLACK_APP_TOKEN: { adapterId: 'slack-bot', field: 'appToken' },
|
|
32
|
+
TELEGRAM_BOT_TOKEN: { adapterId: 'telegram-bot', field: 'token' },
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const MIGRATION_ID = '0001-secrets-v1-to-v2'
|
|
36
|
+
|
|
37
|
+
export type SecretsMigrationResult = { changed: boolean; summary: string }
|
|
38
|
+
|
|
39
|
+
// Idempotent: a folder already at v2 (or with no legacy file) returns
|
|
40
|
+
// `changed: false`. Errors that indicate ambiguous/unsafe state throw with an
|
|
41
|
+
// actionable message rather than guessing.
|
|
42
|
+
//
|
|
43
|
+
// Concurrency: secrets.json is the lock resource SecretsBackend (provider add,
|
|
44
|
+
// OAuth refresh, channel add) and credential exporters use, so we hold ITS lock
|
|
45
|
+
// across the entire precedence resolution AND upgrade. The lock requires the
|
|
46
|
+
// file to exist, so when only auth.json is present we first seed secrets.json
|
|
47
|
+
// with exclusive create-if-absent semantics (never overwriting a file a
|
|
48
|
+
// concurrent writer may have just written), then lock, then re-read precedence
|
|
49
|
+
// from fresh on-disk state under the lock.
|
|
50
|
+
export function migrateSecretsV1ToV2(agentDir: string): SecretsMigrationResult {
|
|
51
|
+
const legacyPath = join(agentDir, LEGACY_FILENAME)
|
|
52
|
+
const targetPath = join(agentDir, TARGET_FILENAME)
|
|
53
|
+
|
|
54
|
+
if (!existsSync(legacyPath) && !existsSync(targetPath)) {
|
|
55
|
+
return { changed: false, summary: 'no secrets file to migrate' }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
seedTargetIfAbsent(targetPath)
|
|
59
|
+
|
|
60
|
+
return withFileLock(targetPath, () => {
|
|
61
|
+
resolvePrecedenceUnderLock(legacyPath, targetPath)
|
|
62
|
+
return upgradeFileInPlace(targetPath)
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Creates an empty v2 envelope at secrets.json only if it does not already
|
|
67
|
+
// exist, using exclusive create ('wx') so a concurrent writer that wrote real
|
|
68
|
+
// credentials between our existsSync check and here is never clobbered — the
|
|
69
|
+
// EEXIST is swallowed because the file we need to lock now exists, which is all
|
|
70
|
+
// we required. A freshly-seeded empty envelope is indistinguishable from "no
|
|
71
|
+
// target" to resolvePrecedenceUnderLock (isEmptyEnvelope returns true), so
|
|
72
|
+
// "only auth.json" collapses into the "secrets.json empty -> auth wins" branch.
|
|
73
|
+
function seedTargetIfAbsent(targetPath: string): void {
|
|
74
|
+
if (existsSync(targetPath)) return
|
|
75
|
+
try {
|
|
76
|
+
writeFileSync(targetPath, stringifyEmptyEnvelope(), { encoding: 'utf8', mode: FILE_MODE, flag: 'wx' })
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// auth.json precedence, run ENTIRELY under the secrets.json lock so the read,
|
|
83
|
+
// the rename/unlink decision, and the rename itself can't interleave with a
|
|
84
|
+
// concurrent secrets.json writer. Preserves the deleted migrateLegacyAuthJson
|
|
85
|
+
// semantics so no credential is ever silently dropped:
|
|
86
|
+
// - no auth.json -> operate on secrets.json as-is
|
|
87
|
+
// - droppable auth.json -> unlink auth.json, operate on secrets.json
|
|
88
|
+
// - secrets.json empty seed -> auth.json wins (rename over the empty seed)
|
|
89
|
+
// - both non-empty -> hard error (can't pick a source of truth)
|
|
90
|
+
function resolvePrecedenceUnderLock(legacyPath: string, targetPath: string): void {
|
|
91
|
+
if (!existsSync(legacyPath)) return
|
|
92
|
+
|
|
93
|
+
if (isDroppableLegacyFile(legacyPath)) {
|
|
94
|
+
unlinkSync(legacyPath)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (isEmptyEnvelope(targetPath)) {
|
|
99
|
+
renameWithRaceFallback(legacyPath, targetPath)
|
|
100
|
+
chmodSync(targetPath, FILE_MODE)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Both ${LEGACY_FILENAME} and a non-empty ${TARGET_FILENAME} exist in the agent folder. ` +
|
|
106
|
+
`Inspect manually and remove the stale file before re-running.`,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function upgradeFileInPlace(path: string): SecretsMigrationResult {
|
|
111
|
+
let raw: string
|
|
112
|
+
try {
|
|
113
|
+
raw = readFileSync(path, 'utf8')
|
|
114
|
+
} catch {
|
|
115
|
+
return { changed: false, summary: 'secrets file unreadable; skipped' }
|
|
116
|
+
}
|
|
117
|
+
if (raw.trim() === '') return { changed: false, summary: 'secrets file empty; skipped' }
|
|
118
|
+
|
|
119
|
+
let parsed: unknown
|
|
120
|
+
try {
|
|
121
|
+
parsed = JSON.parse(raw)
|
|
122
|
+
} catch (err) {
|
|
123
|
+
throw new Error(`secrets file is not valid JSON: ${err instanceof Error ? err.message : String(err)}`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Already current: parseSecretsFile only accepts v2 post-#638, so a successful
|
|
127
|
+
// parse means there is nothing to do.
|
|
128
|
+
if (parseSecretsFile(parsed).ok) return { changed: false, summary: 'already v2; no change' }
|
|
129
|
+
|
|
130
|
+
const upgraded = upgradeToV2(parsed)
|
|
131
|
+
if (upgraded === null) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
'secrets file is neither a valid v2 envelope nor a recognized legacy (v1 / pre-envelope) shape; ' +
|
|
134
|
+
'leaving it untouched for manual inspection',
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Re-validate the product of our own transform before persisting. A transform
|
|
139
|
+
// that emitted an invalid v2 file would brick the next read; failing here is
|
|
140
|
+
// strictly safer than writing garbage.
|
|
141
|
+
const check = parseSecretsFile(upgraded)
|
|
142
|
+
if (!check.ok) {
|
|
143
|
+
throw new Error(`internal: migrated secrets file failed v2 validation: ${check.reason}`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
writeEnvelopeAtomic(path, check.file)
|
|
147
|
+
return { changed: true, summary: `upgraded secrets file to v${SECRETS_FILE_VERSION}` }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Recognizes the two pre-v2 shapes the deleted parseSecretsFile branches used
|
|
151
|
+
// to accept and returns a v2-shaped object. Returns null when the input matches
|
|
152
|
+
// neither (caller turns that into a loud, no-write error).
|
|
153
|
+
//
|
|
154
|
+
// v1 envelope: { version: 1, llm: {...}, channels: { adapter: { ENV: value } } }
|
|
155
|
+
// pre-envelope flat: { providerId: { type, key } } at the top level
|
|
156
|
+
function upgradeToV2(raw: unknown): Record<string, unknown> | null {
|
|
157
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null
|
|
158
|
+
const obj = raw as Record<string, unknown>
|
|
159
|
+
|
|
160
|
+
if (obj.version === 1) {
|
|
161
|
+
return upgradeV1Envelope(obj)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (looksLikeFlatProviders(obj)) {
|
|
165
|
+
return upgradeV1Envelope({ version: 1, llm: obj, channels: {} })
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function upgradeV1Envelope(obj: Record<string, unknown>): Record<string, unknown> {
|
|
172
|
+
const llm = isPlainObject(obj.llm) ? obj.llm : {}
|
|
173
|
+
const legacyChannels = isPlainObject(obj.channels) ? obj.channels : {}
|
|
174
|
+
|
|
175
|
+
const providers: Record<string, unknown> = {}
|
|
176
|
+
for (const [providerId, cred] of Object.entries(llm)) {
|
|
177
|
+
if (!isPlainObject(cred)) continue
|
|
178
|
+
if (cred.type === 'api_key' && typeof cred.key === 'string') {
|
|
179
|
+
providers[providerId] = { type: 'api_key', key: { value: cred.key } }
|
|
180
|
+
} else {
|
|
181
|
+
// OAuth and any unknown credential type pass through verbatim — they are
|
|
182
|
+
// not env-injectable and the v2 schema accepts them via catchall.
|
|
183
|
+
providers[providerId] = cred
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const channels: Record<string, Record<string, unknown>> = {}
|
|
188
|
+
for (const [adapterId, slot] of Object.entries(legacyChannels)) {
|
|
189
|
+
if (!isPlainObject(slot)) continue
|
|
190
|
+
const upgradedSlot: Record<string, unknown> = {}
|
|
191
|
+
for (const [key, value] of Object.entries(slot)) {
|
|
192
|
+
if (typeof value !== 'string') {
|
|
193
|
+
// A non-string value means this isn't the flat env-keyed v1 channel
|
|
194
|
+
// shape (e.g. a kakaotalk block, which is structured). Preserve it
|
|
195
|
+
// verbatim so the catchall keeps it valid; do not try to reshape.
|
|
196
|
+
upgradedSlot[key] = value
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
const mapping = LEGACY_CHANNEL_ENV_TO_FIELD[key]
|
|
200
|
+
if (mapping && mapping.adapterId === adapterId) {
|
|
201
|
+
upgradedSlot[mapping.field] = { value }
|
|
202
|
+
} else {
|
|
203
|
+
// Unknown env-var key on a known adapter, or an unknown adapter:
|
|
204
|
+
// preserve under the original key but still wrap as a v2 Secret so the
|
|
205
|
+
// resulting file is valid v2.
|
|
206
|
+
upgradedSlot[key] = { value }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
channels[adapterId] = upgradedSlot
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const result: Record<string, unknown> = {
|
|
213
|
+
$schema: typeof obj.$schema === 'string' ? obj.$schema : SCHEMA_REL,
|
|
214
|
+
version: SECRETS_FILE_VERSION,
|
|
215
|
+
providers,
|
|
216
|
+
channels,
|
|
217
|
+
}
|
|
218
|
+
return result
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// A flat pre-envelope file is a top-level record of provider credentials. Every
|
|
222
|
+
// value must be a credential object with a `type` field; anything else means we
|
|
223
|
+
// don't recognize the shape and should not guess.
|
|
224
|
+
function looksLikeFlatProviders(obj: Record<string, unknown>): boolean {
|
|
225
|
+
const entries = Object.entries(obj).filter(([k]) => k !== '$schema')
|
|
226
|
+
if (entries.length === 0) return false
|
|
227
|
+
return entries.every(([, value]) => isPlainObject(value) && typeof value.type === 'string')
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isEmptyEnvelope(path: string): boolean {
|
|
231
|
+
const parsed = readJsonOrNull(path)
|
|
232
|
+
if (parsed === undefined) return true
|
|
233
|
+
if (parsed === null) return false
|
|
234
|
+
const result = parseSecretsFile(parsed)
|
|
235
|
+
if (!result.ok) return false
|
|
236
|
+
return Object.keys(result.file.providers).length === 0 && Object.keys(result.file.channels).length === 0
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// True only when a legacy auth.json carries nothing worth keeping, so dropping
|
|
240
|
+
// it in favor of an existing secrets.json is safe: a missing/blank file, or a
|
|
241
|
+
// valid-but-empty v2 envelope. Anything else parseable — a legacy shape with
|
|
242
|
+
// credentials OR a parseable-but-unrecognized object — returns false so
|
|
243
|
+
// resolveLegacyFilename falls through to the both-non-empty hard error rather
|
|
244
|
+
// than silently deleting a file whose contents we can't account for.
|
|
245
|
+
function isDroppableLegacyFile(path: string): boolean {
|
|
246
|
+
const parsed = readJsonOrNull(path)
|
|
247
|
+
if (parsed === undefined) return true
|
|
248
|
+
if (parsed === null) return false
|
|
249
|
+
const v2 = parseSecretsFile(parsed)
|
|
250
|
+
if (!v2.ok) return false
|
|
251
|
+
return Object.keys(v2.file.providers).length === 0 && Object.keys(v2.file.channels).length === 0
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// undefined = file missing/blank (treat as empty); null = present but invalid
|
|
255
|
+
// JSON (treat as "has content we can't safely drop").
|
|
256
|
+
function readJsonOrNull(path: string): unknown {
|
|
257
|
+
let raw: string
|
|
258
|
+
try {
|
|
259
|
+
raw = readFileSync(path, 'utf8')
|
|
260
|
+
} catch {
|
|
261
|
+
return undefined
|
|
262
|
+
}
|
|
263
|
+
if (raw.trim() === '') return undefined
|
|
264
|
+
try {
|
|
265
|
+
return JSON.parse(raw)
|
|
266
|
+
} catch {
|
|
267
|
+
return null
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function stringifyEmptyEnvelope(): string {
|
|
272
|
+
return `${JSON.stringify({ $schema: SCHEMA_REL, version: SECRETS_FILE_VERSION, providers: {}, channels: {} }, null, 2)}\n`
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function writeEnvelopeAtomic(path: string, envelope: unknown): void {
|
|
276
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
|
|
277
|
+
writeFileSync(tmp, `${JSON.stringify(envelope, null, 2)}\n`, { encoding: 'utf8', mode: FILE_MODE })
|
|
278
|
+
try {
|
|
279
|
+
renameSync(tmp, path)
|
|
280
|
+
} catch (err) {
|
|
281
|
+
try {
|
|
282
|
+
unlinkSync(tmp)
|
|
283
|
+
} catch {
|
|
284
|
+
// best-effort cleanup of the temp file when rename fails
|
|
285
|
+
}
|
|
286
|
+
throw err
|
|
287
|
+
}
|
|
288
|
+
chmodSync(path, FILE_MODE)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// renameSync is atomic per syscall, but two concurrent migration runs can both
|
|
292
|
+
// observe auth.json exists and secrets.json does not, then race on the rename.
|
|
293
|
+
// One wins; the loser gets ENOENT because the source is already gone — that is
|
|
294
|
+
// a successful migration from its POV, so recheck the target and swallow it.
|
|
295
|
+
function renameWithRaceFallback(from: string, to: string): void {
|
|
296
|
+
try {
|
|
297
|
+
renameSync(from, to)
|
|
298
|
+
} catch (err) {
|
|
299
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT' && existsSync(to)) return
|
|
300
|
+
throw err
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Mirror SecretsBackend's lock discipline so a concurrent credential write
|
|
305
|
+
// (provider add, OAuth refresh, channel add) can't interleave with the
|
|
306
|
+
// read-transform-write. proper-lockfile needs the target to exist; the target
|
|
307
|
+
// always exists by the time we lock (resolveLegacyFilename guarantees it).
|
|
308
|
+
function withFileLock<T>(path: string, fn: () => T): T {
|
|
309
|
+
let release: (() => void) | undefined
|
|
310
|
+
try {
|
|
311
|
+
release = acquireSyncLockWithRetry(path)
|
|
312
|
+
return fn()
|
|
313
|
+
} finally {
|
|
314
|
+
release?.()
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const SYNC_LOCK_RETRIES = 10
|
|
319
|
+
const SYNC_LOCK_DELAY_MS = 20
|
|
320
|
+
|
|
321
|
+
function acquireSyncLockWithRetry(path: string): () => void {
|
|
322
|
+
let lastError: unknown
|
|
323
|
+
for (let attempt = 1; attempt <= SYNC_LOCK_RETRIES; attempt++) {
|
|
324
|
+
try {
|
|
325
|
+
return lockfile.lockSync(path, { realpath: false })
|
|
326
|
+
} catch (error) {
|
|
327
|
+
const code =
|
|
328
|
+
typeof error === 'object' && error !== null && 'code' in error
|
|
329
|
+
? String((error as { code: unknown }).code)
|
|
330
|
+
: undefined
|
|
331
|
+
if (code !== 'ELOCKED' || attempt === SYNC_LOCK_RETRIES) throw error
|
|
332
|
+
lastError = error
|
|
333
|
+
const start = Date.now()
|
|
334
|
+
while (Date.now() - start < SYNC_LOCK_DELAY_MS) {
|
|
335
|
+
// intentionally empty: synchronous busy-wait to match SecretsBackend
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
throw (lastError as Error | undefined) ?? new Error('Failed to acquire secrets store lock')
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
343
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
344
|
+
}
|
package/src/run/index.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
} from '@/cron'
|
|
43
43
|
import { CLI_VERSION } from '@/init/cli-version'
|
|
44
44
|
import { createMcpManager } from '@/mcp'
|
|
45
|
+
import { runStartupMigrations } from '@/migrations'
|
|
45
46
|
import { loadPlugins, type LoadPluginsResult, pluginCronJobs, type PluginRegistry, summarizeLoaded } from '@/plugin'
|
|
46
47
|
import { createPluginLogger } from '@/plugin/context'
|
|
47
48
|
import type { CronHandlerContext } from '@/plugin/types'
|
|
@@ -194,6 +195,12 @@ export async function startAgent({
|
|
|
194
195
|
materializedSkills: null,
|
|
195
196
|
})
|
|
196
197
|
|
|
198
|
+
// Graduate any pre-0.20.0 on-disk shapes (v1 secrets.json, legacy auth.json)
|
|
199
|
+
// to the current v2 envelope before anything reads secrets — otherwise the
|
|
200
|
+
// v2-only parser rejects the file and hydrate below sees no channels. Runs
|
|
201
|
+
// exactly once per folder; a folder already at v2 is a no-op.
|
|
202
|
+
runStartupMigrations(cwd)
|
|
203
|
+
|
|
197
204
|
// Channel adapters read `process.env[TOKEN_ENV]` (see channels/manager.ts).
|
|
198
205
|
// Hydrate fills any unset env var from secrets.json#channels via env-wins:
|
|
199
206
|
// values already in process.env (from `docker --env-file .env`) are kept
|
|
@@ -329,6 +336,8 @@ export async function startAgent({
|
|
|
329
336
|
...(subagentOptions?.spawnedByRole !== undefined ? { spawnedByRole: subagentOptions.spawnedByRole } : {}),
|
|
330
337
|
...(subagentOptions?.spawnedByOrigin !== undefined ? { spawnedByOrigin: subagentOptions.spawnedByOrigin } : {}),
|
|
331
338
|
}
|
|
339
|
+
const allowBackgroundFromSubagent =
|
|
340
|
+
entry.pluginSubagent.canBackgroundSpawnSubagents === true && entry.pluginSubagent.canSpawnSubagents === true
|
|
332
341
|
const created = await createSessionWithDispose({
|
|
333
342
|
systemPromptOverride: entry.pluginSubagent.systemPrompt,
|
|
334
343
|
sessionManager,
|
|
@@ -359,6 +368,7 @@ export async function startAgent({
|
|
|
359
368
|
liveSubagentRegistry,
|
|
360
369
|
subagentRegistry: snap.subagents,
|
|
361
370
|
createSessionForSubagent,
|
|
371
|
+
allowBackgroundFromSubagent,
|
|
362
372
|
}
|
|
363
373
|
: {}),
|
|
364
374
|
...(entry.pluginSubagent.profile !== undefined ? { profile: entry.pluginSubagent.profile } : {}),
|
|
@@ -380,6 +390,9 @@ export async function startAgent({
|
|
|
380
390
|
agentDir: cwd,
|
|
381
391
|
origin,
|
|
382
392
|
getTranscriptPath: () => sessionManager.getSessionFile(),
|
|
393
|
+
...(allowBackgroundFromSubagent
|
|
394
|
+
? { backgroundDrain: { stream, sessionId, liveRegistry: liveSubagentRegistry } }
|
|
395
|
+
: {}),
|
|
383
396
|
}
|
|
384
397
|
}
|
|
385
398
|
return defaultCreateSessionForSubagent(subagent, subagentOptions)
|
|
@@ -33,3 +33,15 @@ async function probe(bwrap: string): Promise<boolean> {
|
|
|
33
33
|
export function _resetBwrapAvailabilityCacheForTests(): void {
|
|
34
34
|
availabilityCache.clear()
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
// The bun binary this process runs as (process.execPath). build.ts re-exposes
|
|
38
|
+
// it at /proc/self/exe over the masked /proc so sandboxed package runners can
|
|
39
|
+
// self-locate. This is correct ONLY in the bun-centric container: the base
|
|
40
|
+
// image (oven/bun:1-slim) ships no real node — `node` is a bun symlink and
|
|
41
|
+
// bunx/npx/pnpx all resolve to bun (Bun's fake-node model), so every runtime
|
|
42
|
+
// reading /proc/self/exe IS bun. A real node binary would self-locate to the
|
|
43
|
+
// wrong ELF here; if node is ever added to the image this must resolve the
|
|
44
|
+
// actual interpreter instead.
|
|
45
|
+
export function resolveProcSelfExe(): string {
|
|
46
|
+
return process.execPath
|
|
47
|
+
}
|
package/src/sandbox/build.ts
CHANGED
|
@@ -36,14 +36,35 @@ export function buildSandboxedCommand(command: string, policy: SandboxPolicy = {
|
|
|
36
36
|
|
|
37
37
|
function buildArgv(command: string, policy: SandboxPolicy): string[] {
|
|
38
38
|
const bwrap = policy.bwrapPath ?? 'bwrap'
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
const procStrategy = policy.proc ?? 'tmpfs'
|
|
40
|
+
const realProc = procStrategy === 'real-proc'
|
|
41
|
+
|
|
42
|
+
// 'real-proc' splits PID-namespace ownership from bwrap. `unshare --pid
|
|
43
|
+
// --fork --mount --mount-proc` (util-linux, baseline) creates the new PID +
|
|
44
|
+
// mount namespaces as REAL root and mounts a fresh procfs scoped to that PID
|
|
45
|
+
// namespace — which OrbStack permits only with CAP_SYS_ADMIN and NOT from
|
|
46
|
+
// bwrap's user namespace (bwrap's --proc is blocked there). bwrap then runs
|
|
47
|
+
// INSIDE that namespace and must NOT re-unshare pid (it would create a second
|
|
48
|
+
// PID ns with no matching procfs and reintroduce the ENOTDIR crash), so we
|
|
49
|
+
// unshare each namespace EXCEPT pid explicitly instead of --unshare-all. The
|
|
50
|
+
// freshly mounted /proc contains only the sandbox subtree, so --ro-bind /proc
|
|
51
|
+
// (below) binds that scoped procfs, never the agent runtime's /proc/N/environ.
|
|
52
|
+
const argv: string[] = realProc
|
|
53
|
+
? ['unshare', '--pid', '--fork', '--mount', '--mount-proc', '--', bwrap]
|
|
54
|
+
: [bwrap, '--unshare-all']
|
|
55
|
+
if (realProc) {
|
|
56
|
+
argv.push('--unshare-user', '--unshare-ipc', '--unshare-uts', '--unshare-cgroup')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (policy.network !== 'inherit') {
|
|
60
|
+
// Default ('none' / undefined) isolates the net namespace — prompt-injected
|
|
61
|
+
// bash cannot exfiltrate over the network unless the consumer opts in.
|
|
62
|
+
// --unshare-all already covers this in the non-real-proc path; under
|
|
63
|
+
// real-proc the explicit unshares above omit net, so add it here.
|
|
64
|
+
if (realProc) argv.push('--unshare-net')
|
|
65
|
+
} else if (!realProc) {
|
|
66
|
+
// --unshare-all unshared the net namespace; --share-net rejoins the outer
|
|
67
|
+
// container's network. Under real-proc we simply never add --unshare-net.
|
|
47
68
|
argv.push('--share-net')
|
|
48
69
|
}
|
|
49
70
|
|
|
@@ -97,12 +118,35 @@ function buildArgv(command: string, policy: SandboxPolicy): string[] {
|
|
|
97
118
|
'/lib64',
|
|
98
119
|
)
|
|
99
120
|
|
|
100
|
-
if (
|
|
121
|
+
if (realProc) {
|
|
122
|
+
// The outer `unshare --mount-proc` already mounted a fresh procfs scoped to
|
|
123
|
+
// the new PID namespace. --ro-bind /proc /proc binds THAT procfs (not the
|
|
124
|
+
// outer container's), so the child gets real /proc/self/{fd,maps} and the
|
|
125
|
+
// agent runtime's pids — and their /proc/N/environ secrets — are simply
|
|
126
|
+
// absent from this namespace. No /proc/self/exe symlink is needed: a real
|
|
127
|
+
// /proc/self/exe already resolves correctly.
|
|
128
|
+
argv.push('--ro-bind', '/proc', '/proc')
|
|
129
|
+
} else if (procStrategy === 'tmpfs') {
|
|
101
130
|
// --tmpfs /proc, never --proc /proc (OrbStack's kernel blocks
|
|
102
131
|
// mount("proc",...) from user namespaces) and never --dev-bind /proc /proc
|
|
103
132
|
// (leaks the outer container's /proc/N/environ — including
|
|
104
133
|
// FIREWORKS_API_KEY — into the sandbox). See sandbox.mdx.
|
|
105
134
|
argv.push('--tmpfs', '/proc')
|
|
135
|
+
|
|
136
|
+
// Re-expose ONLY the bun ELF at /proc/self/exe so sandboxed package runners
|
|
137
|
+
// can self-locate; /proc/N/environ stays masked by the tmpfs above. The
|
|
138
|
+
// caller passes bun's path (see resolveProcSelfExe): in this bun-centric
|
|
139
|
+
// container bunx/npx/pnpx all resolve to bun, so bun IS the runtime reading
|
|
140
|
+
// /proc/self/exe. --symlink (not --ro-bind /proc/self/exe): /proc/self at
|
|
141
|
+
// setup time is bwrap's pid, so a bind would capture bwrap's own binary.
|
|
142
|
+
// Must come AFTER --tmpfs /proc (last-op-wins) or the tmpfs erases it.
|
|
143
|
+
// This restores only the runner's SELF-location; a spawned child still
|
|
144
|
+
// reads /proc/self/fd + /proc/self/maps, which the empty tmpfs lacks, so
|
|
145
|
+
// external-package execution requires the 'real-proc' strategy above.
|
|
146
|
+
if (policy.procSelfExe !== undefined) {
|
|
147
|
+
argv.push('--ro-bind', policy.procSelfExe, policy.procSelfExe)
|
|
148
|
+
argv.push('--symlink', policy.procSelfExe, '/proc/self/exe')
|
|
149
|
+
}
|
|
106
150
|
}
|
|
107
151
|
|
|
108
152
|
for (const mount of policy.mounts ?? []) {
|
package/src/sandbox/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { buildSandboxedCommand, type SandboxedCommand } from './build'
|
|
2
|
-
export { ensureBwrapAvailable, _resetBwrapAvailabilityCacheForTests } from './availability'
|
|
2
|
+
export { ensureBwrapAvailable, resolveProcSelfExe, _resetBwrapAvailabilityCacheForTests } from './availability'
|
|
3
3
|
export { resolveHiddenPaths, type HiddenPaths } from './hidden-paths'
|
|
4
4
|
export {
|
|
5
5
|
resolveProtectedZones,
|
package/src/sandbox/policy.ts
CHANGED
|
@@ -6,7 +6,15 @@ export type SandboxMount =
|
|
|
6
6
|
|
|
7
7
|
export type SandboxNetwork = 'none' | 'inherit'
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// 'tmpfs' (default): empty /proc + a single /proc/self/exe symlink. Works on
|
|
10
|
+
// every host but gives no /proc/self/{fd,maps}, so a JS package runner's CHILD
|
|
11
|
+
// (the spawned bin) crashes with ENOTDIR reading /proc/self/fd. 'none': no
|
|
12
|
+
// /proc at all. 'real-proc': mount a fresh procfs scoped to a NEW pid namespace
|
|
13
|
+
// so the child gets a real /proc/self/{fd,maps} WITHOUT seeing the agent
|
|
14
|
+
// runtime's pids (no /proc/<agent>/environ leak). 'real-proc' requires the
|
|
15
|
+
// outer container to hold CAP_SYS_ADMIN (mount(2) of proc); start.ts only grants
|
|
16
|
+
// it when the operator opts in via typeclaw.json#sandbox.realProc.
|
|
17
|
+
export type SandboxProcStrategy = 'tmpfs' | 'none' | 'real-proc'
|
|
10
18
|
|
|
11
19
|
export type SandboxEnvPolicy = {
|
|
12
20
|
set?: Record<string, string>
|
|
@@ -60,6 +68,14 @@ export type SandboxProtectedPolicy = {
|
|
|
60
68
|
export type SandboxPolicy = {
|
|
61
69
|
bwrapPath?: string
|
|
62
70
|
cwd?: string
|
|
71
|
+
// Concrete host interpreter ELF (the running bun binary) re-exposed at
|
|
72
|
+
// /proc/self/exe over the --tmpfs /proc mask. JS runtimes self-locate via
|
|
73
|
+
// /proc/self/exe; under the empty tmpfs /proc that read fails and bunx panics
|
|
74
|
+
// in createFakeTemporaryNodeExecutable. A direct --ro-bind of /proc/self/exe
|
|
75
|
+
// is wrong: at bwrap setup time /proc/self is bwrap's pid, so it captures the
|
|
76
|
+
// bwrap binary, not the child runtime. The caller resolves this path (I/O);
|
|
77
|
+
// the builder stays pure.
|
|
78
|
+
procSelfExe?: string
|
|
63
79
|
mounts?: SandboxMount[]
|
|
64
80
|
masks?: SandboxMaskPolicy
|
|
65
81
|
writable?: SandboxWritablePolicy
|
package/typeclaw.schema.json
CHANGED
|
@@ -190,6 +190,18 @@
|
|
|
190
190
|
"minLength": 1
|
|
191
191
|
}
|
|
192
192
|
},
|
|
193
|
+
"compose": {
|
|
194
|
+
"default": {
|
|
195
|
+
"exclude": false
|
|
196
|
+
},
|
|
197
|
+
"type": "object",
|
|
198
|
+
"properties": {
|
|
199
|
+
"exclude": {
|
|
200
|
+
"default": false,
|
|
201
|
+
"type": "boolean"
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
},
|
|
193
205
|
"channels": {
|
|
194
206
|
"default": {},
|
|
195
207
|
"type": "object",
|
|
@@ -1114,6 +1126,18 @@
|
|
|
1114
1126
|
}
|
|
1115
1127
|
}
|
|
1116
1128
|
},
|
|
1129
|
+
"sandbox": {
|
|
1130
|
+
"default": {
|
|
1131
|
+
"realProc": false
|
|
1132
|
+
},
|
|
1133
|
+
"type": "object",
|
|
1134
|
+
"properties": {
|
|
1135
|
+
"realProc": {
|
|
1136
|
+
"default": false,
|
|
1137
|
+
"type": "boolean"
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
},
|
|
1117
1141
|
"docker": {
|
|
1118
1142
|
"default": {
|
|
1119
1143
|
"file": {
|