typeclaw 0.36.1 → 0.36.3

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.
Files changed (40) hide show
  1. package/package.json +2 -2
  2. package/src/agent/index.ts +11 -0
  3. package/src/agent/plugin-tools.ts +43 -21
  4. package/src/agent/restart/index.ts +6 -0
  5. package/src/agent/restart-handoff/index.ts +10 -0
  6. package/src/agent/system-prompt.ts +6 -0
  7. package/src/agent/tools/restart.ts +9 -0
  8. package/src/bundled-plugins/backup/README.md +11 -2
  9. package/src/bundled-plugins/backup/git-auth.ts +58 -0
  10. package/src/bundled-plugins/backup/index.ts +54 -0
  11. package/src/bundled-plugins/backup/runner.ts +82 -12
  12. package/src/channels/adapters/discord-bot-reactions.ts +1 -0
  13. package/src/channels/adapters/line-attachment.ts +97 -0
  14. package/src/channels/adapters/line-classify.ts +14 -3
  15. package/src/channels/adapters/line.ts +5 -1
  16. package/src/channels/manager.ts +15 -3
  17. package/src/channels/router.ts +67 -16
  18. package/src/cli/hostd.ts +37 -4
  19. package/src/cli/reload.ts +26 -5
  20. package/src/cli/ui.ts +6 -0
  21. package/src/container/index.ts +1 -0
  22. package/src/container/start.ts +6 -0
  23. package/src/init/reconcile-plugin-deps.ts +45 -15
  24. package/src/init/restart-deps-preflight.ts +155 -0
  25. package/src/permissions/permissions.ts +24 -4
  26. package/src/plugin/loader.ts +16 -4
  27. package/src/plugin/manager.ts +175 -71
  28. package/src/reload/client.ts +14 -3
  29. package/src/reload/docker-exec-client.ts +109 -0
  30. package/src/reload/index.ts +7 -1
  31. package/src/reload/recover.ts +38 -0
  32. package/src/run/codex-fetch-observer.ts +57 -5
  33. package/src/run/index.ts +5 -0
  34. package/src/sandbox/availability.ts +58 -15
  35. package/src/sandbox/errors.ts +26 -0
  36. package/src/sandbox/index.ts +6 -1
  37. package/src/sandbox/policy.ts +11 -0
  38. package/src/skills/typeclaw-config/SKILL.md +2 -2
  39. package/src/skills/typeclaw-monorepo/SKILL.md +7 -5
  40. package/src/skills/typeclaw-plugins/SKILL.md +11 -2
@@ -0,0 +1,155 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readFile, readdir } from 'node:fs/promises'
3
+ import { isAbsolute, join, relative, resolve } from 'node:path'
4
+
5
+ import { PACKAGE_FILE } from './packagejson'
6
+ import { PACKAGES_DIR } from './paths'
7
+
8
+ // The hostd restart path is destroy-then-recreate: it `docker rm -f`s the live
9
+ // container BEFORE `start()` runs `bun install`. A bad agent edit to
10
+ // typeclaw.json#plugins or a packages/* manifest aborts that install AFTER the
11
+ // old container is gone, with no rollback and no client to report to — the agent
12
+ // self-locks out. This runs BEFORE stop() (via RestartPreflight) so a bad edit
13
+ // becomes "restart refused, agent keeps running" instead of "agent bricked".
14
+ //
15
+ // Mirrors PR #770: ONLY deterministic local config errors block. No bun
16
+ // invocation, no network — a transient registry hiccup must never strand a
17
+ // healthy agent. start() stays the real fail-closed gate for everything else.
18
+
19
+ export type RestartDepsPreflightResult = { ok: true } | { ok: false; reason: string }
20
+
21
+ export type RestartDepsPreflightOptions = {
22
+ cwd: string
23
+ plugins: readonly string[]
24
+ }
25
+
26
+ const WORKSPACE_PROTOCOL = 'workspace:'
27
+
28
+ const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const
29
+
30
+ export async function validateRestartDeps(options: RestartDepsPreflightOptions): Promise<RestartDepsPreflightResult> {
31
+ const { cwd, plugins } = options
32
+
33
+ const localPluginError = checkLocalPluginPaths(cwd, plugins)
34
+ if (localPluginError) return { ok: false, reason: localPluginError }
35
+
36
+ const workspaceError = await checkWorkspaceMembers(cwd)
37
+ if (workspaceError) return { ok: false, reason: workspaceError }
38
+
39
+ return { ok: true }
40
+ }
41
+
42
+ // Mirrors loadLocal() in src/plugin/loader.ts: a local plugin entry is resolved
43
+ // against cwd and confined to it (`rel.startsWith('..') || isAbsolute(rel)`
44
+ // throws). An escaping entry (`../x`, `/abs/x`) that happens to EXIST passes a
45
+ // bare existsSync but the loader rejects it post-stop — so the escape check must
46
+ // run before, and independently of, the existence check.
47
+ function checkLocalPluginPaths(cwd: string, plugins: readonly string[]): string | null {
48
+ for (const entry of plugins) {
49
+ if (!isLocalEntry(entry)) continue
50
+ const resolved = resolve(cwd, entry)
51
+ const rel = relative(cwd, resolved)
52
+ if (rel.startsWith('..') || isAbsolute(rel)) {
53
+ return `local plugin "${entry}" referenced in typeclaw.json#plugins escapes the agent directory; the plugin loader confines local plugins to the agent folder and would reject it after the container has stopped. Use a path inside the agent folder before restarting.`
54
+ }
55
+ if (!existsSync(resolved)) {
56
+ return `local plugin "${entry}" referenced in typeclaw.json#plugins does not exist on disk; restart would fail at dependency install. Remove the entry or restore the path before restarting.`
57
+ }
58
+ }
59
+ return null
60
+ }
61
+
62
+ function isLocalEntry(entry: string): boolean {
63
+ return entry.startsWith('./') || entry.startsWith('../') || isAbsolute(entry)
64
+ }
65
+
66
+ // Bun resolves the `workspace:` protocol strictly against the local workspace
67
+ // set, so a member declaring `"<dep>": "workspace:*"` where `<dep>` is not
68
+ // itself a workspace member aborts the WHOLE install with `<dep>@workspace:*
69
+ // failed to resolve`. Canonical trigger: a half-migrated local plugin still
70
+ // pinning `"typeclaw": "workspace:*"` after typeclaw became an external npm dep.
71
+ async function checkWorkspaceMembers(cwd: string): Promise<string | null> {
72
+ const members = await readWorkspaceMembers(cwd)
73
+ if (members.length === 0) return null
74
+
75
+ const memberNames = new Set<string>()
76
+ for (const m of members) {
77
+ if (m.name !== null) memberNames.add(m.name)
78
+ }
79
+
80
+ for (const member of members) {
81
+ for (const field of DEPENDENCY_FIELDS) {
82
+ const deps = member.deps[field]
83
+ if (!deps) continue
84
+ for (const [depName, spec] of Object.entries(deps)) {
85
+ if (!spec.startsWith(WORKSPACE_PROTOCOL)) continue
86
+ if (!memberNames.has(depName)) {
87
+ return `local workspace package "${member.dirName}" depends on "${depName}": "${spec}", but "${depName}" is not a workspace package under ${PACKAGES_DIR}/. \`bun install\` would abort with "${depName}@${spec} failed to resolve", leaving the agent unable to restart. Fix ${PACKAGES_DIR}/${member.dirName}/${PACKAGE_FILE} (use a registry version range, or remove the package) before restarting.`
88
+ }
89
+ }
90
+ }
91
+ }
92
+
93
+ return null
94
+ }
95
+
96
+ type WorkspaceMember = {
97
+ dirName: string
98
+ name: string | null
99
+ deps: Partial<Record<(typeof DEPENDENCY_FIELDS)[number], Record<string, string>>>
100
+ }
101
+
102
+ // A member whose manifest is missing/unparseable is skipped, not failed: bun may
103
+ // tolerate it, and we only block on the workspace-resolution class above. No
104
+ // packages/ dir at all returns [].
105
+ async function readWorkspaceMembers(cwd: string): Promise<WorkspaceMember[]> {
106
+ const packagesDir = join(cwd, PACKAGES_DIR)
107
+ let entries: string[]
108
+ try {
109
+ entries = await readdir(packagesDir)
110
+ } catch {
111
+ return []
112
+ }
113
+
114
+ const members: WorkspaceMember[] = []
115
+ for (const dirName of entries) {
116
+ const manifestPath = join(packagesDir, dirName, PACKAGE_FILE)
117
+ if (!existsSync(manifestPath)) continue
118
+ let raw: string
119
+ try {
120
+ raw = await readFile(manifestPath, 'utf8')
121
+ } catch {
122
+ continue
123
+ }
124
+ let parsed: unknown
125
+ try {
126
+ parsed = JSON.parse(raw)
127
+ } catch {
128
+ continue
129
+ }
130
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) continue
131
+ const pkg = parsed as Record<string, unknown>
132
+ members.push({
133
+ dirName,
134
+ name: typeof pkg.name === 'string' ? pkg.name : null,
135
+ deps: extractDeps(pkg),
136
+ })
137
+ }
138
+ return members
139
+ }
140
+
141
+ function extractDeps(
142
+ pkg: Record<string, unknown>,
143
+ ): Partial<Record<(typeof DEPENDENCY_FIELDS)[number], Record<string, string>>> {
144
+ const out: Partial<Record<(typeof DEPENDENCY_FIELDS)[number], Record<string, string>>> = {}
145
+ for (const field of DEPENDENCY_FIELDS) {
146
+ const value = pkg[field]
147
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) continue
148
+ const deps: Record<string, string> = {}
149
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
150
+ if (typeof v === 'string') deps[k] = v
151
+ }
152
+ if (Object.keys(deps).length > 0) out[field] = deps
153
+ }
154
+ return out
155
+ }
@@ -20,6 +20,17 @@ export type PermissionService = {
20
20
  // the config reloadable so role match-rule edits (typeclaw role claim,
21
21
  // hand-edits to typeclaw.json) take effect without a container restart.
22
22
  replaceRoles(roles: RolesConfig | undefined): void
23
+ // Rebuilds the role table with a new plugin-permission set, preserving the
24
+ // object identity that plugin factories captured. The plugin manager calls
25
+ // this AFTER the load loop to finalize the permission model from only the
26
+ // plugins that survived: a user plugin that failed to load must not leave
27
+ // its declared permissions or owner-wildcard exclusions in the live service.
28
+ // Optional so the many partial test stubs and the channel-respond stub need
29
+ // not implement it; the real service from createPermissionService always does.
30
+ replacePluginPermissions?(opts: {
31
+ pluginPermissions: readonly string[]
32
+ ownerWildcardExclusions: readonly string[]
33
+ }): void
23
34
  }
24
35
 
25
36
  export type UnknownPermissionWarning = {
@@ -34,6 +45,7 @@ export const noopPermissionService: PermissionService = {
34
45
  compareRoleSeverity: () => undefined,
35
46
  describe: () => ({ role: 'guest', permissions: [] }),
36
47
  replaceRoles: () => {},
48
+ replacePluginPermissions: () => {},
37
49
  }
38
50
 
39
51
  type ResolvedRole = {
@@ -109,9 +121,10 @@ function levenshtein(a: string, b: string): number {
109
121
  }
110
122
 
111
123
  export function createPermissionService(opts: CreatePermissionServiceOptions = {}): PermissionService {
112
- const pluginPermissions = opts.pluginPermissions ?? []
113
- const ownerWildcardExclusions = opts.ownerWildcardExclusions ?? []
114
- let resolved = buildRoleTable(opts.roles ?? {}, pluginPermissions, ownerWildcardExclusions)
124
+ let pluginPermissions = opts.pluginPermissions ?? []
125
+ let ownerWildcardExclusions = opts.ownerWildcardExclusions ?? []
126
+ let lastRoles = opts.roles ?? {}
127
+ let resolved = buildRoleTable(lastRoles, pluginPermissions, ownerWildcardExclusions)
115
128
  let byName = new Map(resolved.map((r) => [r.name, r]))
116
129
 
117
130
  function resolveRole(origin: SessionOrigin | undefined): string {
@@ -186,7 +199,14 @@ export function createPermissionService(opts: CreatePermissionServiceOptions = {
186
199
  return { role: name, permissions: role?.permissions ?? [] }
187
200
  },
188
201
  replaceRoles(roles) {
189
- resolved = buildRoleTable(roles ?? {}, pluginPermissions, ownerWildcardExclusions)
202
+ lastRoles = roles ?? {}
203
+ resolved = buildRoleTable(lastRoles, pluginPermissions, ownerWildcardExclusions)
204
+ byName = new Map(resolved.map((r) => [r.name, r]))
205
+ },
206
+ replacePluginPermissions(next) {
207
+ pluginPermissions = next.pluginPermissions
208
+ ownerWildcardExclusions = next.ownerWildcardExclusions
209
+ resolved = buildRoleTable(lastRoles, pluginPermissions, ownerWildcardExclusions)
190
210
  byName = new Map(resolved.map((r) => [r.name, r]))
191
211
  },
192
212
  }
@@ -15,9 +15,7 @@ export type LoadPluginEntryFn = (entry: string, agentDir: string) => Promise<Res
15
15
 
16
16
  // Thrown only when a plugin entry cannot be resolved at all (uninstalled
17
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.
18
+ // treats this as non-fatal and skips the entry.
21
19
  export class PluginNotFoundError extends Error {
22
20
  readonly entry: string
23
21
  constructor(entry: string, message: string, options?: { cause?: unknown }) {
@@ -27,6 +25,20 @@ export class PluginNotFoundError extends Error {
27
25
  }
28
26
  }
29
27
 
28
+ // Thrown when a plugin entry violates a security boundary (e.g. a local path
29
+ // escaping the agent directory). Stays fatal for ALL plugins — even the
30
+ // per-plugin tolerance for user plugin bugs MUST NOT swallow this, or a
31
+ // malicious typeclaw.json could point at arbitrary host files and have the
32
+ // failure silently downgraded to a warning.
33
+ export class PluginSecurityError extends Error {
34
+ readonly entry: string
35
+ constructor(entry: string, message: string) {
36
+ super(message)
37
+ this.name = 'PluginSecurityError'
38
+ this.entry = entry
39
+ }
40
+ }
41
+
30
42
  export async function loadPluginEntry(entry: string, agentDir: string): Promise<ResolvedPlugin> {
31
43
  if (isLocalPath(entry)) {
32
44
  return loadLocal(entry, agentDir)
@@ -44,7 +56,7 @@ async function loadLocal(entry: string, agentDir: string): Promise<ResolvedPlugi
44
56
  // cannot point at arbitrary files on the host.
45
57
  const rel = relative(agentDir, resolved)
46
58
  if (rel.startsWith('..') || isAbsolute(rel)) {
47
- throw new Error(`plugin path escapes agent directory: ${entry} (resolved to ${resolved})`)
59
+ throw new PluginSecurityError(entry, `plugin path escapes agent directory: ${entry} (resolved to ${resolved})`)
48
60
  }
49
61
  if (!existsSync(resolved)) {
50
62
  throw new PluginNotFoundError(entry, `plugin path does not exist: ${entry} (resolved to ${resolved})`)
@@ -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[] {
@@ -10,6 +10,13 @@ export type RequestReloadOptions = {
10
10
 
11
11
  const DEFAULT_TIMEOUT_MS = 30_000
12
12
 
13
+ export class ReloadConnectionError extends Error {
14
+ constructor(message: string) {
15
+ super(message)
16
+ this.name = 'ReloadConnectionError'
17
+ }
18
+ }
19
+
13
20
  export async function requestReload({
14
21
  url,
15
22
  scope,
@@ -26,11 +33,15 @@ export async function requestReload({
26
33
  }
27
34
  const onError = (err: unknown) => {
28
35
  cleanup()
29
- reject(new Error(`failed to connect to ${displayUrl}: ${err instanceof Error ? err.message : String(err)}`))
36
+ reject(
37
+ new ReloadConnectionError(
38
+ `failed to connect to ${displayUrl}: ${err instanceof Error ? err.message : String(err)}`,
39
+ ),
40
+ )
30
41
  }
31
42
  const onClose = () => {
32
43
  cleanup()
33
- reject(new Error(`connection to ${displayUrl} closed before opening`))
44
+ reject(new ReloadConnectionError(`connection to ${displayUrl} closed before opening`))
34
45
  }
35
46
  const cleanup = () => {
36
47
  if (timer !== undefined) clearTimeout(timer)
@@ -41,7 +52,7 @@ export async function requestReload({
41
52
  timer = setTimeout(() => {
42
53
  cleanup()
43
54
  ws.close()
44
- reject(new Error(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
55
+ reject(new ReloadConnectionError(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
45
56
  }, timeoutMs)
46
57
  ws.addEventListener('open', onOpen, { once: true })
47
58
  ws.addEventListener('error', onError, { once: true })