typeclaw 0.35.1 → 0.36.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 +2 -1
- package/src/agent/plugin-tools.ts +11 -0
- package/src/agent/session-origin.ts +4 -3
- package/src/agent/system-prompt.ts +1 -1
- package/src/bundled-plugins/doc-render/index.ts +20 -0
- package/src/bundled-plugins/doc-render/render.ts +140 -0
- package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +314 -0
- package/src/bundled-plugins/security/index.ts +15 -0
- package/src/bundled-plugins/security/permissions.ts +1 -0
- package/src/bundled-plugins/security/policies/plugin-addition.ts +240 -0
- package/src/channels/adapters/line.ts +12 -2
- package/src/cli/channel.ts +190 -7
- package/src/cli/inspect-select.ts +121 -0
- package/src/cli/inspect.ts +15 -7
- package/src/config/channels-mutation.ts +250 -0
- package/src/container/start.ts +24 -1
- package/src/init/reconcile-plugin-deps.ts +173 -0
- package/src/inspect/index.ts +5 -2
- package/src/inspect/loop.ts +26 -9
- package/src/inspect/render.ts +28 -1
- package/src/inspect/transcript-view.ts +52 -11
- package/src/plugin/index.ts +2 -2
- package/src/plugin/loader.ts +61 -7
- package/src/plugin/manager.ts +18 -4
- package/src/run/bundled-plugins.ts +2 -0
- package/src/sandbox/availability.ts +15 -7
- package/src/sandbox/errors.ts +23 -0
- package/src/sandbox/index.ts +2 -2
- package/src/sandbox/package-install.ts +35 -0
- package/src/secrets/storage.ts +27 -0
- package/src/skills/typeclaw-markdown-pdf/SKILL.md +0 -400
|
@@ -14,6 +14,7 @@ import { colors, markdownTheme } from '@/tui/theme'
|
|
|
14
14
|
|
|
15
15
|
import { streamSessionEvents, type LiveSourceFactory, type StreamPhase } from './index'
|
|
16
16
|
import { originLabel, shortSessionId } from './label'
|
|
17
|
+
import { TimeGate } from './render'
|
|
17
18
|
import type { SessionSummary } from './session-list'
|
|
18
19
|
import type { InspectEvent, InspectFilter } from './types'
|
|
19
20
|
|
|
@@ -25,6 +26,7 @@ export type TranscriptViewOptions = {
|
|
|
25
26
|
sinceMs: number | undefined
|
|
26
27
|
liveSource?: LiveSourceFactory
|
|
27
28
|
createTerminal?: () => Terminal
|
|
29
|
+
maxHistoryEntries?: number
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export const MAX_LIVE_HISTORY_ENTRIES = 250
|
|
@@ -53,12 +55,15 @@ export function createTranscriptView(opts: TranscriptViewOptions) {
|
|
|
53
55
|
// grow until the viewer stalls; the window evicts the oldest entry to keep
|
|
54
56
|
// render cost bounded. Components are evicted per event so a timestamp never
|
|
55
57
|
// outlives its body. Header and pinned status are never evicted.
|
|
56
|
-
const history = new BoundedComponentWindow(MAX_LIVE_HISTORY_ENTRIES)
|
|
57
|
-
const appendEntry = (
|
|
58
|
+
const history = new BoundedComponentWindow<HistoryEntry>(opts.maxHistoryEntries ?? MAX_LIVE_HISTORY_ENTRIES)
|
|
59
|
+
const appendEntry = (entry: HistoryEntry): void => {
|
|
58
60
|
tui.removeChild(status)
|
|
59
|
-
const evicted = history.push(
|
|
60
|
-
if (evicted !== null)
|
|
61
|
-
|
|
61
|
+
const evicted = history.push(entry)
|
|
62
|
+
if (evicted !== null) {
|
|
63
|
+
for (const component of evicted.components) tui.removeChild(component)
|
|
64
|
+
promoteVisibleRunHead(history, evicted)
|
|
65
|
+
}
|
|
66
|
+
for (const component of entry.components) tui.addChild(component)
|
|
62
67
|
tui.addChild(status)
|
|
63
68
|
}
|
|
64
69
|
|
|
@@ -91,15 +96,23 @@ export function createTranscriptView(opts: TranscriptViewOptions) {
|
|
|
91
96
|
// during replay (one render at replay-end) to avoid redraw storms on long
|
|
92
97
|
// transcripts; render per event once live.
|
|
93
98
|
let live = false
|
|
99
|
+
const timeGate = new TimeGate()
|
|
94
100
|
const onEvent = (event: InspectEvent): void => {
|
|
95
|
-
|
|
101
|
+
const body = componentFor(event)
|
|
102
|
+
const stamped = timeGate.shouldShow(event.cat)
|
|
103
|
+
const time = new Text(stamped ? formatEventTime(event.ts) : '', 0, 0)
|
|
104
|
+
appendEntry({ kind: 'event', cat: event.cat, ts: event.ts, time, stamped, components: [time, body] })
|
|
96
105
|
if (live) tui.requestRender()
|
|
97
106
|
}
|
|
98
107
|
const onPhase = (phase: StreamPhase): void => {
|
|
99
108
|
if (phase.phase === 'replay-end') {
|
|
100
109
|
tui.requestRender()
|
|
101
110
|
} else if (phase.phase === 'live-start') {
|
|
102
|
-
|
|
111
|
+
timeGate.reset()
|
|
112
|
+
appendEntry({
|
|
113
|
+
kind: 'divider',
|
|
114
|
+
components: [new Text(divider(phase.sessionLive ? 'live' : 'live (broadcasts only)'), 0, 0)],
|
|
115
|
+
})
|
|
103
116
|
live = true
|
|
104
117
|
tui.requestRender()
|
|
105
118
|
}
|
|
@@ -199,19 +212,47 @@ function divider(text: string): string {
|
|
|
199
212
|
return colors.dim(`─── ${text} ───`)
|
|
200
213
|
}
|
|
201
214
|
|
|
202
|
-
|
|
215
|
+
// After the stamped head of a same-category run is evicted, the new first
|
|
216
|
+
// visible row of that run would have a blank timestamp. Fill its already-present
|
|
217
|
+
// (blank) Text so the visible window never shows a run with no timestamp at all.
|
|
218
|
+
// Mutates text in place — no child add/remove/reorder.
|
|
219
|
+
function promoteVisibleRunHead(history: BoundedComponentWindow<HistoryEntry>, evicted: HistoryEntry): void {
|
|
220
|
+
if (evicted.kind !== 'event' || !evicted.stamped) return
|
|
221
|
+
const first = history.first()
|
|
222
|
+
if (first === undefined || first.kind !== 'event' || first.stamped || first.cat !== evicted.cat) return
|
|
223
|
+
first.time.setText(formatEventTime(first.ts))
|
|
224
|
+
first.stamped = true
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// An event row carries its category + ts + the (possibly blank) timestamp Text
|
|
228
|
+
// so the window can re-stamp the visible run head after eviction. Non-event rows
|
|
229
|
+
// (the live divider) have no category and never participate in re-stamping.
|
|
230
|
+
export type HistoryEntry =
|
|
231
|
+
| {
|
|
232
|
+
kind: 'event'
|
|
233
|
+
cat: InspectEvent['cat']
|
|
234
|
+
ts: number
|
|
235
|
+
time: Text
|
|
236
|
+
stamped: boolean
|
|
237
|
+
components: readonly Component[]
|
|
238
|
+
}
|
|
239
|
+
| { kind: 'divider'; components: readonly Component[] }
|
|
203
240
|
|
|
204
|
-
export class BoundedComponentWindow {
|
|
205
|
-
private readonly entries:
|
|
241
|
+
export class BoundedComponentWindow<T> {
|
|
242
|
+
private readonly entries: T[] = []
|
|
206
243
|
|
|
207
244
|
constructor(private readonly maxEntries: number) {}
|
|
208
245
|
|
|
209
|
-
push(entry:
|
|
246
|
+
push(entry: T): T | null {
|
|
210
247
|
this.entries.push(entry)
|
|
211
248
|
if (this.entries.length <= this.maxEntries) return null
|
|
212
249
|
return this.entries.shift() ?? null
|
|
213
250
|
}
|
|
214
251
|
|
|
252
|
+
first(): T | undefined {
|
|
253
|
+
return this.entries[0]
|
|
254
|
+
}
|
|
255
|
+
|
|
215
256
|
get size(): number {
|
|
216
257
|
return this.entries.length
|
|
217
258
|
}
|
package/src/plugin/index.ts
CHANGED
|
@@ -72,8 +72,8 @@ export {
|
|
|
72
72
|
type LoadPluginsResult,
|
|
73
73
|
} from './manager'
|
|
74
74
|
export type { PermissionService } from '@/permissions'
|
|
75
|
-
export type { LoadPluginEntryFn, ResolvedPlugin } from './loader'
|
|
76
|
-
export { loadPluginEntry,
|
|
75
|
+
export type { LoadPluginEntryFn, PluginEntrySpec, ResolvedPlugin } from './loader'
|
|
76
|
+
export { derivePluginNameFromPackage, loadPluginEntry, PluginNotFoundError, splitPluginEntrySpec } from './loader'
|
|
77
77
|
export { materializeSkills, type MaterializedSkills, type SkillEntry } from './skills'
|
|
78
78
|
export {
|
|
79
79
|
createLoadSkillTool,
|
package/src/plugin/loader.ts
CHANGED
|
@@ -13,6 +13,20 @@ export type ResolvedPlugin = {
|
|
|
13
13
|
|
|
14
14
|
export type LoadPluginEntryFn = (entry: string, agentDir: string) => Promise<ResolvedPlugin>
|
|
15
15
|
|
|
16
|
+
// Thrown only when a plugin entry cannot be resolved at all (uninstalled
|
|
17
|
+
// package, missing local file, unresolvable export subpath). The manager
|
|
18
|
+
// treats this as non-fatal and skips the entry. Every other failure --
|
|
19
|
+
// path-escape, import-time evaluation throws, invalid definition -- stays a
|
|
20
|
+
// plain Error so it remains a hard boot error.
|
|
21
|
+
export class PluginNotFoundError extends Error {
|
|
22
|
+
readonly entry: string
|
|
23
|
+
constructor(entry: string, message: string, options?: { cause?: unknown }) {
|
|
24
|
+
super(message, options)
|
|
25
|
+
this.name = 'PluginNotFoundError'
|
|
26
|
+
this.entry = entry
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
export async function loadPluginEntry(entry: string, agentDir: string): Promise<ResolvedPlugin> {
|
|
17
31
|
if (isLocalPath(entry)) {
|
|
18
32
|
return loadLocal(entry, agentDir)
|
|
@@ -33,7 +47,7 @@ async function loadLocal(entry: string, agentDir: string): Promise<ResolvedPlugi
|
|
|
33
47
|
throw new Error(`plugin path escapes agent directory: ${entry} (resolved to ${resolved})`)
|
|
34
48
|
}
|
|
35
49
|
if (!existsSync(resolved)) {
|
|
36
|
-
throw new
|
|
50
|
+
throw new PluginNotFoundError(entry, `plugin path does not exist: ${entry} (resolved to ${resolved})`)
|
|
37
51
|
}
|
|
38
52
|
const url = pathToFileURL(resolved).href
|
|
39
53
|
const mod = (await import(url)) as { default?: unknown }
|
|
@@ -43,8 +57,14 @@ async function loadLocal(entry: string, agentDir: string): Promise<ResolvedPlugi
|
|
|
43
57
|
}
|
|
44
58
|
|
|
45
59
|
async function loadNpm(entry: string, agentDir: string): Promise<ResolvedPlugin> {
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
// The version suffix (`name@1.2.3`, `@scope/name@1.2.3`) is consumed by the
|
|
61
|
+
// host reconcile step when materializing the entry into package.json. By load
|
|
62
|
+
// time the package is installed at `node_modules/<name>/` under its bare name,
|
|
63
|
+
// so passing the raw `name@version` here would miss the dir and fail the
|
|
64
|
+
// bare-import fallback too.
|
|
65
|
+
const { name: packageName } = splitPluginEntrySpec(entry)
|
|
66
|
+
const pkgJsonPath = findPackageJson(packageName, agentDir)
|
|
67
|
+
let pkgName = packageName
|
|
48
68
|
let version: string | undefined
|
|
49
69
|
let entryPath: string | null = null
|
|
50
70
|
if (pkgJsonPath !== null) {
|
|
@@ -68,16 +88,46 @@ async function loadNpm(entry: string, agentDir: string): Promise<ResolvedPlugin>
|
|
|
68
88
|
// Fall through to bare-import resolution.
|
|
69
89
|
}
|
|
70
90
|
}
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
91
|
+
// Resolve before importing so an unresolvable entry (uninstalled package,
|
|
92
|
+
// missing export subpath) is classified as PluginNotFoundError WITHOUT
|
|
93
|
+
// running the module. Once resolution succeeds, any import-time throw is a
|
|
94
|
+
// genuine plugin bug and propagates fatally -- never swallowed as not-found.
|
|
95
|
+
// The entryPath branch covers packages whose `main`/`module` was already
|
|
96
|
+
// located on disk; the else branch lets Bun's resolver read `exports` maps.
|
|
97
|
+
let importTarget: string
|
|
98
|
+
if (entryPath !== null) {
|
|
99
|
+
importTarget = pathToFileURL(entryPath).href
|
|
100
|
+
} else {
|
|
101
|
+
try {
|
|
102
|
+
importTarget = Bun.resolveSync(packageName, agentDir)
|
|
103
|
+
} catch (err) {
|
|
104
|
+
throw new PluginNotFoundError(entry, `cannot resolve plugin "${entry}": ${describeError(err)}`, { cause: err })
|
|
105
|
+
}
|
|
106
|
+
}
|
|
75
107
|
const mod = (await import(importTarget)) as { default?: unknown }
|
|
76
108
|
const defined = expectDefined(mod, entry)
|
|
77
109
|
const name = derivePluginNameFromPackage(pkgName)
|
|
78
110
|
return { name, version, source: entry, defined }
|
|
79
111
|
}
|
|
80
112
|
|
|
113
|
+
export type PluginEntrySpec = { name: string; versionSpec: string | undefined }
|
|
114
|
+
|
|
115
|
+
// Splits an npm-style entry into package name and optional version spec. The
|
|
116
|
+
// version delimiter is the LAST `@` that isn't the leading scope marker, so
|
|
117
|
+
// `@scope/pkg@1.2.3` → { name: '@scope/pkg', versionSpec: '1.2.3' } while
|
|
118
|
+
// `@scope/pkg` → { name: '@scope/pkg', versionSpec: undefined }.
|
|
119
|
+
export function splitPluginEntrySpec(entry: string): PluginEntrySpec {
|
|
120
|
+
const scoped = entry.startsWith('@')
|
|
121
|
+
const searchFrom = scoped ? entry.indexOf('/') + 1 : 0
|
|
122
|
+
const at = entry.indexOf('@', searchFrom)
|
|
123
|
+
if (at <= 0) return { name: entry, versionSpec: undefined }
|
|
124
|
+
const versionSpec = entry.slice(at + 1)
|
|
125
|
+
return {
|
|
126
|
+
name: entry.slice(0, at),
|
|
127
|
+
versionSpec: versionSpec.length > 0 ? versionSpec : undefined,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
81
131
|
export function derivePluginNameFromPackage(packageName: string): string {
|
|
82
132
|
const PREFIX = 'typeclaw-plugin-'
|
|
83
133
|
const SCOPED_PREFIX_RE = /^@[^/]+\//
|
|
@@ -85,6 +135,10 @@ export function derivePluginNameFromPackage(packageName: string): string {
|
|
|
85
135
|
return stripped.startsWith(PREFIX) ? stripped.slice(PREFIX.length) : stripped
|
|
86
136
|
}
|
|
87
137
|
|
|
138
|
+
function describeError(err: unknown): string {
|
|
139
|
+
return err instanceof Error ? err.message : String(err)
|
|
140
|
+
}
|
|
141
|
+
|
|
88
142
|
function findPackageJson(entry: string, agentDir: string): string | null {
|
|
89
143
|
const PACKAGE_JSON = 'package.json'
|
|
90
144
|
let cur = agentDir
|
package/src/plugin/manager.ts
CHANGED
|
@@ -11,7 +11,7 @@ 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, type ResolvedPlugin } from './loader'
|
|
14
|
+
import { loadPluginEntry, type LoadPluginEntryFn, PluginNotFoundError, type ResolvedPlugin } from './loader'
|
|
15
15
|
import { discardRegistrationsBy, emptyRegistry, type PluginRegistry, registerContributions } from './registry'
|
|
16
16
|
import type { PluginExports } from './types'
|
|
17
17
|
|
|
@@ -51,11 +51,25 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
|
|
|
51
51
|
throw new Error('plugin: spawnSubagent is not yet wired')
|
|
52
52
|
}
|
|
53
53
|
|
|
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.
|
|
59
|
+
const resolvedEntries = await Promise.all(
|
|
60
|
+
opts.entries.map(async (entry) => {
|
|
61
|
+
try {
|
|
62
|
+
return { entry, resolved: await loadEntry(entry, opts.agentDir) }
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (!(err instanceof PluginNotFoundError)) throw err
|
|
65
|
+
console.warn(`[plugin] failed to load "${entry}", ignoring: ${err.message}`)
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
54
70
|
const allPlugins: { entry: string; resolved: ResolvedPlugin }[] = [
|
|
55
71
|
...(opts.bundled?.map((resolved) => ({ entry: `<bundled:${resolved.name}>`, resolved })) ?? []),
|
|
56
|
-
...(
|
|
57
|
-
opts.entries.map(async (entry) => ({ entry, resolved: await loadEntry(entry, opts.agentDir) })),
|
|
58
|
-
)),
|
|
72
|
+
...resolvedEntries.filter((e): e is { entry: string; resolved: ResolvedPlugin } => e !== null),
|
|
59
73
|
]
|
|
60
74
|
|
|
61
75
|
const declaredPermissions = collectDeclaredPermissions(allPlugins)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import agentBrowserPlugin from '@/bundled-plugins/agent-browser'
|
|
2
2
|
import backupPlugin from '@/bundled-plugins/backup'
|
|
3
3
|
import bunHygienePlugin from '@/bundled-plugins/bun-hygiene'
|
|
4
|
+
import docRenderPlugin from '@/bundled-plugins/doc-render'
|
|
4
5
|
import explorerPlugin from '@/bundled-plugins/explorer'
|
|
5
6
|
import githubCliAuthPlugin from '@/bundled-plugins/github-cli-auth'
|
|
6
7
|
import guardPlugin from '@/bundled-plugins/guard'
|
|
@@ -58,6 +59,7 @@ export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
|
|
|
58
59
|
{ name: 'memory', version: undefined, source: '<bundled>', defined: memoryPlugin },
|
|
59
60
|
{ name: 'backup', version: undefined, source: '<bundled>', defined: backupPlugin },
|
|
60
61
|
{ name: 'agent-browser', version: undefined, source: '<bundled>', defined: agentBrowserPlugin },
|
|
62
|
+
{ name: 'doc-render', version: undefined, source: '<bundled>', defined: docRenderPlugin },
|
|
61
63
|
{ name: 'explorer', version: undefined, source: '<bundled>', defined: explorerPlugin },
|
|
62
64
|
{ name: 'scout', version: undefined, source: '<bundled>', defined: scoutPlugin },
|
|
63
65
|
{ name: 'reviewer', version: undefined, source: '<bundled>', defined: reviewerPlugin },
|
|
@@ -209,12 +209,16 @@ export function canBindProcSafely(options?: { bwrapPath?: string }): Promise<boo
|
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
// Default backoff between proc-bind safety re-probes, in ms. Array length = retry
|
|
212
|
-
// count (
|
|
212
|
+
// count (4 retries after the initial attempt = 5 probes total). The probe is
|
|
213
213
|
// normally sub-ms; it only returns 'inconclusive' under transient CPU/IO
|
|
214
214
|
// contention (e.g. a boot-time storm of concurrent LLM calls saturating the box
|
|
215
|
-
// and tripping the probe's own timeout), so a
|
|
216
|
-
//
|
|
217
|
-
|
|
215
|
+
// and tripping the probe's own timeout), so a staggered wait lets the spike pass
|
|
216
|
+
// before re-proving. Widened from [250,1000] after a deployed container degraded
|
|
217
|
+
// to tmpfs (breaking `bun install`) when a boot-time load spike outlasted the old
|
|
218
|
+
// two-retry budget; the longer tail rides out a sustained spike before failing
|
|
219
|
+
// closed. 'unsafe' still short-circuits with no retry, so this never weakens the
|
|
220
|
+
// leak-block guarantee — it only buys more chances to PROVE it.
|
|
221
|
+
export const PROC_BIND_RETRY_BACKOFF_MS = [250, 1_000, 2_000, 4_000] as const
|
|
218
222
|
|
|
219
223
|
// proc-bind selection must distinguish "definitely unavailable" from "couldn't
|
|
220
224
|
// verify right now". A DEFINITIVE verdict is final: 'safe'→true; a real userns
|
|
@@ -375,9 +379,13 @@ async function probeProcBind(bwrap: string): Promise<ProcBindProbe> {
|
|
|
375
379
|
}
|
|
376
380
|
|
|
377
381
|
// Cap on the in-sandbox bwrap probe so a wedged runtime cannot stall the first
|
|
378
|
-
// low-trust bash call. The probe normally completes in a few ms
|
|
379
|
-
//
|
|
380
|
-
|
|
382
|
+
// low-trust bash call. The probe normally completes in a few ms. Raised from 5s
|
|
383
|
+
// because a deployed container fell to the tmpfs degraded mode (which breaks
|
|
384
|
+
// `bun install` with Bun's opaque NotDir) when this timeout fired under a
|
|
385
|
+
// boot-time storm of concurrent LLM/tool calls — a transient 'inconclusive' that
|
|
386
|
+
// proves nothing about the host. A wider ceiling lets the real probe finish on a
|
|
387
|
+
// briefly-saturated box; a genuinely wedged runtime still trips it and degrades.
|
|
388
|
+
const PROC_BIND_PROBE_TIMEOUT_MS = 12_000
|
|
381
389
|
|
|
382
390
|
// Designated probe-script exit codes. ONLY these two are a cacheable verdict;
|
|
383
391
|
// every other code (a setup failure, bwrap startup failure, a signal, 127, …) is
|
package/src/sandbox/errors.ts
CHANGED
|
@@ -18,3 +18,26 @@ export class SandboxPolicyError extends Error {
|
|
|
18
18
|
super(`sandbox policy rejected command: ${reason}`)
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
// Raised when the /proc strategy degraded to the empty `tmpfs` fallback AND the
|
|
23
|
+
// command needs a real /proc (a bun install / bunx / bun run that reads the
|
|
24
|
+
// kernel-backed /proc/self/{fd,maps}). Without this pre-check Bun aborts deep in
|
|
25
|
+
// its install pipeline with the opaque "NotDir" (ENOTDIR) error, which the model
|
|
26
|
+
// misreads as a bad package or a transient hiccup and retries forever. Surfacing
|
|
27
|
+
// it here, before the command runs, turns the failure into one the operator (or
|
|
28
|
+
// the model) can act on: it is an environment/runtime limitation, not the
|
|
29
|
+
// command's fault, so retrying the same command on the same container is futile.
|
|
30
|
+
export class SandboxDegradedProcError extends Error {
|
|
31
|
+
override readonly name = 'SandboxDegradedProcError'
|
|
32
|
+
constructor() {
|
|
33
|
+
super(
|
|
34
|
+
'sandbox /proc is in degraded tmpfs mode, so bun package commands ' +
|
|
35
|
+
'(bun install / bun add / bunx / bun run) cannot run: Bun needs a real ' +
|
|
36
|
+
'/proc/self/fd, which this strategy cannot provide, and would otherwise ' +
|
|
37
|
+
'fail with an opaque "NotDir" error. This is a container/runtime limitation ' +
|
|
38
|
+
'(no usable user namespaces for the cap-free proc-bind strategy), not a ' +
|
|
39
|
+
'problem with the command or the package. Retrying the same command will ' +
|
|
40
|
+
'not help; report it as a sandbox/environment issue.',
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/sandbox/index.ts
CHANGED
|
@@ -24,10 +24,10 @@ export {
|
|
|
24
24
|
type WritableZones,
|
|
25
25
|
} from './writable-zones'
|
|
26
26
|
export { resolveSandboxSymlinks, type SandboxSymlinkSpec } from './symlinks'
|
|
27
|
-
export { isPackageInstallCommand } from './package-install'
|
|
27
|
+
export { commandNeedsRealProc, isPackageInstallCommand } from './package-install'
|
|
28
28
|
export { ensureSessionTmpDir, isUnderTmp, mapVirtualTmpPath, SESSION_TMP_ROOT, sessionTmpDir } from './session-tmp'
|
|
29
29
|
export { formatCommand, shellQuote } from './quote'
|
|
30
|
-
export { SandboxPolicyError, SandboxUnavailableError } from './errors'
|
|
30
|
+
export { SandboxDegradedProcError, SandboxPolicyError, SandboxUnavailableError } from './errors'
|
|
31
31
|
export {
|
|
32
32
|
DEFAULT_SANDBOX_ENV,
|
|
33
33
|
type SandboxCommandFilter,
|
|
@@ -21,3 +21,38 @@ export function isPackageInstallCommand(command: string): boolean {
|
|
|
21
21
|
|
|
22
22
|
return !words.some((word) => GLOBAL_FLAG.test(word))
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
// The bun subcommands whose work (or whose spawned child) reads the kernel-backed
|
|
26
|
+
// /proc/self/{fd,maps} magic symlinks: package installs (add/install/i), the
|
|
27
|
+
// package runners (x/create), and `run` (which can exec a package bin). Under the
|
|
28
|
+
// degraded `tmpfs` /proc strategy those reads return ENOTDIR and Bun aborts with
|
|
29
|
+
// its opaque "NotDir". `bunx` is the bare-binary alias for `bun x`.
|
|
30
|
+
const REAL_PROC_BUN_SUBCOMMANDS = new Set(['add', 'install', 'i', 'x', 'create', 'run'])
|
|
31
|
+
|
|
32
|
+
// Splits a command line at the shell operators that begin a NEW simple command —
|
|
33
|
+
// `&&`, `||`, `;`, `|`, `|&`, `&`, newline — and the subshell opener `(`. A bun
|
|
34
|
+
// invocation after a prelude (`cd app && bun install`, `mkdir a; cd a; bunx foo`)
|
|
35
|
+
// starts a fresh segment, so checking each segment's head catches it where a
|
|
36
|
+
// whole-string `words[0]` check would not. Coarse on purpose: it over-splits
|
|
37
|
+
// inside quotes/`$()`, but this only feeds the DIAGNOSTIC below, never a privilege
|
|
38
|
+
// decision, so a spurious extra segment can only make the error message MORE
|
|
39
|
+
// likely, never widen the sandbox.
|
|
40
|
+
const SHELL_COMMAND_SEPARATOR = /(?:&&|\|\||[;|&\n()])+/
|
|
41
|
+
|
|
42
|
+
// Whether a command will exercise the real /proc, so a caller can turn the
|
|
43
|
+
// degraded-mode `tmpfs` fallback into an actionable diagnostic instead of letting
|
|
44
|
+
// Bun surface its opaque "NotDir". UNLIKE isPackageInstallCommand this is a
|
|
45
|
+
// DIAGNOSTIC heuristic, not a privilege gate — it deliberately fires through shell
|
|
46
|
+
// metacharacters and chained preludes (a `cd app && bunx foo` still runs bunx) and
|
|
47
|
+
// never widens the sandbox surface, so erring toward catching more only improves
|
|
48
|
+
// the error message.
|
|
49
|
+
export function commandNeedsRealProc(command: string): boolean {
|
|
50
|
+
return command.split(SHELL_COMMAND_SEPARATOR).some((segment) => segmentInvokesRealProcBun(segment.trim()))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function segmentInvokesRealProcBun(segment: string): boolean {
|
|
54
|
+
if (segment === 'bunx' || segment.startsWith('bunx ')) return true
|
|
55
|
+
const words = segment.split(/\s+/)
|
|
56
|
+
if (words[0] !== 'bun') return false
|
|
57
|
+
return words[1] !== undefined && REAL_PROC_BUN_SUBCOMMANDS.has(words[1])
|
|
58
|
+
}
|
package/src/secrets/storage.ts
CHANGED
|
@@ -243,6 +243,33 @@ export class SecretsBackend implements AuthStorageBackend {
|
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
// Removes `channels.<kind>` from the envelope. Returns `true` when the
|
|
247
|
+
// adapter slot was present and removed, `false` when nothing changed
|
|
248
|
+
// (idempotent on the CLI side — `channel remove discord-bot` twice should
|
|
249
|
+
// not error on the second call). Mirrors `removeProviderCredentialSync`:
|
|
250
|
+
// rewrites the file only when something changed so canonical-shape reads
|
|
251
|
+
// pay zero cost.
|
|
252
|
+
removeChannelSync(kind: string): boolean {
|
|
253
|
+
if (!existsSync(this.secretsPath)) return false
|
|
254
|
+
let release: (() => void) | undefined
|
|
255
|
+
try {
|
|
256
|
+
release = this.acquireSyncLockWithRetry()
|
|
257
|
+
const envelope = this.readEnvelope()
|
|
258
|
+
if (!(kind in envelope.channels)) return false
|
|
259
|
+
const { [kind]: _removed, ...rest } = envelope.channels
|
|
260
|
+
const next: SecretsFile = {
|
|
261
|
+
...envelope,
|
|
262
|
+
$schema: envelope.$schema ?? SCHEMA_REL,
|
|
263
|
+
version: SECRETS_FILE_VERSION,
|
|
264
|
+
channels: rest,
|
|
265
|
+
}
|
|
266
|
+
this.writeEnvelopeAtomic(next)
|
|
267
|
+
return true
|
|
268
|
+
} finally {
|
|
269
|
+
release?.()
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
246
273
|
writeChannelsSync(next: Channels): void {
|
|
247
274
|
this.ensureParentDir()
|
|
248
275
|
this.ensureFileExists()
|