typeclaw 0.36.1 → 0.36.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/agent/index.ts +11 -0
- package/src/agent/restart/index.ts +6 -0
- package/src/agent/restart-handoff/index.ts +10 -0
- package/src/agent/tools/restart.ts +9 -0
- package/src/bundled-plugins/backup/README.md +11 -2
- package/src/bundled-plugins/backup/git-auth.ts +58 -0
- package/src/bundled-plugins/backup/index.ts +54 -0
- package/src/bundled-plugins/backup/runner.ts +82 -12
- package/src/channels/adapters/discord-bot-reactions.ts +1 -0
- package/src/channels/manager.ts +15 -3
- package/src/channels/router.ts +67 -16
- package/src/cli/hostd.ts +37 -4
- package/src/cli/ui.ts +6 -0
- package/src/container/start.ts +6 -0
- package/src/init/reconcile-plugin-deps.ts +45 -15
- package/src/init/restart-deps-preflight.ts +155 -0
- package/src/permissions/permissions.ts +24 -4
- package/src/plugin/loader.ts +16 -4
- package/src/plugin/manager.ts +175 -71
- package/src/run/codex-fetch-observer.ts +57 -5
- package/src/run/index.ts +5 -0
- package/src/sandbox/policy.ts +11 -0
package/src/channels/router.ts
CHANGED
|
@@ -138,6 +138,20 @@ export const SESSION_GC_INTERVAL_MS = 60 * 1000
|
|
|
138
138
|
// recovery paths (`source: 'system'`) bypass.
|
|
139
139
|
export const MAX_CHANNEL_SENDS_PER_TURN = 10
|
|
140
140
|
export const ENGAGE_REACTION_EMOJI = 'eyes'
|
|
141
|
+
// Best-effort "zipping it / going quiet" ack dropped on the triggering message
|
|
142
|
+
// when the model disengages (channel_disengage); fire-and-forget like engage :eyes:.
|
|
143
|
+
export const DISENGAGE_REACTION_EMOJI = 'zipper_mouth_face'
|
|
144
|
+
// Per-adapter fallback for platforms that cannot render the default. GitHub's
|
|
145
|
+
// Reactions API is a fixed 8-emoji set with no zipper-mouth; 'confused' is the
|
|
146
|
+
// closest "stepping back" signal it can post, so a GitHub disengage still acks
|
|
147
|
+
// instead of silently no-op'ing on the unsupported result.
|
|
148
|
+
const DISENGAGE_REACTION_EMOJI_OVERRIDES: Partial<Record<AdapterId, string>> = {
|
|
149
|
+
github: 'confused',
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function disengageReactionEmojiFor(adapter: AdapterId): string {
|
|
153
|
+
return DISENGAGE_REACTION_EMOJI_OVERRIDES[adapter] ?? DISENGAGE_REACTION_EMOJI
|
|
154
|
+
}
|
|
141
155
|
|
|
142
156
|
// Wake nudge pushed into a resumed channel session at boot so drain() has a
|
|
143
157
|
// non-empty batch and fires a turn. The substantive instruction the model acts
|
|
@@ -691,6 +705,12 @@ type LiveSession = {
|
|
|
691
705
|
type ChannelCommandContext = {
|
|
692
706
|
live: LiveSession | null
|
|
693
707
|
event: InboundMessage | null
|
|
708
|
+
// The user who actually invoked the command, supplied by BOTH dispatch
|
|
709
|
+
// paths (text: event.authorId; native slash: options.invokerId, where
|
|
710
|
+
// event is null). /restart stamps the resume handoff's triggeringAuthorId
|
|
711
|
+
// from this so a restart resumes under the INVOKER's author-scoped role,
|
|
712
|
+
// not whichever speaker happened to own the live turn.
|
|
713
|
+
invokerId: string | null
|
|
694
714
|
}
|
|
695
715
|
|
|
696
716
|
export type ExecuteCommandResult =
|
|
@@ -999,6 +1019,7 @@ export type RestartCommandContext = {
|
|
|
999
1019
|
originatingSessionId: string
|
|
1000
1020
|
originatingSessionFile?: string
|
|
1001
1021
|
handoffOrigin: { kind: 'channel'; key: ChannelKey }
|
|
1022
|
+
triggeringAuthorId?: string
|
|
1002
1023
|
}
|
|
1003
1024
|
|
|
1004
1025
|
export type ClaimHandlerInput = {
|
|
@@ -1125,18 +1146,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1125
1146
|
// Resolve the live session when one exists so the restart can write a
|
|
1126
1147
|
// resume handoff for this conversation; still bounces from a cold channel.
|
|
1127
1148
|
wantsLiveSession: true,
|
|
1128
|
-
handler: async ({ live }) => ({
|
|
1129
|
-
reply: await onRestart(
|
|
1130
|
-
live !== null
|
|
1131
|
-
? {
|
|
1132
|
-
originatingSessionId: live.sessionId,
|
|
1133
|
-
...(live.getTranscriptPath?.() !== undefined
|
|
1134
|
-
? { originatingSessionFile: live.getTranscriptPath!()! }
|
|
1135
|
-
: {}),
|
|
1136
|
-
handoffOrigin: { kind: 'channel', key: live.key },
|
|
1137
|
-
}
|
|
1138
|
-
: undefined,
|
|
1139
|
-
),
|
|
1149
|
+
handler: async ({ live, invokerId }) => ({
|
|
1150
|
+
reply: await onRestart(live !== null ? buildRestartCommandContext(live, invokerId) : undefined),
|
|
1140
1151
|
}),
|
|
1141
1152
|
})
|
|
1142
1153
|
}
|
|
@@ -1933,6 +1944,19 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1933
1944
|
}
|
|
1934
1945
|
}
|
|
1935
1946
|
|
|
1947
|
+
const buildRestartCommandContext = (live: LiveSession, invokerId: string | null): RestartCommandContext => {
|
|
1948
|
+
// Prefer the command invoker: a restart resumes under the author who ran
|
|
1949
|
+
// /restart, not whichever speaker last owned the live turn. Fall back to
|
|
1950
|
+
// live turn state only when the dispatch path supplied no invoker.
|
|
1951
|
+
const triggeringAuthorId = invokerId ?? live.currentTurnAuthorId ?? live.lastTurnAuthorId ?? undefined
|
|
1952
|
+
return {
|
|
1953
|
+
originatingSessionId: live.sessionId,
|
|
1954
|
+
...(live.getTranscriptPath?.() !== undefined ? { originatingSessionFile: live.getTranscriptPath!()! } : {}),
|
|
1955
|
+
handoffOrigin: { kind: 'channel', key: live.key },
|
|
1956
|
+
...(triggeringAuthorId !== undefined ? { triggeringAuthorId } : {}),
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1936
1960
|
const buildLiveOrigin = (live: LiveSession): SessionOrigin => {
|
|
1937
1961
|
const membership = readMembership(live.key)
|
|
1938
1962
|
const self = resolveSelfIdentity(live.key)
|
|
@@ -2234,7 +2258,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2234
2258
|
// Gating (channel.respond / session.control) and live-session resolution stay
|
|
2235
2259
|
// at the call sites — this helper only runs the handler and delivers the reply.
|
|
2236
2260
|
const runChannelCommand = async (event: InboundMessage, live: LiveSession | null): Promise<CommandResult> => {
|
|
2237
|
-
const result = await commands.execute(event.text, { live, event })
|
|
2261
|
+
const result = await commands.execute(event.text, { live, event, invokerId: event.authorId })
|
|
2238
2262
|
if (result.kind === 'handled' && result.reply !== undefined) {
|
|
2239
2263
|
await send(
|
|
2240
2264
|
{
|
|
@@ -3686,7 +3710,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3686
3710
|
|
|
3687
3711
|
let live: LiveSession
|
|
3688
3712
|
try {
|
|
3689
|
-
live = await ensureLive(key, undefined,
|
|
3713
|
+
live = await ensureLive(key, undefined, handoff.triggeringAuthorId, {
|
|
3690
3714
|
sessionId: handoff.originatingSessionId,
|
|
3691
3715
|
sessionFile: handoff.originatingSessionFile,
|
|
3692
3716
|
})
|
|
@@ -3785,7 +3809,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3785
3809
|
const resolved = resolveLiveSessionForCommand(liveSessions, key)
|
|
3786
3810
|
live = resolved.kind === 'found' ? resolved.session : null
|
|
3787
3811
|
}
|
|
3788
|
-
const result = await commands.execute(`/${lowered}`, { live, event: null })
|
|
3812
|
+
const result = await commands.execute(`/${lowered}`, { live, event: null, invokerId: options.invokerId })
|
|
3789
3813
|
if (result.kind === 'handled') {
|
|
3790
3814
|
return result.reply !== undefined
|
|
3791
3815
|
? { kind: 'handled', name: result.name, reply: result.reply }
|
|
@@ -3911,11 +3935,38 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3911
3935
|
// not re-grant the credit just cleared (see `disengagedTurn`). No-op when
|
|
3912
3936
|
// the key has no live session — the ledger clear above still stands.
|
|
3913
3937
|
const live = liveSessions.get(keyId)
|
|
3914
|
-
if (live && !live.destroyed)
|
|
3938
|
+
if (live && !live.destroyed) {
|
|
3939
|
+
live.disengagedTurn = live.turnSeq
|
|
3940
|
+
reactOnDisengage(live)
|
|
3941
|
+
}
|
|
3915
3942
|
logger.info(`[channels] ${keyId} sticky cleared count=${cleared}`)
|
|
3916
3943
|
return { keyId, cleared }
|
|
3917
3944
|
}
|
|
3918
3945
|
|
|
3946
|
+
const reactOnDisengage = (live: LiveSession): void => {
|
|
3947
|
+
if (live.currentTurnReactionRef === null) return
|
|
3948
|
+
void react({
|
|
3949
|
+
adapter: live.key.adapter,
|
|
3950
|
+
workspace: live.key.workspace,
|
|
3951
|
+
chat: live.key.chat,
|
|
3952
|
+
thread: live.key.thread,
|
|
3953
|
+
reactionRef: live.currentTurnReactionRef,
|
|
3954
|
+
emoji: disengageReactionEmojiFor(live.key.adapter),
|
|
3955
|
+
})
|
|
3956
|
+
.then((result) => {
|
|
3957
|
+
if (!result.ok && result.code !== 'unsupported') {
|
|
3958
|
+
logger.info(
|
|
3959
|
+
`[channels] disengage-react failed adapter=${live.key.adapter} chat=${live.key.chat}: ${result.error}`,
|
|
3960
|
+
)
|
|
3961
|
+
}
|
|
3962
|
+
})
|
|
3963
|
+
.catch((err) => {
|
|
3964
|
+
logger.info(
|
|
3965
|
+
`[channels] disengage-react threw adapter=${live.key.adapter} chat=${live.key.chat}: ${describe(err)}`,
|
|
3966
|
+
)
|
|
3967
|
+
})
|
|
3968
|
+
}
|
|
3969
|
+
|
|
3919
3970
|
return {
|
|
3920
3971
|
route,
|
|
3921
3972
|
send,
|
package/src/cli/hostd.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { createKakaoRenewalManager } from '@/hostd/kakao-renewal-manager'
|
|
|
7
7
|
import { createPortbrokerManager } from '@/hostd/portbroker-manager'
|
|
8
8
|
import type { SupervisorLogEvent, SupervisorRestart } from '@/hostd/supervisor'
|
|
9
9
|
import { computeSourceVersion, resolveSrcRoot, UNVERSIONED_SENTINEL } from '@/hostd/version'
|
|
10
|
+
import { validateRestartDeps, type RestartDepsPreflightResult } from '@/init/restart-deps-preflight'
|
|
10
11
|
|
|
11
12
|
export const hostdCommand = defineCommand({
|
|
12
13
|
meta: {
|
|
@@ -43,7 +44,7 @@ export const hostdCommand = defineCommand({
|
|
|
43
44
|
onShutdown: () => process.exit(0),
|
|
44
45
|
portbroker,
|
|
45
46
|
kakaoRenewal,
|
|
46
|
-
restartPreflight: buildHostdRestartPreflight(cliEntry, version),
|
|
47
|
+
restartPreflight: buildHostdRestartPreflight(cliEntry, version, defaultPreflightDeps),
|
|
47
48
|
restart: hostdRestart,
|
|
48
49
|
})
|
|
49
50
|
|
|
@@ -104,10 +105,42 @@ export function buildHostdRestart(
|
|
|
104
105
|
}
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
export
|
|
108
|
-
|
|
108
|
+
export type HostdPreflightDeps = {
|
|
109
|
+
loadConfigSync: (cwd: string) => Config
|
|
110
|
+
validateRestartDeps: (opts: { cwd: string; plugins: readonly string[] }) => Promise<RestartDepsPreflightResult>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const defaultPreflightDeps: HostdPreflightDeps = {
|
|
114
|
+
loadConfigSync,
|
|
115
|
+
validateRestartDeps,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function buildHostdRestartPreflight(
|
|
119
|
+
cliEntry: string,
|
|
120
|
+
daemonVersion: string,
|
|
121
|
+
deps: HostdPreflightDeps = defaultPreflightDeps,
|
|
122
|
+
): RestartPreflight {
|
|
123
|
+
return async ({ containerName, cwd }) => {
|
|
109
124
|
const drift = await detectSourceDrift(cliEntry, daemonVersion)
|
|
110
|
-
|
|
125
|
+
if (drift) return { ok: false, reason: drift }
|
|
126
|
+
|
|
127
|
+
// Read plugins through loadConfigSync, not validateConfig: a config that
|
|
128
|
+
// fails schema validation is caught later in buildHostdRestart (before
|
|
129
|
+
// stop). On read/parse failure we let the restart proceed — start() is the
|
|
130
|
+
// fail-closed gate, and a preflight that can't read config must not strand a
|
|
131
|
+
// healthy agent.
|
|
132
|
+
let plugins: readonly string[]
|
|
133
|
+
try {
|
|
134
|
+
plugins = deps.loadConfigSync(cwd).plugins
|
|
135
|
+
} catch {
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const depsCheck = await deps.validateRestartDeps({ cwd, plugins })
|
|
140
|
+
if (!depsCheck.ok) {
|
|
141
|
+
return { ok: false, reason: `restart refused for ${containerName}: ${depsCheck.reason}` }
|
|
142
|
+
}
|
|
143
|
+
return null
|
|
111
144
|
}
|
|
112
145
|
}
|
|
113
146
|
|
package/src/cli/ui.ts
CHANGED
|
@@ -152,6 +152,7 @@ export type StartLikeResult = {
|
|
|
152
152
|
containerId: string
|
|
153
153
|
hostd: { state: 'registered' } | { state: 'unavailable'; reason: string } | { state: 'disabled' }
|
|
154
154
|
autoUpgrade?: AutoUpgradeOutcome
|
|
155
|
+
skippedPlugins?: string[]
|
|
155
156
|
}
|
|
156
157
|
|
|
157
158
|
export function renderStartSuccess(result: StartLikeResult): string {
|
|
@@ -167,6 +168,11 @@ export function renderStartSuccess(result: StartLikeResult): string {
|
|
|
167
168
|
}
|
|
168
169
|
}
|
|
169
170
|
|
|
171
|
+
if (result.skippedPlugins && result.skippedPlugins.length > 0) {
|
|
172
|
+
const list = result.skippedPlugins.join(', ')
|
|
173
|
+
lines.push(`${c.yellow('Skipped plugins not found in the registry:')} ${list}`)
|
|
174
|
+
}
|
|
175
|
+
|
|
170
176
|
if (result.alreadyRunning) {
|
|
171
177
|
lines.push(`${c.green('●')} ${name} is already running on host port ${port}.`)
|
|
172
178
|
} else {
|
package/src/container/start.ts
CHANGED
|
@@ -140,6 +140,10 @@ export type StartResult =
|
|
|
140
140
|
// path — that one rebuilds the container from scratch.
|
|
141
141
|
alreadyRunning: boolean
|
|
142
142
|
autoUpgrade: AutoUpgradeOutcome
|
|
143
|
+
// npm plugins dropped this start because their package 404s in the
|
|
144
|
+
// registry. Non-fatal by design: a typo'd or unpublished plugin warns
|
|
145
|
+
// instead of blocking the launch.
|
|
146
|
+
skippedPlugins: string[]
|
|
143
147
|
}
|
|
144
148
|
| { ok: false; reason: string }
|
|
145
149
|
|
|
@@ -438,6 +442,7 @@ export async function start({
|
|
|
438
442
|
hostd: stripHostDaemonControl(hostd),
|
|
439
443
|
alreadyRunning: false,
|
|
440
444
|
autoUpgrade: upgrade,
|
|
445
|
+
skippedPlugins: pluginReconcile.skipped,
|
|
441
446
|
}
|
|
442
447
|
} catch (error) {
|
|
443
448
|
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
@@ -758,6 +763,7 @@ async function reportAlreadyRunning(exec: DockerExec, cwd: string, containerName
|
|
|
758
763
|
hostd: { state: 'disabled' },
|
|
759
764
|
alreadyRunning: true,
|
|
760
765
|
autoUpgrade: { kind: 'skipped-already-running' },
|
|
766
|
+
skippedPlugins: [],
|
|
761
767
|
}
|
|
762
768
|
}
|
|
763
769
|
|
|
@@ -6,12 +6,23 @@ import { splitPluginEntrySpec } from '@/plugin'
|
|
|
6
6
|
|
|
7
7
|
const PACKAGE_FILE = 'package.json'
|
|
8
8
|
|
|
9
|
+
const NOOP: ReconcilePluginDepsResult = { changed: false, files: [], skipped: [] }
|
|
10
|
+
|
|
9
11
|
export type ReconcilePluginDepsResult = {
|
|
10
12
|
changed: boolean
|
|
11
13
|
files: string[]
|
|
14
|
+
// Plugins skipped because their package could not be found in the registry
|
|
15
|
+
// (npm 404 / E404). A missing plugin must not block `start`: the entry is
|
|
16
|
+
// dropped from this reconcile pass and surfaced here so the caller can warn.
|
|
17
|
+
skipped: string[]
|
|
12
18
|
}
|
|
13
19
|
|
|
14
|
-
|
|
20
|
+
// Resolves a bare plugin name to its latest published version. Returns null
|
|
21
|
+
// when the package genuinely does not exist in the registry (404 / E404) so
|
|
22
|
+
// the caller can skip it without blocking start. Throws on every other failure
|
|
23
|
+
// (network outage, missing bun runtime, empty registry response) — those are
|
|
24
|
+
// transient or environmental, not "plugin not found", and must still block.
|
|
25
|
+
export type ResolveLatestVersion = (packageName: string) => Promise<string | null>
|
|
15
26
|
|
|
16
27
|
export type ReconcilePluginDepsOptions = {
|
|
17
28
|
cwd: string
|
|
@@ -31,27 +42,27 @@ export async function reconcilePluginDeps(options: ReconcilePluginDepsOptions):
|
|
|
31
42
|
const resolveLatest = options.resolveLatest ?? resolveLatestFromRegistry
|
|
32
43
|
|
|
33
44
|
const pkgPath = join(cwd, PACKAGE_FILE)
|
|
34
|
-
if (!existsSync(pkgPath)) return
|
|
45
|
+
if (!existsSync(pkgPath)) return NOOP
|
|
35
46
|
|
|
36
47
|
let raw: string
|
|
37
48
|
try {
|
|
38
49
|
raw = await readFile(pkgPath, 'utf8')
|
|
39
50
|
} catch {
|
|
40
|
-
return
|
|
51
|
+
return NOOP
|
|
41
52
|
}
|
|
42
53
|
|
|
43
54
|
let pkg: PackageJsonShape
|
|
44
55
|
try {
|
|
45
56
|
const parsed = JSON.parse(raw) as unknown
|
|
46
|
-
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return
|
|
57
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return NOOP
|
|
47
58
|
pkg = parsed as PackageJsonShape
|
|
48
59
|
} catch {
|
|
49
|
-
return
|
|
60
|
+
return NOOP
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
const dependencies = { ...pkg.dependencies }
|
|
53
64
|
const previousManaged = readManagedPlugins(pkg)
|
|
54
|
-
const desired = await resolveDesiredManaged(plugins, previousManaged, resolveLatest)
|
|
65
|
+
const { desired, skipped } = await resolveDesiredManaged(plugins, previousManaged, resolveLatest)
|
|
55
66
|
|
|
56
67
|
let changed = false
|
|
57
68
|
|
|
@@ -73,11 +84,11 @@ export async function reconcilePluginDeps(options: ReconcilePluginDepsOptions):
|
|
|
73
84
|
|
|
74
85
|
if (!managedEqual(previousManaged, desired)) changed = true
|
|
75
86
|
|
|
76
|
-
if (!changed) return { changed: false, files: [] }
|
|
87
|
+
if (!changed) return { changed: false, files: [], skipped }
|
|
77
88
|
|
|
78
89
|
const next = withManagedPlugins({ ...pkg, dependencies: sortKeys(dependencies) }, desired)
|
|
79
90
|
await writeFile(pkgPath, `${JSON.stringify(next, null, 2)}\n`)
|
|
80
|
-
return { changed: true, files: [PACKAGE_FILE] }
|
|
91
|
+
return { changed: true, files: [PACKAGE_FILE], skipped }
|
|
81
92
|
}
|
|
82
93
|
|
|
83
94
|
type PackageJsonShape = {
|
|
@@ -97,19 +108,30 @@ async function resolveDesiredManaged(
|
|
|
97
108
|
plugins: readonly string[],
|
|
98
109
|
previousManaged: Record<string, string>,
|
|
99
110
|
resolveLatest: ResolveLatestVersion,
|
|
100
|
-
): Promise<Record<string, string
|
|
111
|
+
): Promise<{ desired: Record<string, string>; skipped: string[] }> {
|
|
101
112
|
const desired: Record<string, string> = {}
|
|
113
|
+
const skipped: string[] = []
|
|
102
114
|
for (const entry of plugins) {
|
|
103
115
|
if (isLocalEntry(entry)) continue
|
|
104
116
|
const { name, versionSpec } = splitPluginEntrySpec(entry)
|
|
105
117
|
if (name.length === 0) continue
|
|
106
118
|
if (versionSpec !== undefined) {
|
|
107
119
|
desired[name] = versionSpec
|
|
108
|
-
|
|
109
|
-
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
const pinned = previousManaged[name]
|
|
123
|
+
if (pinned !== undefined) {
|
|
124
|
+
desired[name] = pinned
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
const resolved = await resolveLatest(name)
|
|
128
|
+
if (resolved === null) {
|
|
129
|
+
skipped.push(name)
|
|
130
|
+
continue
|
|
110
131
|
}
|
|
132
|
+
desired[name] = resolved
|
|
111
133
|
}
|
|
112
|
-
return sortKeys(desired)
|
|
134
|
+
return { desired: sortKeys(desired), skipped }
|
|
113
135
|
}
|
|
114
136
|
|
|
115
137
|
function isLocalEntry(entry: string): boolean {
|
|
@@ -154,7 +176,7 @@ function sortKeys(obj: Record<string, string>): Record<string, string> {
|
|
|
154
176
|
return out
|
|
155
177
|
}
|
|
156
178
|
|
|
157
|
-
async function resolveLatestFromRegistry(packageName: string): Promise<string> {
|
|
179
|
+
async function resolveLatestFromRegistry(packageName: string): Promise<string | null> {
|
|
158
180
|
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
159
181
|
if (!bun) throw new Error(`cannot resolve latest version for ${packageName}: bun runtime not available`)
|
|
160
182
|
const proc = bun.spawn({
|
|
@@ -164,10 +186,18 @@ async function resolveLatestFromRegistry(packageName: string): Promise<string> {
|
|
|
164
186
|
})
|
|
165
187
|
const code = await proc.exited
|
|
166
188
|
if (code !== 0) {
|
|
167
|
-
const stderr = await new Response(proc.stderr).text()
|
|
168
|
-
|
|
189
|
+
const stderr = (await new Response(proc.stderr).text()).trim()
|
|
190
|
+
if (isPackageNotFound(stderr)) return null
|
|
191
|
+
throw new Error(`failed to resolve latest version for ${packageName}: ${stderr || `exit ${code}`}`)
|
|
169
192
|
}
|
|
170
193
|
const version = (await new Response(proc.stdout).text()).trim().replace(/^["']|["']$/g, '')
|
|
171
194
|
if (version.length === 0) throw new Error(`registry returned no version for ${packageName}`)
|
|
172
195
|
return version
|
|
173
196
|
}
|
|
197
|
+
|
|
198
|
+
// A registry 404 means the package does not exist — a user typo or an
|
|
199
|
+
// unpublished plugin — which `start` must tolerate, not abort on. Network and
|
|
200
|
+
// auth failures are deliberately NOT matched here so they keep throwing.
|
|
201
|
+
export function isPackageNotFound(stderr: string): boolean {
|
|
202
|
+
return /\bE404\b/.test(stderr) || /\b404\b/.test(stderr) || /not found/i.test(stderr)
|
|
203
|
+
}
|
|
@@ -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})`)
|