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.
- package/package.json +2 -2
- package/src/agent/index.ts +11 -0
- package/src/agent/plugin-tools.ts +43 -21
- package/src/agent/restart/index.ts +6 -0
- package/src/agent/restart-handoff/index.ts +10 -0
- package/src/agent/system-prompt.ts +6 -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/adapters/line-attachment.ts +97 -0
- package/src/channels/adapters/line-classify.ts +14 -3
- package/src/channels/adapters/line.ts +5 -1
- package/src/channels/manager.ts +15 -3
- package/src/channels/router.ts +67 -16
- package/src/cli/hostd.ts +37 -4
- package/src/cli/reload.ts +26 -5
- package/src/cli/ui.ts +6 -0
- package/src/container/index.ts +1 -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/reload/client.ts +14 -3
- package/src/reload/docker-exec-client.ts +109 -0
- package/src/reload/index.ts +7 -1
- package/src/reload/recover.ts +38 -0
- package/src/run/codex-fetch-observer.ts +57 -5
- package/src/run/index.ts +5 -0
- package/src/sandbox/availability.ts +58 -15
- package/src/sandbox/errors.ts +26 -0
- package/src/sandbox/index.ts +6 -1
- package/src/sandbox/policy.ts +11 -0
- package/src/skills/typeclaw-config/SKILL.md +2 -2
- package/src/skills/typeclaw-monorepo/SKILL.md +7 -5
- 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
|
-
|
|
113
|
-
|
|
114
|
-
let
|
|
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
|
-
|
|
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
|
}
|
package/src/plugin/loader.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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})`)
|
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[] {
|
package/src/reload/client.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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 })
|