typeclaw 0.3.1 → 0.5.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 +20 -15
- package/auth.schema.json +113 -0
- package/package.json +1 -1
- package/secrets.schema.json +113 -0
- package/src/agent/auth.ts +4 -2
- package/src/agent/index.ts +16 -28
- package/src/agent/model-fallback.ts +127 -0
- package/src/agent/session-meta.ts +1 -1
- package/src/agent/session-origin.ts +3 -2
- package/src/agent/tools/curl-impersonate.ts +300 -0
- package/src/agent/tools/ddg.ts +13 -88
- package/src/agent/tools/webfetch/fetch.ts +105 -2
- package/src/agent/tools/webfetch/tool.ts +4 -0
- package/src/bundled-plugins/agent-browser/shim.ts +47 -0
- package/src/bundled-plugins/backup/subagents.ts +2 -0
- package/src/bundled-plugins/memory/README.md +49 -12
- package/src/bundled-plugins/memory/citation-superset.ts +63 -0
- package/src/bundled-plugins/memory/dreaming.ts +105 -17
- package/src/bundled-plugins/memory/index.ts +2 -2
- package/src/bundled-plugins/memory/memory-logger.ts +45 -26
- package/src/bundled-plugins/memory/strength.ts +127 -0
- package/src/bundled-plugins/memory/topics.ts +75 -0
- package/src/bundled-plugins/security/index.ts +88 -43
- package/src/bundled-plugins/security/permissions.ts +36 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
- package/src/channels/adapters/github/auth-app.ts +120 -0
- package/src/channels/adapters/github/auth-pat.ts +50 -0
- package/src/channels/adapters/github/auth.ts +33 -0
- package/src/channels/adapters/github/channel-resolver.ts +30 -0
- package/src/channels/adapters/github/dedup.ts +26 -0
- package/src/channels/adapters/github/event-allowlist.ts +8 -0
- package/src/channels/adapters/github/fetch-attachment.ts +5 -0
- package/src/channels/adapters/github/history.ts +63 -0
- package/src/channels/adapters/github/inbound.ts +286 -0
- package/src/channels/adapters/github/index.ts +370 -0
- package/src/channels/adapters/github/managed-path.ts +54 -0
- package/src/channels/adapters/github/membership.ts +35 -0
- package/src/channels/adapters/github/outbound.ts +145 -0
- package/src/channels/adapters/github/webhook-register.ts +349 -0
- package/src/channels/manager.ts +94 -9
- package/src/channels/router.ts +194 -28
- package/src/channels/schema.ts +31 -1
- package/src/channels/tunnel-bridge.ts +51 -0
- package/src/channels/types.ts +3 -1
- package/src/cli/builtins.ts +28 -0
- package/src/cli/channel.ts +511 -25
- package/src/cli/container-command-client.ts +244 -0
- package/src/cli/cron.ts +173 -0
- package/src/cli/host-command-runner.ts +150 -0
- package/src/cli/index.ts +42 -1
- package/src/cli/init.ts +400 -67
- package/src/cli/model.ts +14 -4
- package/src/cli/oauth-callbacks.ts +49 -0
- package/src/cli/plugin-command-help.ts +49 -0
- package/src/cli/plugin-commands-dispatch.ts +112 -0
- package/src/cli/plugin-commands.ts +118 -0
- package/src/cli/provider.ts +3 -20
- package/src/cli/tui.ts +10 -2
- package/src/cli/tunnel.ts +533 -0
- package/src/cli/ui.ts +8 -3
- package/src/config/config.ts +134 -24
- package/src/config/models-mutation.ts +42 -8
- package/src/config/providers-mutation.ts +12 -8
- package/src/container/start.ts +48 -4
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +174 -48
- package/src/cron/index.ts +19 -2
- package/src/cron/list.ts +105 -0
- package/src/cron/scheduler.ts +12 -3
- package/src/cron/schema.ts +11 -3
- package/src/doctor/checks.ts +0 -50
- package/src/init/dockerfile.ts +165 -13
- package/src/init/ensure-deps.ts +15 -4
- package/src/init/github-webhook-install.ts +109 -0
- package/src/init/hatching.ts +2 -2
- package/src/init/index.ts +519 -12
- package/src/init/oauth-login.ts +17 -3
- package/src/init/run-bun-install.ts +17 -3
- package/src/init/run-owner-claim.ts +11 -2
- package/src/permissions/builtins.ts +29 -2
- package/src/permissions/match-rule.ts +24 -2
- package/src/permissions/permissions.ts +24 -7
- package/src/permissions/resolve.ts +1 -0
- package/src/plugin/define.ts +44 -1
- package/src/plugin/index.ts +18 -3
- package/src/plugin/manager.ts +16 -0
- package/src/plugin/registry.ts +85 -3
- package/src/plugin/types.ts +144 -1
- package/src/plugin/zod-introspect.ts +100 -0
- package/src/role-claim/match-rule.ts +2 -1
- package/src/run/index.ts +112 -4
- package/src/secrets/index.ts +1 -1
- package/src/secrets/schema.ts +21 -0
- package/src/server/command-runner.ts +476 -0
- package/src/server/index.ts +388 -5
- package/src/shared/index.ts +8 -0
- package/src/shared/protocol.ts +80 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
- package/src/skills/typeclaw-config/SKILL.md +27 -26
- package/src/skills/typeclaw-cron/SKILL.md +234 -3
- package/src/skills/typeclaw-memory/SKILL.md +25 -15
- package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
- package/src/skills/typeclaw-permissions/SKILL.md +35 -16
- package/src/skills/typeclaw-plugins/SKILL.md +251 -5
- package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
- package/src/test-helpers/wait-for.ts +50 -0
- package/src/tui/index.ts +70 -7
- package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
- package/src/tunnels/events.ts +14 -0
- package/src/tunnels/index.ts +12 -0
- package/src/tunnels/log-ring.ts +54 -0
- package/src/tunnels/manager.ts +139 -0
- package/src/tunnels/providers/cloudflare-quick.ts +189 -0
- package/src/tunnels/providers/external.ts +53 -0
- package/src/tunnels/quick-url-parser.ts +5 -0
- package/src/tunnels/types.ts +43 -0
- package/src/usage/report.ts +15 -12
- package/typeclaw.schema.json +311 -26
package/src/init/oauth-login.ts
CHANGED
|
@@ -14,16 +14,29 @@ export type OAuthLoginResult = { ok: true } | { ok: false; reason: string }
|
|
|
14
14
|
export type OAuthLoginRunner = (options: { cwd: string; model: KnownModelRef }) => Promise<OAuthLoginResult>
|
|
15
15
|
|
|
16
16
|
// Wrap pi-ai's OAuth callbacks so the CLI doesn't have to know about the
|
|
17
|
-
// upstream callback shape. The CLI
|
|
17
|
+
// upstream callback shape. The CLI sees four lifecycle events:
|
|
18
18
|
// (1) onAuth(url) — print the URL the user must visit
|
|
19
19
|
// (2) onProgress(message) — show waiting/finalizing status
|
|
20
20
|
// (3) onPrompt(prompt) — ask the user for a manual code if the browser flow
|
|
21
|
-
// can't reach the local callback server.
|
|
22
|
-
//
|
|
21
|
+
// can't reach the local callback server. Fires only after the local
|
|
22
|
+
// server gave up (bind error -> waitForCode resolves null).
|
|
23
|
+
// (4) onManualCodeInput() — concurrent paste input that RACES the local
|
|
24
|
+
// callback server. Required for cross-device flows: pi-ai's openai-codex
|
|
25
|
+
// OAuth hardcodes redirect_uri=http://localhost:1455/auth/callback, which
|
|
26
|
+
// resolves to the *browser's* machine. When the user runs `typeclaw init`
|
|
27
|
+
// over SSH or on a remote dev box and completes login on a different
|
|
28
|
+
// laptop, the browser callback never reaches the CLI's local server and
|
|
29
|
+
// waitForCode() hangs forever — so onPrompt would never fire either.
|
|
30
|
+
// onManualCodeInput is the upstream-supported escape hatch: it shows a
|
|
31
|
+
// paste field IMMEDIATELY alongside the URL, and whichever path lands a
|
|
32
|
+
// code first wins. parseAuthorizationInput on the upstream side accepts
|
|
33
|
+
// the full redirect URL, the bare `code=...&state=...` query string, or
|
|
34
|
+
// just the code value.
|
|
23
35
|
export type OAuthCallbacks = {
|
|
24
36
|
onAuth: (url: string, instructions?: string) => void
|
|
25
37
|
onProgress?: (message: string) => void
|
|
26
38
|
onPrompt: (message: string, placeholder?: string) => Promise<string | null>
|
|
39
|
+
onManualCodeInput?: () => Promise<string>
|
|
27
40
|
}
|
|
28
41
|
|
|
29
42
|
// Default runner: real OAuth flow against pi-ai. Tests inject a stub to skip
|
|
@@ -50,6 +63,7 @@ export function makeOAuthLoginRunner(callbacks: OAuthCallbacks): OAuthLoginRunne
|
|
|
50
63
|
}
|
|
51
64
|
return value
|
|
52
65
|
},
|
|
66
|
+
onManualCodeInput: callbacks.onManualCodeInput,
|
|
53
67
|
})
|
|
54
68
|
return { ok: true }
|
|
55
69
|
} catch (error) {
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
export type InstallResult = { ok: true } | { ok: false; reason: string }
|
|
2
2
|
|
|
3
|
+
export type InstallRunnerOptions = {
|
|
4
|
+
// Append `--force` to the bun install argv to bypass the cache for
|
|
5
|
+
// `file:` / `link:` deps. Bun treats name+version of a `file:` dep as a
|
|
6
|
+
// cache hit even after the source on disk has changed, so changes to a
|
|
7
|
+
// locally-linked typeclaw never propagate into <agent>/node_modules until
|
|
8
|
+
// either the typeclaw version is bumped or the install is forced.
|
|
9
|
+
force?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
3
12
|
// Signature for the function `runInit` uses to materialize the agent folder's
|
|
4
13
|
// dependencies. Exposed as a named type so callers (and tests) can pass their
|
|
5
14
|
// own stub without re-declaring the shape, mirroring `HatchRunner` and
|
|
6
15
|
// `KakaotalkAuthRunner` in `./index.ts`.
|
|
7
|
-
export type InstallRunner = (cwd: string) => Promise<InstallResult>
|
|
16
|
+
export type InstallRunner = (cwd: string, opts?: InstallRunnerOptions) => Promise<InstallResult>
|
|
8
17
|
|
|
9
|
-
export async function runBunInstall(cwd: string): Promise<InstallResult> {
|
|
18
|
+
export async function runBunInstall(cwd: string, opts?: InstallRunnerOptions): Promise<InstallResult> {
|
|
10
19
|
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
11
20
|
if (!bun) return { ok: false, reason: 'bun runtime not available' }
|
|
12
21
|
try {
|
|
@@ -21,7 +30,12 @@ export async function runBunInstall(cwd: string): Promise<InstallResult> {
|
|
|
21
30
|
// the bug are non-trivial. Hoisted is the fallback strategy bun shipped
|
|
22
31
|
// before 1.3 — slightly slower for huge monorepos, indistinguishable
|
|
23
32
|
// for an agent folder, and not affected by the bug.
|
|
24
|
-
|
|
33
|
+
//
|
|
34
|
+
// `--force` is conditional: it bypasses the package cache so file:/link:
|
|
35
|
+
// deps re-copy their current on-disk source into node_modules. Bun's
|
|
36
|
+
// file-dep cache is keyed on name+version, so without --force, edits to
|
|
37
|
+
// a `file:..` typeclaw never reach the container after the first install.
|
|
38
|
+
cmd: opts?.force ? ['bun', 'install', '--linker=hoisted', '--force'] : ['bun', 'install', '--linker=hoisted'],
|
|
25
39
|
cwd,
|
|
26
40
|
stdout: 'pipe',
|
|
27
41
|
stderr: 'pipe',
|
|
@@ -8,6 +8,7 @@ import type { ChannelKind } from './index'
|
|
|
8
8
|
const CHANNEL_LABELS: Record<ChannelKind, string> = {
|
|
9
9
|
'slack-bot': 'Slack',
|
|
10
10
|
'discord-bot': 'Discord',
|
|
11
|
+
github: 'GitHub',
|
|
11
12
|
'telegram-bot': 'Telegram',
|
|
12
13
|
kakaotalk: 'KakaoTalk',
|
|
13
14
|
}
|
|
@@ -25,9 +26,17 @@ export type RunOwnerClaimOptions = {
|
|
|
25
26
|
// normal hatching path so the TUI still opens — the operator can run
|
|
26
27
|
// `typeclaw role claim` later.
|
|
27
28
|
export async function runOwnerClaim({ url, configuredChannels }: RunOwnerClaimOptions): Promise<void> {
|
|
28
|
-
|
|
29
|
+
// GitHub has no DM affordance, and the github adapter doesn't yet route
|
|
30
|
+
// claim codes (the `typeclaw role claim` CLI explicitly lists only the
|
|
31
|
+
// four chat adapters as --channel values). Owner authorization for github
|
|
32
|
+
// is handled by the `roles.member.match[]` repo allowlist that
|
|
33
|
+
// runAddChannel writes during init. Filtering here keeps the auto-claim
|
|
34
|
+
// working for chat channels mixed with github, and skips the flow
|
|
35
|
+
// entirely when github is the only wired channel.
|
|
36
|
+
const claimable = configuredChannels.filter((c) => c !== 'github')
|
|
37
|
+
if (claimable.length === 0) return
|
|
29
38
|
|
|
30
|
-
const channelList =
|
|
39
|
+
const channelList = claimable.map((c) => CHANNEL_LABELS[c] ?? c).join(', ')
|
|
31
40
|
|
|
32
41
|
const proceed = await confirm({
|
|
33
42
|
message: `Claim owner role on ${channelList} now?`,
|
|
@@ -25,6 +25,21 @@ export type BuiltinRoleSpec = {
|
|
|
25
25
|
readonly permissions: readonly string[]
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Owner carries low + medium tier strings explicitly AND the wildcard
|
|
29
|
+
// sentinel. The sentinel expands to plugin-contributed `security.bypass.*`
|
|
30
|
+
// strings minus the security plugin's `ownerWildcardExclusions` (today:
|
|
31
|
+
// `security.bypass.high` plus high-tier per-guard strings). Net effect:
|
|
32
|
+
// owner auto-bypasses every low- and medium-tier guard, and high-tier
|
|
33
|
+
// guards require per-call ack from owner too (the audience-leak rule —
|
|
34
|
+
// owner-in-public-channel must not silently post credentials).
|
|
35
|
+
//
|
|
36
|
+
// Trusted carries only `security.bypass.low`. Trusted does NOT carry the
|
|
37
|
+
// pre-PR per-guard grants (`bypassSecretExfilBash`, `bypassGitExfil`):
|
|
38
|
+
// those guards are medium/high under the audience-leak axis and per-guard
|
|
39
|
+
// grants would re-introduce exactly the bypass holes the tier system
|
|
40
|
+
// exists to prevent. Operators who want the pre-PR ergonomics can add the
|
|
41
|
+
// per-guard strings explicitly to `roles.trusted.permissions[]` in
|
|
42
|
+
// typeclaw.json — that path stays alive forever.
|
|
28
43
|
export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> = {
|
|
29
44
|
owner: {
|
|
30
45
|
match: [{ kind: 'tui' }],
|
|
@@ -32,12 +47,14 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
|
|
|
32
47
|
CORE_PERMISSIONS.channelRespond,
|
|
33
48
|
CORE_PERMISSIONS.cronSchedule,
|
|
34
49
|
CORE_PERMISSIONS.cronModify,
|
|
50
|
+
'security.bypass.low',
|
|
51
|
+
'security.bypass.medium',
|
|
35
52
|
OWNER_SECURITY_WILDCARD,
|
|
36
53
|
],
|
|
37
54
|
},
|
|
38
55
|
trusted: {
|
|
39
56
|
match: [],
|
|
40
|
-
permissions: [CORE_PERMISSIONS.channelRespond, CORE_PERMISSIONS.cronSchedule, 'security.bypass.
|
|
57
|
+
permissions: [CORE_PERMISSIONS.channelRespond, CORE_PERMISSIONS.cronSchedule, 'security.bypass.low'],
|
|
41
58
|
},
|
|
42
59
|
member: {
|
|
43
60
|
match: [],
|
|
@@ -49,11 +66,21 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
|
|
|
49
66
|
},
|
|
50
67
|
}
|
|
51
68
|
|
|
69
|
+
// Expands the owner wildcard sentinel against plugin-contributed
|
|
70
|
+
// `security.bypass.*` strings. `wildcardExclusions` is an optional set of
|
|
71
|
+
// permission strings the sentinel must NOT expand to — used by the
|
|
72
|
+
// bundled security plugin to exclude `security.bypass.high` AND the
|
|
73
|
+
// per-guard strings for high-tier guards, so the wildcard does not
|
|
74
|
+
// auto-grant audience-leak bypass to owner. Explicit operator grants of
|
|
75
|
+
// those strings in `roles.owner.permissions[]` still take effect (they
|
|
76
|
+
// flow through the non-sentinel branch).
|
|
52
77
|
export function expandOwnerWildcard(
|
|
53
78
|
ownerPermissions: readonly string[],
|
|
54
79
|
pluginContributed: readonly string[],
|
|
80
|
+
wildcardExclusions: readonly string[] = [],
|
|
55
81
|
): readonly string[] {
|
|
56
|
-
const
|
|
82
|
+
const excludeSet = new Set(wildcardExclusions)
|
|
83
|
+
const bypass = pluginContributed.filter((p) => p.startsWith('security.bypass.') && !excludeSet.has(p))
|
|
57
84
|
const out: string[] = []
|
|
58
85
|
for (const p of ownerPermissions) {
|
|
59
86
|
if (p === OWNER_SECURITY_WILDCARD) {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// error messages with typo suggestions; a single big regex would only ever
|
|
17
17
|
// say "didn't match".
|
|
18
18
|
|
|
19
|
-
export const PLATFORMS = ['slack', 'discord', 'telegram', 'kakao'] as const
|
|
19
|
+
export const PLATFORMS = ['slack', 'discord', 'telegram', 'kakao', 'github'] as const
|
|
20
20
|
export type Platform = (typeof PLATFORMS)[number]
|
|
21
21
|
|
|
22
22
|
const SUBAGENT_NAME = /^[a-z][a-z0-9-]*$/
|
|
@@ -50,7 +50,7 @@ export type ParseMatchRuleResult = { ok: true; value: MatchRule } | { ok: false;
|
|
|
50
50
|
// this to `author:` only, the JSON schema would reject typos with a generic
|
|
51
51
|
// "did not match pattern" error and the user would lose the actionable hint.
|
|
52
52
|
export const MATCH_RULE_REGEX_SOURCE =
|
|
53
|
-
'^(tui|cron|subagent(:[a-z][a-z0-9-]*)?|\\*|(slack|discord|telegram|kakao):[^\\s]+)(\\s+[a-zA-Z][a-zA-Z0-9_]*:[^\\s]+)*$'
|
|
53
|
+
'^(tui|cron|subagent(:[a-z][a-z0-9-]*)?|\\*|(slack|discord|telegram|kakao|github):[^\\s]+)(\\s+[a-zA-Z][a-zA-Z0-9_]*:[^\\s]+)*$'
|
|
54
54
|
|
|
55
55
|
export function parseMatchRule(input: string): ParseMatchRuleResult {
|
|
56
56
|
if (input !== input.trim() || input.length === 0) {
|
|
@@ -138,6 +138,8 @@ function parseChannelScope(platform: Platform, rest: string, author: string | un
|
|
|
138
138
|
return { ok: true, value: buildChannelRule(platform, { author }) }
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
if (platform === 'github') return parseGithubChannelScope(rest, author)
|
|
142
|
+
|
|
141
143
|
// Bucket scopes: `dm/*`, `dm/<id>`, `group/*`, `open/*`. Slack's `im` is
|
|
142
144
|
// renamed to `dm`; that mapping is enforced by the legacy-prefix table at
|
|
143
145
|
// the top of parseMatchRule for unprefixed forms — here we just refuse the
|
|
@@ -203,6 +205,26 @@ function parseChannelScope(platform: Platform, rest: string, author: string | un
|
|
|
203
205
|
return { ok: true, value: buildChannelRule(platform, { workspace: rest, author }) }
|
|
204
206
|
}
|
|
205
207
|
|
|
208
|
+
function parseGithubChannelScope(rest: string, author: string | undefined): ParseMatchRuleResult {
|
|
209
|
+
const [owner, repo, ...chatParts] = rest.split('/')
|
|
210
|
+
if (owner === undefined || owner === '' || repo === undefined || repo === '') {
|
|
211
|
+
return { ok: false, error: "github scope requires 'owner/repo' format" }
|
|
212
|
+
}
|
|
213
|
+
if (repo === '*') {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
error: `'github:${owner}/*' is not supported; use 'github:${owner}/repo' for a specific repo or 'github:*' for all github events`,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const workspace = `${owner}/${repo}`
|
|
220
|
+
if (chatParts.length === 0) return { ok: true, value: buildChannelRule('github', { workspace, author }) }
|
|
221
|
+
const chat = chatParts.join('/')
|
|
222
|
+
if (chat === '' || chat.includes('/')) {
|
|
223
|
+
return { ok: false, error: "github chat scope must be a single segment like 'issue:42'" }
|
|
224
|
+
}
|
|
225
|
+
return { ok: true, value: buildChannelRule('github', { workspace, chat, author }) }
|
|
226
|
+
}
|
|
227
|
+
|
|
206
228
|
function buildChannelRule(
|
|
207
229
|
platform: Platform,
|
|
208
230
|
parts: {
|
|
@@ -38,6 +38,12 @@ type ResolvedRole = {
|
|
|
38
38
|
export type CreatePermissionServiceOptions = {
|
|
39
39
|
roles?: RolesConfig
|
|
40
40
|
pluginPermissions?: readonly string[]
|
|
41
|
+
// Permission strings that the owner wildcard sentinel must NOT
|
|
42
|
+
// auto-expand to. Today populated from the bundled security plugin's
|
|
43
|
+
// high-tier list so audience-leak guards do not get auto-granted to
|
|
44
|
+
// owner. Generic by design — any future plugin could contribute
|
|
45
|
+
// exclusions through the plugin manager. See expandOwnerWildcard.
|
|
46
|
+
ownerWildcardExclusions?: readonly string[]
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
// Returns warnings for user-declared `permissions[]` strings that aren't
|
|
@@ -97,7 +103,8 @@ function levenshtein(a: string, b: string): number {
|
|
|
97
103
|
|
|
98
104
|
export function createPermissionService(opts: CreatePermissionServiceOptions = {}): PermissionService {
|
|
99
105
|
const pluginPermissions = opts.pluginPermissions ?? []
|
|
100
|
-
|
|
106
|
+
const ownerWildcardExclusions = opts.ownerWildcardExclusions ?? []
|
|
107
|
+
let resolved = buildRoleTable(opts.roles ?? {}, pluginPermissions, ownerWildcardExclusions)
|
|
101
108
|
let byName = new Map(resolved.map((r) => [r.name, r]))
|
|
102
109
|
|
|
103
110
|
function resolveRole(origin: SessionOrigin | undefined): string {
|
|
@@ -139,36 +146,46 @@ export function createPermissionService(opts: CreatePermissionServiceOptions = {
|
|
|
139
146
|
return { role: name, permissions: role?.permissions ?? [] }
|
|
140
147
|
},
|
|
141
148
|
replaceRoles(roles) {
|
|
142
|
-
resolved = buildRoleTable(roles ?? {}, pluginPermissions)
|
|
149
|
+
resolved = buildRoleTable(roles ?? {}, pluginPermissions, ownerWildcardExclusions)
|
|
143
150
|
byName = new Map(resolved.map((r) => [r.name, r]))
|
|
144
151
|
},
|
|
145
152
|
}
|
|
146
153
|
}
|
|
147
154
|
|
|
148
|
-
function buildRoleTable(
|
|
155
|
+
function buildRoleTable(
|
|
156
|
+
roles: RolesConfig,
|
|
157
|
+
pluginPermissions: readonly string[],
|
|
158
|
+
ownerWildcardExclusions: readonly string[],
|
|
159
|
+
): ResolvedRole[] {
|
|
149
160
|
const out: ResolvedRole[] = []
|
|
150
161
|
const seen = new Set<string>()
|
|
151
162
|
|
|
152
163
|
for (const name of Object.keys(roles)) {
|
|
153
164
|
if (seen.has(name)) continue
|
|
154
165
|
seen.add(name)
|
|
155
|
-
out.push(resolveOne(name, roles[name], pluginPermissions))
|
|
166
|
+
out.push(resolveOne(name, roles[name], pluginPermissions, ownerWildcardExclusions))
|
|
156
167
|
}
|
|
157
168
|
|
|
158
169
|
for (const name of BUILTIN_ROLE_NAMES) {
|
|
159
170
|
if (seen.has(name)) continue
|
|
160
|
-
out.push(resolveOne(name, undefined, pluginPermissions))
|
|
171
|
+
out.push(resolveOne(name, undefined, pluginPermissions, ownerWildcardExclusions))
|
|
161
172
|
}
|
|
162
173
|
|
|
163
174
|
return out
|
|
164
175
|
}
|
|
165
176
|
|
|
166
|
-
function resolveOne(
|
|
177
|
+
function resolveOne(
|
|
178
|
+
name: string,
|
|
179
|
+
user: RoleConfig | undefined,
|
|
180
|
+
pluginPermissions: readonly string[],
|
|
181
|
+
ownerWildcardExclusions: readonly string[],
|
|
182
|
+
): ResolvedRole {
|
|
167
183
|
if (isBuiltinRoleName(name)) {
|
|
168
184
|
const builtin = BUILTIN_ROLES[name]
|
|
169
185
|
const match = [...builtin.match, ...(user?.match ?? [])]
|
|
170
186
|
const rawPerms = user?.permissions !== undefined ? user.permissions : [...builtin.permissions]
|
|
171
|
-
const permissions =
|
|
187
|
+
const permissions =
|
|
188
|
+
name === 'owner' ? expandOwnerWildcard(rawPerms, pluginPermissions, ownerWildcardExclusions) : rawPerms
|
|
172
189
|
return { name, match, permissions }
|
|
173
190
|
}
|
|
174
191
|
return {
|
package/src/plugin/define.ts
CHANGED
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
import type { z } from 'zod'
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
BuiltinToolRef,
|
|
5
|
+
ContainerCommand,
|
|
6
|
+
DefinedPlugin,
|
|
7
|
+
EitherCommand,
|
|
8
|
+
HostCommand,
|
|
9
|
+
PluginCommand,
|
|
10
|
+
PluginContext,
|
|
11
|
+
PluginExports,
|
|
12
|
+
Subagent,
|
|
13
|
+
Tool,
|
|
14
|
+
} from './types'
|
|
4
15
|
|
|
5
16
|
type DefinePluginSpec<S extends z.ZodType<unknown> | undefined> =
|
|
6
17
|
S extends z.ZodType<infer T>
|
|
7
18
|
? {
|
|
8
19
|
configSchema: S
|
|
9
20
|
permissions?: readonly string[]
|
|
21
|
+
ownerWildcardExclusions?: readonly string[]
|
|
22
|
+
commands?: Record<string, PluginCommand>
|
|
10
23
|
plugin: (ctx: PluginContext<T>) => Promise<PluginExports>
|
|
11
24
|
}
|
|
12
25
|
: {
|
|
13
26
|
permissions?: readonly string[]
|
|
27
|
+
ownerWildcardExclusions?: readonly string[]
|
|
28
|
+
commands?: Record<string, PluginCommand>
|
|
14
29
|
plugin: (ctx: PluginContext<unknown>) => Promise<PluginExports>
|
|
15
30
|
}
|
|
16
31
|
|
|
@@ -28,6 +43,34 @@ export function defineSubagent<P>(subagent: Subagent<P>): Subagent<P> {
|
|
|
28
43
|
return subagent
|
|
29
44
|
}
|
|
30
45
|
|
|
46
|
+
type ContainerCommandSpec<S extends z.ZodObject<z.ZodRawShape> | undefined> =
|
|
47
|
+
S extends z.ZodObject<z.ZodRawShape>
|
|
48
|
+
? Omit<ContainerCommand<z.infer<S>>, 'args'> & { args: S }
|
|
49
|
+
: Omit<ContainerCommand<unknown>, 'args'> & { args?: undefined }
|
|
50
|
+
|
|
51
|
+
type HostCommandSpec<S extends z.ZodObject<z.ZodRawShape> | undefined> =
|
|
52
|
+
S extends z.ZodObject<z.ZodRawShape>
|
|
53
|
+
? Omit<HostCommand<z.infer<S>>, 'args'> & { args: S }
|
|
54
|
+
: Omit<HostCommand<unknown>, 'args'> & { args?: undefined }
|
|
55
|
+
|
|
56
|
+
type EitherCommandSpec<S extends z.ZodObject<z.ZodRawShape> | undefined> =
|
|
57
|
+
S extends z.ZodObject<z.ZodRawShape>
|
|
58
|
+
? Omit<EitherCommand<z.infer<S>>, 'args'> & { args: S }
|
|
59
|
+
: Omit<EitherCommand<unknown>, 'args'> & { args?: undefined }
|
|
60
|
+
|
|
61
|
+
export function defineCommand<S extends z.ZodObject<z.ZodRawShape> | undefined = undefined>(
|
|
62
|
+
cmd: ContainerCommandSpec<S>,
|
|
63
|
+
): ContainerCommand<S extends z.ZodObject<z.ZodRawShape> ? z.infer<S> : unknown>
|
|
64
|
+
export function defineCommand<S extends z.ZodObject<z.ZodRawShape> | undefined = undefined>(
|
|
65
|
+
cmd: HostCommandSpec<S>,
|
|
66
|
+
): HostCommand<S extends z.ZodObject<z.ZodRawShape> ? z.infer<S> : unknown>
|
|
67
|
+
export function defineCommand<S extends z.ZodObject<z.ZodRawShape> | undefined = undefined>(
|
|
68
|
+
cmd: EitherCommandSpec<S>,
|
|
69
|
+
): EitherCommand<S extends z.ZodObject<z.ZodRawShape> ? z.infer<S> : unknown>
|
|
70
|
+
export function defineCommand(cmd: PluginCommand): PluginCommand {
|
|
71
|
+
return cmd
|
|
72
|
+
}
|
|
73
|
+
|
|
31
74
|
export const readTool: BuiltinToolRef = { __builtinTool: 'read' }
|
|
32
75
|
export const bashTool: BuiltinToolRef = { __builtinTool: 'bash' }
|
|
33
76
|
export const editTool: BuiltinToolRef = { __builtinTool: 'edit' }
|
package/src/plugin/index.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export {
|
|
2
2
|
bashTool,
|
|
3
|
-
|
|
3
|
+
defineCommand,
|
|
4
4
|
definePlugin,
|
|
5
5
|
defineSubagent,
|
|
6
|
+
defineTool,
|
|
6
7
|
editTool,
|
|
7
8
|
findTool,
|
|
8
9
|
grepTool,
|
|
@@ -13,14 +14,24 @@ export {
|
|
|
13
14
|
|
|
14
15
|
export type {
|
|
15
16
|
BuiltinToolRef,
|
|
17
|
+
CommandExecResult,
|
|
18
|
+
CommandStreams,
|
|
19
|
+
ContainerCommand,
|
|
20
|
+
ContainerCommandContext,
|
|
16
21
|
ContentPart,
|
|
17
22
|
DefinedPlugin,
|
|
23
|
+
EitherCommand,
|
|
24
|
+
EitherCommandContext,
|
|
18
25
|
HookContext,
|
|
19
26
|
HookName,
|
|
20
27
|
Hooks,
|
|
28
|
+
HostCommand,
|
|
29
|
+
HostCommandContext,
|
|
21
30
|
PluginCheckResult,
|
|
22
31
|
PluginCheckStatus,
|
|
32
|
+
PluginCommand,
|
|
23
33
|
PluginContext,
|
|
34
|
+
CronHandlerContext,
|
|
24
35
|
PluginCronJob,
|
|
25
36
|
PluginDoctorCheck,
|
|
26
37
|
PluginDoctorContext,
|
|
@@ -28,6 +39,7 @@ export type {
|
|
|
28
39
|
PluginExports,
|
|
29
40
|
PluginFixResult,
|
|
30
41
|
PluginFixSuggestion,
|
|
42
|
+
PluginHandlerCronJob,
|
|
31
43
|
PluginLogger,
|
|
32
44
|
PluginPromptCronJob,
|
|
33
45
|
PluginSkill,
|
|
@@ -61,12 +73,15 @@ export { loadPluginEntry, derivePluginNameFromPackage } from './loader'
|
|
|
61
73
|
export { materializeSkills, type MaterializedSkills, type SkillEntry } from './skills'
|
|
62
74
|
export {
|
|
63
75
|
buildPluginCronGlobalId,
|
|
76
|
+
RESERVED_COMMAND_NAMES,
|
|
77
|
+
validateCommandDeclaration,
|
|
64
78
|
type PluginRegistry,
|
|
79
|
+
type RegisteredCommand,
|
|
65
80
|
type RegisteredCronJob,
|
|
66
81
|
type RegisteredDoctorCheck,
|
|
82
|
+
type RegisteredSkillDir,
|
|
83
|
+
type RegisteredSkillEntry,
|
|
67
84
|
type RegisteredSubagent,
|
|
68
85
|
type RegisteredTool,
|
|
69
|
-
type RegisteredSkillEntry,
|
|
70
|
-
type RegisteredSkillDir,
|
|
71
86
|
} from './registry'
|
|
72
87
|
export { createHookBus, type HookBus } from './hooks'
|
package/src/plugin/manager.ts
CHANGED
|
@@ -56,9 +56,11 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
|
|
|
56
56
|
]
|
|
57
57
|
|
|
58
58
|
const declaredPermissions = collectDeclaredPermissions(allPlugins)
|
|
59
|
+
const ownerWildcardExclusions = collectOwnerWildcardExclusions(allPlugins)
|
|
59
60
|
const permissions = createPermissionService({
|
|
60
61
|
...(opts.roles !== undefined ? { roles: opts.roles } : {}),
|
|
61
62
|
pluginPermissions: declaredPermissions,
|
|
63
|
+
ownerWildcardExclusions,
|
|
62
64
|
})
|
|
63
65
|
|
|
64
66
|
// Non-fatal: surface user-declared `permissions[]` strings that aren't in
|
|
@@ -117,6 +119,7 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
|
|
|
117
119
|
pluginName: resolved.name,
|
|
118
120
|
logger,
|
|
119
121
|
exports,
|
|
122
|
+
...(resolved.defined.commands !== undefined ? { commands: resolved.defined.commands } : {}),
|
|
120
123
|
registry,
|
|
121
124
|
hooks,
|
|
122
125
|
agentDir: opts.agentDir,
|
|
@@ -157,6 +160,18 @@ function collectDeclaredPermissions(
|
|
|
157
160
|
return out
|
|
158
161
|
}
|
|
159
162
|
|
|
163
|
+
function collectOwnerWildcardExclusions(
|
|
164
|
+
plugins: readonly { entry: string; resolved: ResolvedPlugin }[],
|
|
165
|
+
): readonly string[] {
|
|
166
|
+
const out: string[] = []
|
|
167
|
+
for (const { resolved } of plugins) {
|
|
168
|
+
for (const perm of resolved.defined.ownerWildcardExclusions ?? []) {
|
|
169
|
+
if (!out.includes(perm)) out.push(perm)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out
|
|
173
|
+
}
|
|
174
|
+
|
|
160
175
|
export function summarizeLoaded(loaded: LoadPluginsResult['loadedPlugins'], registry: PluginRegistry): string {
|
|
161
176
|
const head = loaded.map((p) => (p.version !== undefined ? `${p.name} v${p.version}` : p.name)).join(', ')
|
|
162
177
|
const counts = [
|
|
@@ -166,6 +181,7 @@ export function summarizeLoaded(loaded: LoadPluginsResult['loadedPlugins'], regi
|
|
|
166
181
|
`${registry.skills.length} skill(s)`,
|
|
167
182
|
`${registry.skillsDirs.length} skills dir(s)`,
|
|
168
183
|
`${registry.doctorChecks.length} doctor check(s)`,
|
|
184
|
+
`${registry.commands.length} command(s)`,
|
|
169
185
|
].join(', ')
|
|
170
186
|
return `${loaded.length} plugin(s): ${head} [${counts}]`
|
|
171
187
|
}
|
package/src/plugin/registry.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
|
|
3
|
+
import { BUILTIN_COMMAND_NAMES } from '@/cli/builtins'
|
|
3
4
|
import type { CronJob, PromptJob } from '@/cron'
|
|
4
5
|
|
|
5
6
|
import type { HookBus } from './hooks'
|
|
6
7
|
import type {
|
|
8
|
+
PluginCommand,
|
|
7
9
|
PluginCronJob,
|
|
8
10
|
PluginDoctorCheck,
|
|
9
11
|
PluginExports,
|
|
@@ -12,6 +14,7 @@ import type {
|
|
|
12
14
|
Subagent,
|
|
13
15
|
Tool,
|
|
14
16
|
} from './types'
|
|
17
|
+
import { isPrimitiveZodObject } from './zod-introspect'
|
|
15
18
|
|
|
16
19
|
export type RegisteredTool = { pluginName: string; toolName: string; tool: Tool<any>; logger: PluginLogger }
|
|
17
20
|
export type RegisteredSubagent = { pluginName: string; subagentName: string; subagent: Subagent<any> }
|
|
@@ -25,6 +28,12 @@ export type RegisteredDoctorCheck = {
|
|
|
25
28
|
logger: PluginLogger
|
|
26
29
|
check: PluginDoctorCheck
|
|
27
30
|
}
|
|
31
|
+
export type RegisteredCommand = {
|
|
32
|
+
pluginName: string
|
|
33
|
+
commandName: string
|
|
34
|
+
command: PluginCommand
|
|
35
|
+
logger: PluginLogger
|
|
36
|
+
}
|
|
28
37
|
|
|
29
38
|
export type PluginRegistry = {
|
|
30
39
|
tools: RegisteredTool[]
|
|
@@ -33,18 +42,28 @@ export type PluginRegistry = {
|
|
|
33
42
|
skills: RegisteredSkillEntry[]
|
|
34
43
|
skillsDirs: RegisteredSkillDir[]
|
|
35
44
|
doctorChecks: RegisteredDoctorCheck[]
|
|
45
|
+
commands: RegisteredCommand[]
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
export type RegisterContributionsOptions = {
|
|
39
49
|
pluginName: string
|
|
40
50
|
logger: PluginLogger
|
|
41
51
|
exports: PluginExports
|
|
52
|
+
// Static commands declared on `DefinedPlugin.commands`. Passed alongside
|
|
53
|
+
// `exports` because they live outside the factory's return value.
|
|
54
|
+
commands?: Record<string, PluginCommand>
|
|
42
55
|
registry: PluginRegistry
|
|
43
56
|
hooks: HookBus
|
|
44
57
|
agentDir: string
|
|
45
58
|
pluginConfig: unknown
|
|
46
59
|
}
|
|
47
60
|
|
|
61
|
+
const COMMAND_NAME_REGEX = /^[a-z][a-z0-9-]*$/
|
|
62
|
+
|
|
63
|
+
// CLI subcommands plugins MUST NOT shadow. Derived from BUILTIN_COMMAND_NAMES
|
|
64
|
+
// so cli/index.ts and registry.ts cannot drift apart.
|
|
65
|
+
export const RESERVED_COMMAND_NAMES: ReadonlySet<string> = new Set(BUILTIN_COMMAND_NAMES)
|
|
66
|
+
|
|
48
67
|
export function buildPluginCronGlobalId(pluginName: string, localId: string): string {
|
|
49
68
|
return `__plugin_${pluginName}_${localId}`
|
|
50
69
|
}
|
|
@@ -127,6 +146,19 @@ export function registerContributions(opts: RegisterContributionsOptions): void
|
|
|
127
146
|
registry.doctorChecks.push({ pluginName, checkName, pluginConfig, logger, check })
|
|
128
147
|
}
|
|
129
148
|
}
|
|
149
|
+
|
|
150
|
+
if (opts.commands) {
|
|
151
|
+
for (const [commandName, command] of Object.entries(opts.commands)) {
|
|
152
|
+
validateCommandDeclaration(pluginName, commandName, command)
|
|
153
|
+
const conflict = registry.commands.find((c) => c.commandName === commandName)
|
|
154
|
+
if (conflict) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`plugin ${pluginName}: command "${commandName}" already registered by plugin ${conflict.pluginName}`,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
registry.commands.push({ pluginName, commandName, command, logger })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
130
162
|
}
|
|
131
163
|
|
|
132
164
|
export function discardRegistrationsBy(pluginName: string, registry: PluginRegistry, hooks: HookBus): void {
|
|
@@ -136,11 +168,20 @@ export function discardRegistrationsBy(pluginName: string, registry: PluginRegis
|
|
|
136
168
|
registry.skills = registry.skills.filter((s) => s.pluginName !== pluginName)
|
|
137
169
|
registry.skillsDirs = registry.skillsDirs.filter((d) => d.pluginName !== pluginName)
|
|
138
170
|
registry.doctorChecks = registry.doctorChecks.filter((d) => d.pluginName !== pluginName)
|
|
171
|
+
registry.commands = registry.commands.filter((c) => c.pluginName !== pluginName)
|
|
139
172
|
hooks.unregisterAll(pluginName)
|
|
140
173
|
}
|
|
141
174
|
|
|
142
175
|
export function emptyRegistry(): PluginRegistry {
|
|
143
|
-
return {
|
|
176
|
+
return {
|
|
177
|
+
tools: [],
|
|
178
|
+
subagents: [],
|
|
179
|
+
cronJobs: [],
|
|
180
|
+
skills: [],
|
|
181
|
+
skillsDirs: [],
|
|
182
|
+
doctorChecks: [],
|
|
183
|
+
commands: [],
|
|
184
|
+
}
|
|
144
185
|
}
|
|
145
186
|
|
|
146
187
|
function assertNotEmpty(kind: string, value: string, pluginName: string): void {
|
|
@@ -149,6 +190,36 @@ function assertNotEmpty(kind: string, value: string, pluginName: string): void {
|
|
|
149
190
|
}
|
|
150
191
|
}
|
|
151
192
|
|
|
193
|
+
function assertValidCommandArgsSchema(pluginName: string, commandName: string, command: PluginCommand): void {
|
|
194
|
+
if (command.args === undefined) return
|
|
195
|
+
if (!isPrimitiveZodObject(command.args)) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`plugin ${pluginName}: command "${commandName}" args must be a z.object({...}) with primitive (string/number/boolean) leaves`,
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Reuses the same checks `registerContributions` runs at boot, so host-stage
|
|
203
|
+
// discovery and runtime registration agree on what is a valid command. Throws
|
|
204
|
+
// a precise error referencing the plugin and command; callers translate the
|
|
205
|
+
// error into a discovery `loadError` rather than failing the whole CLI.
|
|
206
|
+
export function validateCommandDeclaration(pluginName: string, commandName: string, command: PluginCommand): void {
|
|
207
|
+
if (commandName.length === 0) {
|
|
208
|
+
throw new Error(`plugin ${pluginName}: empty command name`)
|
|
209
|
+
}
|
|
210
|
+
if (!COMMAND_NAME_REGEX.test(commandName)) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`plugin ${pluginName}: command "${commandName}" does not match ${COMMAND_NAME_REGEX.source} (lowercase letters, digits, dashes; must start with a letter)`,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
if (RESERVED_COMMAND_NAMES.has(commandName)) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`plugin ${pluginName}: command "${commandName}" shadows a built-in typeclaw subcommand and cannot be registered`,
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
assertValidCommandArgsSchema(pluginName, commandName, command)
|
|
221
|
+
}
|
|
222
|
+
|
|
152
223
|
function toCronJob(globalId: string, spec: PluginCronJob): CronJob {
|
|
153
224
|
// Plugin-contributed jobs default to `owner` because they are part of the
|
|
154
225
|
// agent's bundled (or operator-installed) runtime, not user-channel
|
|
@@ -171,12 +242,23 @@ function toCronJob(globalId: string, spec: PluginCronJob): CronJob {
|
|
|
171
242
|
}
|
|
172
243
|
return job
|
|
173
244
|
}
|
|
245
|
+
if (spec.kind === 'exec') {
|
|
246
|
+
return {
|
|
247
|
+
id: globalId,
|
|
248
|
+
schedule: spec.schedule,
|
|
249
|
+
enabled: spec.enabled ?? true,
|
|
250
|
+
kind: 'exec',
|
|
251
|
+
command: spec.command,
|
|
252
|
+
scheduledByRole,
|
|
253
|
+
...(spec.timezone !== undefined ? { timezone: spec.timezone } : {}),
|
|
254
|
+
}
|
|
255
|
+
}
|
|
174
256
|
return {
|
|
175
257
|
id: globalId,
|
|
176
258
|
schedule: spec.schedule,
|
|
177
259
|
enabled: spec.enabled ?? true,
|
|
178
|
-
kind: '
|
|
179
|
-
|
|
260
|
+
kind: 'handler',
|
|
261
|
+
handler: spec.handler,
|
|
180
262
|
scheduledByRole,
|
|
181
263
|
...(spec.timezone !== undefined ? { timezone: spec.timezone } : {}),
|
|
182
264
|
}
|