typeclaw 0.1.5 → 0.2.0
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/README.md +14 -12
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +7 -4
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +23 -9
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +799 -319
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +6 -1
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +8 -1
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +385 -12
- package/src/config/index.ts +7 -0
- package/src/config/models-mutation.ts +209 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +5 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +39 -58
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +50 -33
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +32 -6
- package/src/init/index.ts +190 -61
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +55 -6
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +68 -0
- package/src/server/index.ts +122 -11
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +38 -64
- package/src/skills/typeclaw-memory/SKILL.md +1 -1
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- package/typeclaw.schema.json +57 -45
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { AdapterId } from '@/channels/schema'
|
|
2
|
+
|
|
3
|
+
import type { MatchRule, Platform } from './match-rule'
|
|
4
|
+
|
|
5
|
+
const ADAPTER_TO_PLATFORM: Record<AdapterId, Platform> = {
|
|
6
|
+
'slack-bot': 'slack',
|
|
7
|
+
'discord-bot': 'discord',
|
|
8
|
+
'telegram-bot': 'telegram',
|
|
9
|
+
kakaotalk: 'kakao',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type MatchableOrigin =
|
|
13
|
+
| { kind: 'tui'; sessionId?: string }
|
|
14
|
+
| { kind: 'cron'; jobId?: string }
|
|
15
|
+
| { kind: 'subagent'; subagent?: string }
|
|
16
|
+
| {
|
|
17
|
+
kind: 'channel'
|
|
18
|
+
adapter: AdapterId
|
|
19
|
+
workspace: string
|
|
20
|
+
chat: string
|
|
21
|
+
lastInboundAuthorId?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function matchesOrigin(rule: MatchRule, origin: MatchableOrigin): boolean {
|
|
25
|
+
switch (rule.kind) {
|
|
26
|
+
case 'wildcard':
|
|
27
|
+
return origin.kind === 'channel'
|
|
28
|
+
case 'tui':
|
|
29
|
+
return origin.kind === 'tui'
|
|
30
|
+
case 'cron':
|
|
31
|
+
return origin.kind === 'cron'
|
|
32
|
+
case 'subagent':
|
|
33
|
+
if (origin.kind !== 'subagent') return false
|
|
34
|
+
if (rule.subagent === undefined) return true
|
|
35
|
+
return origin.subagent === rule.subagent
|
|
36
|
+
case 'channel':
|
|
37
|
+
return matchesChannel(rule, origin)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function matchesChannel(rule: Extract<MatchRule, { kind: 'channel' }>, origin: MatchableOrigin): boolean {
|
|
42
|
+
if (origin.kind !== 'channel') return false
|
|
43
|
+
if (ADAPTER_TO_PLATFORM[origin.adapter] !== rule.platform) return false
|
|
44
|
+
|
|
45
|
+
if (rule.bucket !== undefined) {
|
|
46
|
+
if (!matchesBucket(rule.bucket, origin)) return false
|
|
47
|
+
if (rule.chat !== undefined && rule.chat !== origin.chat) return false
|
|
48
|
+
} else {
|
|
49
|
+
if (rule.workspace !== undefined && rule.workspace !== origin.workspace) return false
|
|
50
|
+
if (rule.chat !== undefined && rule.chat !== origin.chat) return false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (rule.author !== undefined && rule.author !== origin.lastInboundAuthorId) return false
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// DM and group buckets are inferred from the workspace/chat shape of the
|
|
58
|
+
// inbound origin. Different adapters mark DMs differently — Slack uses an
|
|
59
|
+
// `@dm` workspace marker today, Discord uses a `dm` workspace marker, and
|
|
60
|
+
// KakaoTalk preserves group/open/dm explicitly in its workspace field. We
|
|
61
|
+
// keep the heuristic narrow: a rule that says `slack:dm/*` matches only when
|
|
62
|
+
// the origin's workspace is `@dm` (Slack) or `dm` (Discord). KakaoTalk uses
|
|
63
|
+
// the workspace prefix itself.
|
|
64
|
+
function matchesBucket(
|
|
65
|
+
bucket: 'dm' | 'group' | 'open',
|
|
66
|
+
origin: Extract<MatchableOrigin, { kind: 'channel' }>,
|
|
67
|
+
): boolean {
|
|
68
|
+
const platform = ADAPTER_TO_PLATFORM[origin.adapter]
|
|
69
|
+
if (platform === 'kakao') {
|
|
70
|
+
if (bucket === 'dm') return origin.workspace === '@kakao-dm'
|
|
71
|
+
if (bucket === 'group') return origin.workspace === '@kakao-group'
|
|
72
|
+
if (bucket === 'open') return origin.workspace === '@kakao-open'
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
if (bucket !== 'dm') return false
|
|
76
|
+
if (platform === 'slack') return origin.workspace === '@dm'
|
|
77
|
+
if (platform === 'discord') return origin.workspace === 'dm' || origin.workspace === '@dm'
|
|
78
|
+
if (platform === 'telegram') return origin.workspace === 'dm' || origin.workspace.startsWith('@')
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import { isBuiltinRoleName } from './builtins'
|
|
4
|
+
import { type MatchRule, MATCH_RULE_REGEX_SOURCE, parseMatchRule } from './match-rule'
|
|
5
|
+
|
|
6
|
+
// The `.regex()` is added so the generated JSON Schema emits a `pattern`
|
|
7
|
+
// for editor-time validation (catches typos like `tem:T0123` before the
|
|
8
|
+
// agent ever boots). The pattern is intentionally a permissive
|
|
9
|
+
// over-approximation of what `parseMatchRule` accepts; the parser owns
|
|
10
|
+
// the precise semantic errors (typo suggestions, redundant-form rejection,
|
|
11
|
+
// etc.) at boot time. Layering the two means editors flag obvious shape
|
|
12
|
+
// mistakes immediately and the parser flags the rest with actionable
|
|
13
|
+
// messages.
|
|
14
|
+
const matchRuleSchema: z.ZodType<MatchRule> = z
|
|
15
|
+
.string()
|
|
16
|
+
.regex(new RegExp(MATCH_RULE_REGEX_SOURCE), {
|
|
17
|
+
message: "match rule must look like 'tui' / 'slack:T0123' / 'discord:9999 author:U_X'",
|
|
18
|
+
})
|
|
19
|
+
.transform((raw, ctx) => {
|
|
20
|
+
const parsed = parseMatchRule(raw)
|
|
21
|
+
if (!parsed.ok) {
|
|
22
|
+
ctx.addIssue({ code: 'custom', message: parsed.error })
|
|
23
|
+
return z.NEVER
|
|
24
|
+
}
|
|
25
|
+
return parsed.value
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
export const MATCH_RULE_JSON_SCHEMA_PATTERN = MATCH_RULE_REGEX_SOURCE
|
|
29
|
+
|
|
30
|
+
const PERMISSION_NAME = /^[a-z][a-z0-9]*(\.[a-z][a-zA-Z0-9]*)+$/
|
|
31
|
+
|
|
32
|
+
const permissionSchema = z.string().min(1).regex(PERMISSION_NAME, {
|
|
33
|
+
message: "permission must be lowercase dotted form like 'cron.schedule' or 'security.bypass.foo'",
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const roleConfigSchema = z
|
|
37
|
+
.object({
|
|
38
|
+
match: z.array(matchRuleSchema).default([]),
|
|
39
|
+
permissions: z.array(permissionSchema).optional(),
|
|
40
|
+
})
|
|
41
|
+
.strict()
|
|
42
|
+
|
|
43
|
+
export type RoleConfig = z.infer<typeof roleConfigSchema>
|
|
44
|
+
|
|
45
|
+
export const rolesConfigSchema = z.record(z.string(), roleConfigSchema).superRefine((roles, ctx) => {
|
|
46
|
+
for (const [name, role] of Object.entries(roles)) {
|
|
47
|
+
if (!isValidRoleName(name)) {
|
|
48
|
+
ctx.addIssue({
|
|
49
|
+
code: 'custom',
|
|
50
|
+
path: [name],
|
|
51
|
+
message: `role name '${name}' must match /^[a-z][a-z0-9-]*$/`,
|
|
52
|
+
})
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
if (!isBuiltinRoleName(name)) {
|
|
56
|
+
if (role.permissions === undefined) {
|
|
57
|
+
ctx.addIssue({
|
|
58
|
+
code: 'custom',
|
|
59
|
+
path: [name, 'permissions'],
|
|
60
|
+
message: `custom role '${name}' must declare 'permissions' (built-in defaults apply only to built-in role names)`,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
if (role.match.length === 0) {
|
|
64
|
+
ctx.addIssue({
|
|
65
|
+
code: 'custom',
|
|
66
|
+
path: [name, 'match'],
|
|
67
|
+
message: `custom role '${name}' must declare at least one 'match' rule`,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
export type RolesConfig = z.infer<typeof rolesConfigSchema>
|
|
75
|
+
|
|
76
|
+
const ROLE_NAME = /^[a-z][a-z0-9-]*$/
|
|
77
|
+
function isValidRoleName(name: string): boolean {
|
|
78
|
+
return ROLE_NAME.test(name)
|
|
79
|
+
}
|
package/src/plugin/context.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PermissionService } from '@/permissions'
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import type { PluginContext, PluginLogger, SpawnSubagentOptions } from './types'
|
|
4
|
+
|
|
5
|
+
export type SpawnSubagentFn = (name: string, payload?: unknown, options?: SpawnSubagentOptions) => Promise<void>
|
|
4
6
|
|
|
5
7
|
export type CreatePluginContextOptions<TConfig> = {
|
|
6
8
|
name: string
|
|
@@ -8,6 +10,7 @@ export type CreatePluginContextOptions<TConfig> = {
|
|
|
8
10
|
agentDir: string
|
|
9
11
|
config: TConfig
|
|
10
12
|
logger: PluginLogger
|
|
13
|
+
permissions: PermissionService
|
|
11
14
|
spawnSubagent: SpawnSubagentFn
|
|
12
15
|
isBooted: () => boolean
|
|
13
16
|
}
|
|
@@ -19,13 +22,14 @@ export function createPluginContext<TConfig>(opts: CreatePluginContextOptions<TC
|
|
|
19
22
|
agentDir: opts.agentDir,
|
|
20
23
|
config: opts.config,
|
|
21
24
|
logger: opts.logger,
|
|
22
|
-
|
|
25
|
+
permissions: opts.permissions,
|
|
26
|
+
spawnSubagent: async (name: string, payload?: unknown, options?: SpawnSubagentOptions) => {
|
|
23
27
|
if (!opts.isBooted()) {
|
|
24
28
|
throw new Error(
|
|
25
29
|
`plugin ${opts.name}: spawnSubagent("${name}") called before boot completed; subagent registry is not yet wired`,
|
|
26
30
|
)
|
|
27
31
|
}
|
|
28
|
-
await opts.spawnSubagent(name, payload)
|
|
32
|
+
await opts.spawnSubagent(name, payload, options)
|
|
29
33
|
},
|
|
30
34
|
})
|
|
31
35
|
}
|
package/src/plugin/define.ts
CHANGED
|
@@ -6,9 +6,11 @@ type DefinePluginSpec<S extends z.ZodType<unknown> | undefined> =
|
|
|
6
6
|
S extends z.ZodType<infer T>
|
|
7
7
|
? {
|
|
8
8
|
configSchema: S
|
|
9
|
+
permissions?: readonly string[]
|
|
9
10
|
plugin: (ctx: PluginContext<T>) => Promise<PluginExports>
|
|
10
11
|
}
|
|
11
12
|
: {
|
|
13
|
+
permissions?: readonly string[]
|
|
12
14
|
plugin: (ctx: PluginContext<unknown>) => Promise<PluginExports>
|
|
13
15
|
}
|
|
14
16
|
|
package/src/plugin/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ export type {
|
|
|
36
36
|
SessionIdleEvent,
|
|
37
37
|
SessionPromptEvent,
|
|
38
38
|
SessionStartEvent,
|
|
39
|
+
SpawnSubagentOptions,
|
|
39
40
|
Subagent,
|
|
40
41
|
SubagentContext,
|
|
41
42
|
Tool,
|
|
@@ -54,6 +55,7 @@ export {
|
|
|
54
55
|
type LoadPluginsOptions,
|
|
55
56
|
type LoadPluginsResult,
|
|
56
57
|
} from './manager'
|
|
58
|
+
export type { PermissionService } from '@/permissions'
|
|
57
59
|
export type { LoadPluginEntryFn, ResolvedPlugin } from './loader'
|
|
58
60
|
export { loadPluginEntry, derivePluginNameFromPackage } from './loader'
|
|
59
61
|
export { materializeSkills, type MaterializedSkills, type SkillEntry } from './skills'
|
package/src/plugin/manager.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
3
|
import type { CronJob } from '@/cron'
|
|
4
|
+
import {
|
|
5
|
+
createPermissionService,
|
|
6
|
+
findUnknownPermissions,
|
|
7
|
+
type PermissionService,
|
|
8
|
+
type RolesConfig,
|
|
9
|
+
} from '@/permissions'
|
|
4
10
|
|
|
5
11
|
import { createPluginContext, createPluginLogger, type SpawnSubagentFn } from './context'
|
|
6
12
|
import { createHookBus, type HookBus } from './hooks'
|
|
@@ -13,6 +19,7 @@ export type LoadPluginsOptions = {
|
|
|
13
19
|
agentDir: string
|
|
14
20
|
configsByName: Record<string, unknown>
|
|
15
21
|
loadEntry?: LoadPluginEntryFn
|
|
22
|
+
roles?: RolesConfig
|
|
16
23
|
// Bundled plugins resolved by the runtime (not from typeclaw.json). Loaded
|
|
17
24
|
// before user-declared `entries` so a config block named after a bundled
|
|
18
25
|
// plugin (e.g. "memory") is consumed by the bundled plugin, and so plugin-
|
|
@@ -23,6 +30,8 @@ export type LoadPluginsOptions = {
|
|
|
23
30
|
export type LoadPluginsResult = {
|
|
24
31
|
registry: PluginRegistry
|
|
25
32
|
hooks: HookBus
|
|
33
|
+
permissions: PermissionService
|
|
34
|
+
declaredPermissions: readonly string[]
|
|
26
35
|
loadedPlugins: { name: string; version: string | undefined; source: string }[]
|
|
27
36
|
markBooted: () => void
|
|
28
37
|
setSpawnSubagent: (fn: SpawnSubagentFn) => void
|
|
@@ -46,6 +55,23 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
|
|
|
46
55
|
)),
|
|
47
56
|
]
|
|
48
57
|
|
|
58
|
+
const declaredPermissions = collectDeclaredPermissions(allPlugins)
|
|
59
|
+
const permissions = createPermissionService({
|
|
60
|
+
...(opts.roles !== undefined ? { roles: opts.roles } : {}),
|
|
61
|
+
pluginPermissions: declaredPermissions,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// Non-fatal: surface user-declared `permissions[]` strings that aren't in
|
|
65
|
+
// the known set, so a typo like `security.bypass.secretExfilBach` is
|
|
66
|
+
// visible at boot rather than silently failing to bypass the matching
|
|
67
|
+
// guard. We log instead of throw because the runtime still functions --
|
|
68
|
+
// the unknown string just never matches anything.
|
|
69
|
+
for (const warning of findUnknownPermissions(opts.roles, declaredPermissions)) {
|
|
70
|
+
console.warn(
|
|
71
|
+
`[permissions] role "${warning.role}" declares unknown permission "${warning.permission}" — ${warning.hint}`,
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
49
75
|
for (const { entry, resolved } of allPlugins) {
|
|
50
76
|
if (loaded.find((l) => l.name === resolved.name)) {
|
|
51
77
|
throw new Error(`plugin name conflict: ${resolved.name} (entry ${entry}) already loaded`)
|
|
@@ -72,6 +98,7 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
|
|
|
72
98
|
agentDir: opts.agentDir,
|
|
73
99
|
config: validatedConfig as never,
|
|
74
100
|
logger,
|
|
101
|
+
permissions,
|
|
75
102
|
spawnSubagent: (name, payload) => spawnSubagentImpl(name, payload),
|
|
76
103
|
isBooted: () => booted,
|
|
77
104
|
})
|
|
@@ -106,6 +133,8 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
|
|
|
106
133
|
return {
|
|
107
134
|
registry,
|
|
108
135
|
hooks,
|
|
136
|
+
permissions,
|
|
137
|
+
declaredPermissions,
|
|
109
138
|
loadedPlugins: loaded,
|
|
110
139
|
markBooted: () => {
|
|
111
140
|
booted = true
|
|
@@ -116,6 +145,18 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
|
|
|
116
145
|
}
|
|
117
146
|
}
|
|
118
147
|
|
|
148
|
+
function collectDeclaredPermissions(
|
|
149
|
+
plugins: readonly { entry: string; resolved: ResolvedPlugin }[],
|
|
150
|
+
): readonly string[] {
|
|
151
|
+
const out: string[] = []
|
|
152
|
+
for (const { resolved } of plugins) {
|
|
153
|
+
for (const perm of resolved.defined.permissions ?? []) {
|
|
154
|
+
if (!out.includes(perm)) out.push(perm)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out
|
|
158
|
+
}
|
|
159
|
+
|
|
119
160
|
export function summarizeLoaded(loaded: LoadPluginsResult['loadedPlugins'], registry: PluginRegistry): string {
|
|
120
161
|
const head = loaded.map((p) => (p.version !== undefined ? `${p.name} v${p.version}` : p.name)).join(', ')
|
|
121
162
|
const counts = [
|
package/src/plugin/registry.ts
CHANGED
|
@@ -150,6 +150,13 @@ function assertNotEmpty(kind: string, value: string, pluginName: string): void {
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
function toCronJob(globalId: string, spec: PluginCronJob): CronJob {
|
|
153
|
+
// Plugin-contributed jobs default to `owner` because they are part of the
|
|
154
|
+
// agent's bundled (or operator-installed) runtime, not user-channel
|
|
155
|
+
// schedules. Without this default they would resolve to `guest` and the
|
|
156
|
+
// bundled memory dreaming cron (which writes MEMORY.md, runs git, etc.)
|
|
157
|
+
// would lose every security bypass. Hand-authored cron.json entries take
|
|
158
|
+
// a different path and must declare scheduledByRole explicitly.
|
|
159
|
+
const scheduledByRole: PromptJob['scheduledByRole'] = 'owner'
|
|
153
160
|
if (spec.kind === 'prompt') {
|
|
154
161
|
const job: PromptJob = {
|
|
155
162
|
id: globalId,
|
|
@@ -157,6 +164,7 @@ function toCronJob(globalId: string, spec: PluginCronJob): CronJob {
|
|
|
157
164
|
enabled: spec.enabled ?? true,
|
|
158
165
|
kind: 'prompt',
|
|
159
166
|
prompt: spec.prompt,
|
|
167
|
+
scheduledByRole,
|
|
160
168
|
...(spec.timezone !== undefined ? { timezone: spec.timezone } : {}),
|
|
161
169
|
...(spec.subagent !== undefined ? { subagent: spec.subagent } : {}),
|
|
162
170
|
...(spec.payload !== undefined ? { payload: spec.payload } : {}),
|
|
@@ -169,6 +177,7 @@ function toCronJob(globalId: string, spec: PluginCronJob): CronJob {
|
|
|
169
177
|
enabled: spec.enabled ?? true,
|
|
170
178
|
kind: 'exec',
|
|
171
179
|
command: spec.command,
|
|
180
|
+
scheduledByRole,
|
|
172
181
|
...(spec.timezone !== undefined ? { timezone: spec.timezone } : {}),
|
|
173
182
|
}
|
|
174
183
|
}
|
package/src/plugin/types.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { z } from 'zod'
|
|
2
2
|
|
|
3
3
|
import type { SessionOrigin } from '@/agent/session-origin'
|
|
4
|
+
import type { ToolResultBudget } from '@/agent/tool-result-budget'
|
|
5
|
+
import type { PermissionService } from '@/permissions'
|
|
4
6
|
|
|
5
7
|
export type ContentPart = { type: 'text'; text: string } | { type: 'image'; mimeType: string; data: string }
|
|
6
8
|
|
|
@@ -40,6 +42,13 @@ export type RunSession = (override?: { userPrompt?: string }) => Promise<void>
|
|
|
40
42
|
|
|
41
43
|
export type Subagent<P = unknown> = {
|
|
42
44
|
systemPrompt: string
|
|
45
|
+
// Model profile this subagent prefers. Resolved against `models` in
|
|
46
|
+
// typeclaw.json at session construction. Unknown profile names fall back to
|
|
47
|
+
// `default` with a warning. Well-known names: `default`, `fast`, `deep`,
|
|
48
|
+
// `vision`. Subagents that want a specific tier (e.g. memory-logger wants
|
|
49
|
+
// `fast`, dreaming wants `deep`) declare it here so the user only has to
|
|
50
|
+
// map tier → model in config rather than wire each subagent individually.
|
|
51
|
+
profile?: string
|
|
43
52
|
tools?: BuiltinToolRef[]
|
|
44
53
|
customTools?: Tool<any>[]
|
|
45
54
|
payloadSchema?: z.ZodType<P>
|
|
@@ -50,6 +59,16 @@ export type Subagent<P = unknown> = {
|
|
|
50
59
|
// parentSessionId so different parent sessions run in parallel while
|
|
51
60
|
// duplicate runs against the same session deduplicate.
|
|
52
61
|
inFlightKey?: (payload: P) => string
|
|
62
|
+
// Defensive ceiling on cumulative bytes of tool-result text per subagent
|
|
63
|
+
// run, applied to the named tools only. Once exceeded, subsequent calls to
|
|
64
|
+
// those tools short-circuit with a fixed message instructing the agent to
|
|
65
|
+
// stop reading. See `src/agent/tool-result-budget.ts` for the full
|
|
66
|
+
// rationale; the short version is: a single broken tool (e.g. find_entry
|
|
67
|
+
// failing because of a schema mismatch) can cause an agent to fall back to
|
|
68
|
+
// chunked reads of huge files, ballooning subagent token cost. The budget
|
|
69
|
+
// bounds the blast radius without changing per-call semantics for healthy
|
|
70
|
+
// runs.
|
|
71
|
+
toolResultBudget?: ToolResultBudget
|
|
53
72
|
}
|
|
54
73
|
|
|
55
74
|
// Cron job map keys are local; the runtime prefixes with `__plugin_<plugin-name>_`
|
|
@@ -88,6 +107,7 @@ export type SessionStartEvent = {
|
|
|
88
107
|
|
|
89
108
|
export type SessionEndEvent = {
|
|
90
109
|
sessionId: string
|
|
110
|
+
origin?: SessionOrigin
|
|
91
111
|
}
|
|
92
112
|
|
|
93
113
|
export type SessionIdleEvent = {
|
|
@@ -132,6 +152,7 @@ export type ToolBeforeEvent = {
|
|
|
132
152
|
sessionId: string
|
|
133
153
|
callId: string
|
|
134
154
|
args: Record<string, unknown>
|
|
155
|
+
origin?: SessionOrigin
|
|
135
156
|
}
|
|
136
157
|
|
|
137
158
|
export type ToolBeforeResult = void | undefined | { block: true; reason: string }
|
|
@@ -168,13 +189,25 @@ export type PluginLogger = {
|
|
|
168
189
|
error: (msg: string) => void
|
|
169
190
|
}
|
|
170
191
|
|
|
192
|
+
export type SpawnSubagentOptions = {
|
|
193
|
+
// Identifies the spawning session so the subagent's session origin carries
|
|
194
|
+
// parent provenance. Hook handlers that own this context (e.g. session.idle,
|
|
195
|
+
// session.turn.end) should pass at minimum `parentSessionId` and
|
|
196
|
+
// `spawnedByOrigin: event.origin`. The runtime resolves `spawnedByRole`
|
|
197
|
+
// from the origin via the PermissionService, so the spawning session's
|
|
198
|
+
// role is inherited rather than forged from outside.
|
|
199
|
+
parentSessionId?: string
|
|
200
|
+
spawnedByOrigin?: SessionOrigin
|
|
201
|
+
}
|
|
202
|
+
|
|
171
203
|
export type PluginContext<TConfig = never> = {
|
|
172
204
|
readonly name: string
|
|
173
205
|
readonly version: string | undefined
|
|
174
206
|
readonly agentDir: string
|
|
175
207
|
readonly config: TConfig
|
|
176
208
|
readonly logger: PluginLogger
|
|
177
|
-
|
|
209
|
+
readonly permissions: PermissionService
|
|
210
|
+
spawnSubagent: (name: string, payload?: unknown, options?: SpawnSubagentOptions) => Promise<void>
|
|
178
211
|
}
|
|
179
212
|
|
|
180
213
|
export type PluginExports = {
|
|
@@ -233,5 +266,6 @@ export type PluginFixResult = {
|
|
|
233
266
|
|
|
234
267
|
export type DefinedPlugin<TConfig = never> = {
|
|
235
268
|
readonly configSchema?: z.ZodType<TConfig>
|
|
269
|
+
readonly permissions?: readonly string[]
|
|
236
270
|
readonly plugin: (ctx: PluginContext<TConfig>) => Promise<PluginExports>
|
|
237
271
|
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ClaimCompletedPayload,
|
|
3
|
+
ClaimErrorPayload,
|
|
4
|
+
ClaimStartedPayload,
|
|
5
|
+
ClientMessage,
|
|
6
|
+
ServerMessage,
|
|
7
|
+
} from '@/shared'
|
|
8
|
+
|
|
9
|
+
import { generateClaimCode } from './code'
|
|
10
|
+
|
|
11
|
+
export type ClaimSessionOptions = {
|
|
12
|
+
url: string
|
|
13
|
+
role: string
|
|
14
|
+
channel?: string
|
|
15
|
+
ttlMs?: number
|
|
16
|
+
connectTimeoutMs?: number
|
|
17
|
+
onStarted?: (payload: ClaimStartedPayload) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ClaimSessionResult =
|
|
21
|
+
| { kind: 'completed'; payload: ClaimCompletedPayload }
|
|
22
|
+
| { kind: 'error'; payload: ClaimErrorPayload }
|
|
23
|
+
| { kind: 'timeout' }
|
|
24
|
+
|
|
25
|
+
const DEFAULT_TTL_MS = 10 * 60 * 1000
|
|
26
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 30_000
|
|
27
|
+
|
|
28
|
+
export async function runClaimSession(opts: ClaimSessionOptions): Promise<ClaimSessionResult> {
|
|
29
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS
|
|
30
|
+
const connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
|
|
31
|
+
const code = generateClaimCode()
|
|
32
|
+
|
|
33
|
+
const ws = new WebSocket(opts.url)
|
|
34
|
+
const displayUrl = redactUrl(opts.url)
|
|
35
|
+
await waitForOpen(ws, displayUrl, connectTimeoutMs)
|
|
36
|
+
await waitForConnected(ws, displayUrl, connectTimeoutMs)
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const request: ClientMessage = {
|
|
40
|
+
type: 'claim_start',
|
|
41
|
+
code,
|
|
42
|
+
role: opts.role,
|
|
43
|
+
ttlMs,
|
|
44
|
+
...(opts.channel !== undefined ? { channel: opts.channel } : {}),
|
|
45
|
+
}
|
|
46
|
+
ws.send(JSON.stringify(request))
|
|
47
|
+
|
|
48
|
+
return await waitForOutcome(ws, code, ttlMs, opts.onStarted)
|
|
49
|
+
} finally {
|
|
50
|
+
try {
|
|
51
|
+
const cancel: ClientMessage = { type: 'claim_cancel' }
|
|
52
|
+
ws.send(JSON.stringify(cancel))
|
|
53
|
+
} catch {}
|
|
54
|
+
ws.close()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function waitForOpen(ws: WebSocket, displayUrl: string, timeoutMs: number): Promise<void> {
|
|
59
|
+
await new Promise<void>((resolve, reject) => {
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
cleanup()
|
|
62
|
+
ws.close()
|
|
63
|
+
reject(new Error(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
|
|
64
|
+
}, timeoutMs)
|
|
65
|
+
const onOpen = () => {
|
|
66
|
+
cleanup()
|
|
67
|
+
resolve()
|
|
68
|
+
}
|
|
69
|
+
const onError = (err: unknown) => {
|
|
70
|
+
cleanup()
|
|
71
|
+
reject(new Error(`failed to connect to ${displayUrl}: ${err instanceof Error ? err.message : String(err)}`))
|
|
72
|
+
}
|
|
73
|
+
const onClose = () => {
|
|
74
|
+
cleanup()
|
|
75
|
+
reject(new Error(`connection to ${displayUrl} closed before opening`))
|
|
76
|
+
}
|
|
77
|
+
const cleanup = () => {
|
|
78
|
+
clearTimeout(timer)
|
|
79
|
+
ws.removeEventListener('open', onOpen)
|
|
80
|
+
ws.removeEventListener('error', onError)
|
|
81
|
+
ws.removeEventListener('close', onClose)
|
|
82
|
+
}
|
|
83
|
+
ws.addEventListener('open', onOpen, { once: true })
|
|
84
|
+
ws.addEventListener('error', onError, { once: true })
|
|
85
|
+
ws.addEventListener('close', onClose, { once: true })
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The server's WS `open` handler is async (it awaits createSession) and only
|
|
90
|
+
// registers per-ws state and sends `connected` after that resolves. Bun
|
|
91
|
+
// delivers any client messages received before `open` completes, but the
|
|
92
|
+
// server's `claim_start` handler silently drops messages when state is null.
|
|
93
|
+
// Waiting here mirrors what the TUI client does (see src/tui/index.ts) and
|
|
94
|
+
// fixes the hatching-time hang where the spinner stayed on "Generating your
|
|
95
|
+
// claim code..." until the ttl expired.
|
|
96
|
+
async function waitForConnected(ws: WebSocket, displayUrl: string, timeoutMs: number): Promise<void> {
|
|
97
|
+
await new Promise<void>((resolve, reject) => {
|
|
98
|
+
const timer = setTimeout(() => {
|
|
99
|
+
cleanup()
|
|
100
|
+
ws.close()
|
|
101
|
+
reject(new Error(`timed out waiting for connected message from ${displayUrl} after ${timeoutMs}ms`))
|
|
102
|
+
}, timeoutMs)
|
|
103
|
+
const onMessage = (event: MessageEvent): void => {
|
|
104
|
+
let msg: ServerMessage
|
|
105
|
+
try {
|
|
106
|
+
msg = JSON.parse(String(event.data)) as ServerMessage
|
|
107
|
+
} catch {
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if (msg.type === 'connected') {
|
|
111
|
+
cleanup()
|
|
112
|
+
resolve()
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (msg.type === 'error') {
|
|
116
|
+
cleanup()
|
|
117
|
+
reject(new Error(`server rejected connection to ${displayUrl}: ${msg.message}`))
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const onClose = () => {
|
|
121
|
+
cleanup()
|
|
122
|
+
reject(new Error(`connection to ${displayUrl} closed before the session was ready`))
|
|
123
|
+
}
|
|
124
|
+
const cleanup = () => {
|
|
125
|
+
clearTimeout(timer)
|
|
126
|
+
ws.removeEventListener('message', onMessage)
|
|
127
|
+
ws.removeEventListener('close', onClose)
|
|
128
|
+
}
|
|
129
|
+
ws.addEventListener('message', onMessage)
|
|
130
|
+
ws.addEventListener('close', onClose, { once: true })
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function waitForOutcome(
|
|
135
|
+
ws: WebSocket,
|
|
136
|
+
code: string,
|
|
137
|
+
ttlMs: number,
|
|
138
|
+
onStarted?: (payload: ClaimStartedPayload) => void,
|
|
139
|
+
): Promise<ClaimSessionResult> {
|
|
140
|
+
return new Promise<ClaimSessionResult>((resolve) => {
|
|
141
|
+
const timer = setTimeout(() => {
|
|
142
|
+
ws.removeEventListener('message', onMessage)
|
|
143
|
+
resolve({ kind: 'timeout' })
|
|
144
|
+
}, ttlMs + 5_000)
|
|
145
|
+
|
|
146
|
+
const onMessage = (event: MessageEvent): void => {
|
|
147
|
+
let msg: ServerMessage
|
|
148
|
+
try {
|
|
149
|
+
msg = JSON.parse(String(event.data)) as ServerMessage
|
|
150
|
+
} catch {
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
if (msg.type === 'claim_started' && msg.payload.code === code) {
|
|
154
|
+
onStarted?.(msg.payload)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
if (msg.type === 'claim_completed' && msg.payload.code === code) {
|
|
158
|
+
clearTimeout(timer)
|
|
159
|
+
ws.removeEventListener('message', onMessage)
|
|
160
|
+
resolve({ kind: 'completed', payload: msg.payload })
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
if (msg.type === 'claim_error' && msg.payload.code === code) {
|
|
164
|
+
clearTimeout(timer)
|
|
165
|
+
ws.removeEventListener('message', onMessage)
|
|
166
|
+
resolve({ kind: 'error', payload: msg.payload })
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
ws.addEventListener('message', onMessage)
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function redactUrl(url: string): string {
|
|
175
|
+
try {
|
|
176
|
+
const parsed = new URL(url)
|
|
177
|
+
if (parsed.searchParams.has('token')) parsed.searchParams.set('token', '<redacted>')
|
|
178
|
+
return parsed.toString()
|
|
179
|
+
} catch {
|
|
180
|
+
return url
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
// Role-claim codes are short, human-typeable tokens the operator sends from
|
|
4
|
+
// their host CLI to the bot via a channel DM to prove ownership of that
|
|
5
|
+
// channel identity. Shape: `claim-XXXX-YYYY` where each block is 4 chars
|
|
6
|
+
// from a Crockford-style base32 alphabet (0-9 + A-Z minus I, L, O, U to
|
|
7
|
+
// dodge OCR-confusable / profane shapes). 8 chars * 5 bits = 40 bits of
|
|
8
|
+
// entropy, which is overkill for a TTL'd in-memory window but cheap to
|
|
9
|
+
// display and dictate over voice.
|
|
10
|
+
//
|
|
11
|
+
// The `claim-` prefix lets the channel router recognize potential claim
|
|
12
|
+
// attempts in a DM body without scanning the whole text for hex blocks,
|
|
13
|
+
// and distinguishes claim DMs from normal first-message text like "hi"
|
|
14
|
+
// which would otherwise need a regex of its own to disambiguate.
|
|
15
|
+
|
|
16
|
+
export const CLAIM_CODE_PREFIX = 'claim-'
|
|
17
|
+
|
|
18
|
+
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
|
|
19
|
+
const BLOCK_SIZE = 4
|
|
20
|
+
const BLOCK_COUNT = 2
|
|
21
|
+
|
|
22
|
+
export function generateClaimCode(): string {
|
|
23
|
+
const bytes = randomBytes(BLOCK_SIZE * BLOCK_COUNT)
|
|
24
|
+
const chars: string[] = []
|
|
25
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
26
|
+
chars.push(ALPHABET[bytes[i]! % ALPHABET.length]!)
|
|
27
|
+
}
|
|
28
|
+
const blocks: string[] = []
|
|
29
|
+
for (let b = 0; b < BLOCK_COUNT; b++) {
|
|
30
|
+
blocks.push(chars.slice(b * BLOCK_SIZE, (b + 1) * BLOCK_SIZE).join(''))
|
|
31
|
+
}
|
|
32
|
+
return `${CLAIM_CODE_PREFIX}${blocks.join('-')}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Extracts the first claim-code-shaped token from inbound text. Returns
|
|
36
|
+
// the canonical-case (upper) code, or null. Tolerates surrounding
|
|
37
|
+
// whitespace, punctuation, and case — chat clients may auto-correct case
|
|
38
|
+
// or surround pastes with quotes/backticks.
|
|
39
|
+
export function extractClaimCode(text: string): string | null {
|
|
40
|
+
const pattern = new RegExp(
|
|
41
|
+
`${CLAIM_CODE_PREFIX}([0-9a-zA-Z]{${BLOCK_SIZE}}(?:-[0-9a-zA-Z]{${BLOCK_SIZE}}){${BLOCK_COUNT - 1}})`,
|
|
42
|
+
'i',
|
|
43
|
+
)
|
|
44
|
+
const match = pattern.exec(text)
|
|
45
|
+
if (!match) return null
|
|
46
|
+
return `${CLAIM_CODE_PREFIX}${match[1]!.toUpperCase()}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function normalizeClaimCode(code: string): string {
|
|
50
|
+
const trimmed = code.trim()
|
|
51
|
+
if (!trimmed.toLowerCase().startsWith(CLAIM_CODE_PREFIX)) return trimmed.toUpperCase()
|
|
52
|
+
return `${CLAIM_CODE_PREFIX}${trimmed.slice(CLAIM_CODE_PREFIX.length).toUpperCase()}`
|
|
53
|
+
}
|