typeclaw 0.18.0 → 0.20.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/package.json +1 -1
- package/src/agent/index.ts +9 -1
- package/src/agent/live-subagents.ts +4 -0
- package/src/agent/model-overrides.ts +77 -0
- package/src/agent/plugin-tools.ts +53 -4
- package/src/agent/session-origin.ts +32 -10
- package/src/agent/tools/channel-react.ts +79 -0
- package/src/agent/tools/grant-role.ts +102 -8
- package/src/agent/tools/spawn-subagent.ts +1 -0
- package/src/agent/tools/subagent-access.ts +67 -0
- package/src/agent/tools/subagent-cancel.ts +11 -6
- package/src/agent/tools/subagent-output.ts +10 -2
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +372 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +42 -0
- package/src/bundled-plugins/github-cli-auth/token-class.ts +11 -0
- package/src/bundled-plugins/reviewer/skills/code-review.ts +18 -1
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +9 -2
- package/src/channels/adapters/discord-bot.ts +242 -7
- package/src/channels/adapters/github/inbound.ts +40 -55
- package/src/channels/adapters/github/index.ts +89 -18
- package/src/channels/adapters/github/membership.ts +4 -0
- package/src/channels/adapters/github/permission-guidance.ts +20 -1
- package/src/channels/adapters/github/reactions.ts +142 -0
- package/src/channels/adapters/slack-bot-slash-commands.ts +3 -1
- package/src/channels/adapters/slack-bot.ts +4 -4
- package/src/channels/commands.ts +10 -0
- package/src/channels/engagement.ts +30 -2
- package/src/channels/github-token-bridge.ts +42 -0
- package/src/channels/index.ts +6 -0
- package/src/channels/manager.ts +6 -0
- package/src/channels/membership.ts +9 -0
- package/src/channels/router.ts +295 -42
- package/src/channels/types.ts +42 -0
- package/src/cli/inspect.ts +3 -0
- package/src/cli/ui.ts +6 -0
- package/src/commands/index.ts +54 -4
- package/src/init/dockerfile.ts +60 -0
- package/src/init/validate-api-key.ts +15 -1
- package/src/inspect/loop.ts +12 -1
- package/src/permissions/permissions.ts +24 -0
- package/src/plugin/context.ts +8 -0
- package/src/plugin/manager.ts +3 -0
- package/src/plugin/types.ts +6 -0
- package/src/run/bundled-plugins.ts +9 -0
- package/src/run/index.ts +4 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +80 -43
package/src/cli/ui.ts
CHANGED
|
@@ -152,6 +152,12 @@ export const SLACK_APP_MANIFEST = {
|
|
|
152
152
|
// so a misconfigured (non-Socket-Mode) deployment fails fast rather
|
|
153
153
|
// than silently routing real slash invocations to a third-party URL.
|
|
154
154
|
slash_commands: [
|
|
155
|
+
{
|
|
156
|
+
command: '/help',
|
|
157
|
+
description: 'List available commands',
|
|
158
|
+
url: 'https://example.invalid/typeclaw-uses-socket-mode',
|
|
159
|
+
should_escape: false,
|
|
160
|
+
},
|
|
155
161
|
{
|
|
156
162
|
command: '/stop',
|
|
157
163
|
description: 'Abort the current turn in this channel',
|
package/src/commands/index.ts
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
|
-
|
|
1
|
+
// Returning nothing keeps the void contract — the dispatch layer then falls
|
|
2
|
+
// back to its static per-result-kind reply. A `reply` lets dynamic commands
|
|
3
|
+
// (/help) surface text the dispatcher could not have known statically.
|
|
4
|
+
export type CommandHandlerResult = void | { reply?: string }
|
|
5
|
+
|
|
6
|
+
export type CommandHandler<Context> = (
|
|
7
|
+
context: Context,
|
|
8
|
+
command: ParsedCommand,
|
|
9
|
+
) => Promise<CommandHandlerResult> | CommandHandlerResult
|
|
10
|
+
|
|
11
|
+
// `permission` and `requiresLiveSession` are command-level policy the dispatch
|
|
12
|
+
// layer (the channel router) enforces. They live on the command, not the
|
|
13
|
+
// dispatcher, so a new command declares its own requirements in one place:
|
|
14
|
+
// 'session.control' + requiresLiveSession:true is the control-command default
|
|
15
|
+
// (/stop); 'none' + requiresLiveSession:false is the informational default
|
|
16
|
+
// (/help). Both are optional so plain registries (tests, TUI) need not care.
|
|
17
|
+
export type CommandPermission = 'none' | 'session.control'
|
|
2
18
|
|
|
3
19
|
export type Command<Context> = {
|
|
4
20
|
name: string
|
|
5
21
|
aliases?: readonly string[]
|
|
22
|
+
description: string
|
|
23
|
+
permission?: CommandPermission
|
|
24
|
+
requiresLiveSession?: boolean
|
|
6
25
|
handler: CommandHandler<Context>
|
|
7
26
|
}
|
|
8
27
|
|
|
@@ -11,14 +30,27 @@ export type ParsedCommand = {
|
|
|
11
30
|
args: string
|
|
12
31
|
}
|
|
13
32
|
|
|
33
|
+
// Read-only view of a registered command, used to generate help text from the
|
|
34
|
+
// registry so the listing can never drift from the actual command set. Aliases
|
|
35
|
+
// are folded into the canonical entry rather than listed as separate commands.
|
|
36
|
+
export type CommandInfo = {
|
|
37
|
+
name: string
|
|
38
|
+
aliases: readonly string[]
|
|
39
|
+
description: string
|
|
40
|
+
permission: CommandPermission
|
|
41
|
+
requiresLiveSession: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
14
44
|
export type CommandResult =
|
|
15
45
|
| { kind: 'not-command' }
|
|
16
46
|
| { kind: 'unknown-command'; name: string }
|
|
17
|
-
| { kind: 'handled'; name: string }
|
|
47
|
+
| { kind: 'handled'; name: string; reply?: string }
|
|
18
48
|
|
|
19
49
|
export type CommandRegistry<Context> = {
|
|
20
50
|
parse: (text: string) => ParsedCommand | null
|
|
21
51
|
has: (name: string) => boolean
|
|
52
|
+
get: (name: string) => CommandInfo | undefined
|
|
53
|
+
list: () => readonly CommandInfo[]
|
|
22
54
|
execute: (text: string, context: Context) => Promise<CommandResult>
|
|
23
55
|
}
|
|
24
56
|
|
|
@@ -33,16 +65,34 @@ export function createCommandRegistry<Context>(commands: readonly Command<Contex
|
|
|
33
65
|
}
|
|
34
66
|
}
|
|
35
67
|
|
|
68
|
+
const info = (command: Command<Context>): CommandInfo => ({
|
|
69
|
+
name: command.name,
|
|
70
|
+
aliases: command.aliases ?? [],
|
|
71
|
+
description: command.description,
|
|
72
|
+
permission: command.permission ?? 'session.control',
|
|
73
|
+
requiresLiveSession: command.requiresLiveSession ?? true,
|
|
74
|
+
})
|
|
75
|
+
|
|
36
76
|
return {
|
|
37
77
|
parse: parseCommand,
|
|
38
78
|
has: (name) => byName.has(name.toLowerCase()),
|
|
79
|
+
get: (name) => {
|
|
80
|
+
const command = byName.get(name.toLowerCase())
|
|
81
|
+
return command ? info(command) : undefined
|
|
82
|
+
},
|
|
83
|
+
// Canonical commands only, in declaration order. Aliases resolve to the
|
|
84
|
+
// same Command object, so de-dupe by identity to avoid duplicate rows.
|
|
85
|
+
list: () => commands.map(info),
|
|
39
86
|
execute: async (text, context) => {
|
|
40
87
|
const parsed = parseCommand(text)
|
|
41
88
|
if (parsed === null) return { kind: 'not-command' }
|
|
42
89
|
const command = byName.get(parsed.name)
|
|
43
90
|
if (!command) return { kind: 'unknown-command', name: parsed.name }
|
|
44
|
-
await command.handler(context, parsed)
|
|
45
|
-
|
|
91
|
+
const result = await command.handler(context, parsed)
|
|
92
|
+
const reply = result?.reply
|
|
93
|
+
return reply !== undefined
|
|
94
|
+
? { kind: 'handled', name: command.name, reply }
|
|
95
|
+
: { kind: 'handled', name: command.name }
|
|
46
96
|
},
|
|
47
97
|
}
|
|
48
98
|
}
|
package/src/init/dockerfile.ts
CHANGED
|
@@ -50,6 +50,16 @@ export type BuildDockerfileOptions = {
|
|
|
50
50
|
// flag the seccomp default profile blocks `unshare(CLONE_NEWUSER)` and bwrap
|
|
51
51
|
// fails at startup. The two changes are load-bearing together — do not drop
|
|
52
52
|
// one without the other.
|
|
53
|
+
//
|
|
54
|
+
// `jq` is in baseline (not behind a toggle) so it is available to the
|
|
55
|
+
// per-tool bwrap sandbox that wraps agent bash calls. The sandbox only
|
|
56
|
+
// `--ro-bind`s `/usr` + `/bin` from the container (see `src/sandbox/build.ts`),
|
|
57
|
+
// so a binary that is not in the base image is invisible inside the sandbox —
|
|
58
|
+
// there is no per-call install path. JSON munging in one-shot read-only
|
|
59
|
+
// pipelines (`curl ... | jq`, `cat foo.json | jq`) is a baseline expectation
|
|
60
|
+
// for the explorer/reviewer/scout subagents, whose prompts already advertise
|
|
61
|
+
// `jq` as an available pipeline tool, so it ships unconditionally rather than
|
|
62
|
+
// as an opt-in toggle.
|
|
53
63
|
const BASELINE_APT_PACKAGES = [
|
|
54
64
|
'git',
|
|
55
65
|
'ca-certificates',
|
|
@@ -58,6 +68,7 @@ const BASELINE_APT_PACKAGES = [
|
|
|
58
68
|
'iptables',
|
|
59
69
|
'util-linux',
|
|
60
70
|
'bubblewrap',
|
|
71
|
+
'jq',
|
|
61
72
|
] as const
|
|
62
73
|
|
|
63
74
|
// curl-impersonate is the only currently-working way to query DuckDuckGo from
|
|
@@ -87,6 +98,33 @@ export const CURL_IMPERSONATE_SHA256_ARM64 = '6766bc67fd3e8e2313875f32b36b5a3fab
|
|
|
87
98
|
// the impersonation to whatever `curl_chrome` resolves to.
|
|
88
99
|
export const CURL_IMPERSONATE_PROFILE = 'chrome136'
|
|
89
100
|
|
|
101
|
+
// yq is the YAML/JSON/XML processor the `explorer` and `reviewer` subagent
|
|
102
|
+
// prompts advertise alongside `jq` as a sanctioned one-shot read-only
|
|
103
|
+
// pipeline tool (`... | yq`). Like `jq` (shipped in baseline), a binary that
|
|
104
|
+
// is not in the container base image is invisible inside the per-tool bwrap
|
|
105
|
+
// sandbox that wraps agent bash calls — the sandbox only `--ro-bind`s `/usr`
|
|
106
|
+
// + `/bin` from the container, and there is no per-call install path. So `yq`
|
|
107
|
+
// ships unconditionally, same rationale as `jq`/`bubblewrap`/`util-linux`.
|
|
108
|
+
//
|
|
109
|
+
// This is Mike Farah's Go `yq` (https://github.com/mikefarah/yq), NOT the
|
|
110
|
+
// Python jq-wrapper of the same name in Debian's `yq` apt package. The
|
|
111
|
+
// prompts pair `yq` with `jq` for jq-style expression pipelines, which is
|
|
112
|
+
// Farah's syntax — the Python tool's CLI is incompatible. Distributed as a
|
|
113
|
+
// per-arch static binary (no apt package on trixie for the Go variant), so
|
|
114
|
+
// it follows the pinned-version + per-arch SHA256 + `sha256sum -c` pattern of
|
|
115
|
+
// curl-impersonate and cloudflared rather than the apt baseline list.
|
|
116
|
+
//
|
|
117
|
+
// To bump: pick a release from https://github.com/mikefarah/yq/releases,
|
|
118
|
+
// then for each arch download yq_linux_<arch> and `shasum -a 256` it (or read
|
|
119
|
+
// the SHA-256 column from the release's `checksums` file, cross-indexed via
|
|
120
|
+
// `checksums_hashes_order`). Update all three constants in the same commit;
|
|
121
|
+
// the build fails loudly at `sha256sum -c` on a mismatch. Version literal is
|
|
122
|
+
// the release tag exactly as it appears on GitHub (with `v` prefix).
|
|
123
|
+
export const YQ_VERSION = 'v4.53.2'
|
|
124
|
+
export const YQ_SHA256_AMD64 = 'd56bf5c6819e8e696340c312bd70f849dc1678a7cda9c2ad63eebd906371d56b'
|
|
125
|
+
export const YQ_SHA256_ARM64 = '03061b2a50c7a498de2bbb92d7cb078ce433011f085a4994117c2726be4106ea'
|
|
126
|
+
export const YQ_RELEASE_URL_BASE = 'https://github.com/mikefarah/yq/releases/download'
|
|
127
|
+
|
|
90
128
|
// cloudflared powers `cloudflare-quick` tunnels. Pinned-version + per-arch
|
|
91
129
|
// SHA256 mirrors the curl-impersonate pattern above: bumping requires updating
|
|
92
130
|
// all three constants in the same commit, and the build fails loudly at
|
|
@@ -1177,6 +1215,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
|
|
|
1177
1215
|
|
|
1178
1216
|
${LAYER_2_5_CURL_IMPERSONATE}
|
|
1179
1217
|
|
|
1218
|
+
${LAYER_2_6_YQ}
|
|
1219
|
+
|
|
1180
1220
|
${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
|
|
1181
1221
|
|
|
1182
1222
|
${LAYER_4_AGENT_BROWSER_INSTALL}
|
|
@@ -1256,6 +1296,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
|
|
|
1256
1296
|
|
|
1257
1297
|
${LAYER_2_5_CURL_IMPERSONATE}
|
|
1258
1298
|
|
|
1299
|
+
${LAYER_2_6_YQ}
|
|
1300
|
+
|
|
1259
1301
|
${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
|
|
1260
1302
|
|
|
1261
1303
|
${LAYER_4_AGENT_BROWSER_INSTALL}
|
|
@@ -1310,6 +1352,24 @@ RUN ARCH_TARBALL="$(if [ "$TARGETARCH" = "arm64" ]; then echo aarch64-linux-gnu;
|
|
|
1310
1352
|
&& rm curl-impersonate.tar.gz \\
|
|
1311
1353
|
&& /usr/local/bin/curl_${CURL_IMPERSONATE_PROFILE} --version > /dev/null`
|
|
1312
1354
|
|
|
1355
|
+
// Layer 2.6: install pinned Mike Farah `yq` (Go) so the explorer/reviewer
|
|
1356
|
+
// subagents' advertised `... | yq` pipelines resolve inside the bwrap
|
|
1357
|
+
// sandbox. Unconditional like the apt baseline (jq, git): a missing binary
|
|
1358
|
+
// is invisible to sandboxed bash, so this is not behind a toggle. Placed
|
|
1359
|
+
// after curl-impersonate (curl + ca-certificates from baseline guaranteed
|
|
1360
|
+
// present) and before agent-browser so an agent-browser bump doesn't
|
|
1361
|
+
// invalidate this layer. See the YQ_* constants above for the bump recipe
|
|
1362
|
+
// and the Go-vs-Python `yq` rationale.
|
|
1363
|
+
const LAYER_2_6_YQ = `# Layer 2.6 (stable): pinned Mike Farah yq for sandbox-visible YAML pipelines.
|
|
1364
|
+
RUN ARCH_BIN="$(if [ "$TARGETARCH" = "arm64" ]; then echo arm64; else echo amd64; fi)" \\
|
|
1365
|
+
&& ARCH_SHA="$(if [ "$TARGETARCH" = "arm64" ]; then echo ${YQ_SHA256_ARM64}; else echo ${YQ_SHA256_AMD64}; fi)" \\
|
|
1366
|
+
&& cd /tmp \\
|
|
1367
|
+
&& curl -fsSL -o yq "${YQ_RELEASE_URL_BASE}/${YQ_VERSION}/yq_linux_\${ARCH_BIN}" \\
|
|
1368
|
+
&& echo "\${ARCH_SHA} yq" | sha256sum -c - \\
|
|
1369
|
+
&& chmod +x yq \\
|
|
1370
|
+
&& mv yq /usr/local/bin/yq \\
|
|
1371
|
+
&& /usr/local/bin/yq --version > /dev/null`
|
|
1372
|
+
|
|
1313
1373
|
const LAYER_3_AGENT_BROWSER_ARM64_CONFIG = `# Layer 3 (stable, arm64 only): point agent-browser at the apt-installed
|
|
1314
1374
|
# chromium. Independent of the npm install below so it stays cached across
|
|
1315
1375
|
# agent-browser version bumps.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { effectiveBaseUrl } from '@/agent/model-overrides'
|
|
1
2
|
import { KNOWN_PROVIDERS, type KnownProviderId } from '@/config/providers'
|
|
2
3
|
|
|
3
4
|
const PROVIDER_PROBE: Partial<Record<KnownProviderId, { url: string; authHeader: 'bearer' | 'x-api-key' }>> = {
|
|
@@ -8,6 +9,19 @@ const PROVIDER_PROBE: Partial<Record<KnownProviderId, { url: string; authHeader:
|
|
|
8
9
|
'zai-coding': { url: 'https://api.z.ai/api/coding/paas/v4/models', authHeader: 'bearer' },
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
// When a base-URL override (ANTHROPIC_BASE_URL / OPENAI_BASE_URL) points at a
|
|
13
|
+
// proxy, probe THAT endpoint — validating against the public API would test the
|
|
14
|
+
// wrong gateway (and may reject a proxy-only credential). The path suffix is
|
|
15
|
+
// whatever the hardcoded default probe URL adds on top of the provider's
|
|
16
|
+
// default baseUrl, so providers with different version-segment conventions
|
|
17
|
+
// (anthropic baseUrl omits `/v1`, openai includes it) each keep their own path.
|
|
18
|
+
function probeUrlFor(providerId: KnownProviderId, defaultUrl: string): string {
|
|
19
|
+
const defaultBase = KNOWN_PROVIDERS[providerId].baseUrl
|
|
20
|
+
const base = effectiveBaseUrl(providerId, defaultBase)
|
|
21
|
+
if (base === undefined) return defaultUrl
|
|
22
|
+
return `${base}${defaultUrl.slice(defaultBase.length)}`
|
|
23
|
+
}
|
|
24
|
+
|
|
11
25
|
export type KeyValidationResult =
|
|
12
26
|
| { kind: 'ok' }
|
|
13
27
|
| { kind: 'skipped'; reason: 'no-probe' | 'network-error'; detail?: string }
|
|
@@ -36,7 +50,7 @@ export async function validateApiKey(
|
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
try {
|
|
39
|
-
const res = await fetchImpl(probe.url, {
|
|
53
|
+
const res = await fetchImpl(probeUrlFor(providerId, probe.url), {
|
|
40
54
|
method: 'GET',
|
|
41
55
|
headers,
|
|
42
56
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
package/src/inspect/loop.ts
CHANGED
|
@@ -2,6 +2,12 @@ import { runInspect, type RunInspectOptions, type RunInspectResult } from './ind
|
|
|
2
2
|
|
|
3
3
|
export type RunInspectLoopOptions = Omit<RunInspectOptions, 'escSignal'> & {
|
|
4
4
|
newEscSignal: () => AbortSignal
|
|
5
|
+
// Runs after every runInspect attempt settles. The caller disarms the raw-mode
|
|
6
|
+
// ESC listener here so the live tail releases stdin before clack re-opens the
|
|
7
|
+
// picker: an ESC-aborted tail leaves the listener armed (raw mode on, 'data'
|
|
8
|
+
// handler attached), and handing clack that flowing stream freezes the picker
|
|
9
|
+
// on SSH/Bun pseudo-TTYs.
|
|
10
|
+
afterEscStream?: () => void
|
|
5
11
|
}
|
|
6
12
|
|
|
7
13
|
export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunInspectResult> {
|
|
@@ -23,7 +29,12 @@ export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunIn
|
|
|
23
29
|
if (sessionArg !== undefined) callOpts.sessionIdOrPrefix = sessionArg
|
|
24
30
|
else delete (callOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
|
|
25
31
|
|
|
26
|
-
|
|
32
|
+
let result: RunInspectResult
|
|
33
|
+
try {
|
|
34
|
+
result = await runInspect(callOpts)
|
|
35
|
+
} finally {
|
|
36
|
+
opts.afterEscStream?.()
|
|
37
|
+
}
|
|
27
38
|
if (!result.ok) return result
|
|
28
39
|
if (result.escToPicker !== true) return result
|
|
29
40
|
sessionArg = undefined
|
|
@@ -9,6 +9,12 @@ export type PermissionService = {
|
|
|
9
9
|
has(origin: SessionOrigin | undefined, permission: string): boolean
|
|
10
10
|
resolveRole(origin: SessionOrigin | undefined): string
|
|
11
11
|
describe(origin: SessionOrigin | undefined): { role: string; permissions: readonly string[] }
|
|
12
|
+
// Orders two role names on the severity tower so callers can cap an
|
|
13
|
+
// action to the requester's role (a guest turn must not read the output
|
|
14
|
+
// of a member-spawned subagent). `undefined` means an unknown role on
|
|
15
|
+
// either side and MUST be treated as deny, never allow — mistreating it
|
|
16
|
+
// as allow reopens the privilege-escalation hole this gate closes.
|
|
17
|
+
compareRoleSeverity(a: string, b: string): -1 | 0 | 1 | undefined
|
|
12
18
|
// Rebuilds the resolved role table from the given roles config, preserving
|
|
13
19
|
// the same plugin-permission set captured at construction time. Used by
|
|
14
20
|
// the config reloadable so role match-rule edits (typeclaw role claim,
|
|
@@ -25,6 +31,7 @@ export type UnknownPermissionWarning = {
|
|
|
25
31
|
export const noopPermissionService: PermissionService = {
|
|
26
32
|
has: () => false,
|
|
27
33
|
resolveRole: () => 'guest',
|
|
34
|
+
compareRoleSeverity: () => undefined,
|
|
28
35
|
describe: () => ({ role: 'guest', permissions: [] }),
|
|
29
36
|
replaceRoles: () => {},
|
|
30
37
|
}
|
|
@@ -139,6 +146,15 @@ export function createPermissionService(opts: CreatePermissionServiceOptions = {
|
|
|
139
146
|
return 'guest'
|
|
140
147
|
}
|
|
141
148
|
|
|
149
|
+
function roleSeverity(name: string): number | undefined {
|
|
150
|
+
if (name === 'owner') return 4
|
|
151
|
+
if (name === 'trusted') return 3
|
|
152
|
+
if (name === 'member') return 1
|
|
153
|
+
if (name === 'guest') return 0
|
|
154
|
+
if (byName.has(name)) return 2
|
|
155
|
+
return undefined
|
|
156
|
+
}
|
|
157
|
+
|
|
142
158
|
return {
|
|
143
159
|
has(origin, permission) {
|
|
144
160
|
// Fail-safe floor: an undefined origin holds nothing, regardless of
|
|
@@ -156,6 +172,14 @@ export function createPermissionService(opts: CreatePermissionServiceOptions = {
|
|
|
156
172
|
return role.permissions.includes(permission)
|
|
157
173
|
},
|
|
158
174
|
resolveRole,
|
|
175
|
+
compareRoleSeverity(a, b) {
|
|
176
|
+
const aRank = roleSeverity(a)
|
|
177
|
+
const bRank = roleSeverity(b)
|
|
178
|
+
if (aRank === undefined || bRank === undefined) return undefined
|
|
179
|
+
if (aRank < bRank) return -1
|
|
180
|
+
if (aRank > bRank) return 1
|
|
181
|
+
return 0
|
|
182
|
+
},
|
|
159
183
|
describe(origin) {
|
|
160
184
|
const name = resolveRole(origin)
|
|
161
185
|
const role = byName.get(name)
|
package/src/plugin/context.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ResolveGithubTokenForRepo } from '@/channels/github-token-bridge'
|
|
1
2
|
import type { PermissionService } from '@/permissions'
|
|
2
3
|
|
|
3
4
|
import type { PluginContext, PluginLogger, SpawnSubagentOptions } from './types'
|
|
@@ -11,10 +12,16 @@ export type CreatePluginContextOptions<TConfig> = {
|
|
|
11
12
|
config: TConfig
|
|
12
13
|
logger: PluginLogger
|
|
13
14
|
permissions: PermissionService
|
|
15
|
+
resolveGithubTokenForRepo?: ResolveGithubTokenForRepo
|
|
14
16
|
spawnSubagent: SpawnSubagentFn
|
|
15
17
|
isBooted: () => boolean
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
const githubTokenUnavailable: ResolveGithubTokenForRepo = async () => ({
|
|
21
|
+
kind: 'unavailable',
|
|
22
|
+
reason: 'GitHub token resolution is not wired in this context.',
|
|
23
|
+
})
|
|
24
|
+
|
|
18
25
|
export function createPluginContext<TConfig>(opts: CreatePluginContextOptions<TConfig>): PluginContext<TConfig> {
|
|
19
26
|
return Object.freeze({
|
|
20
27
|
name: opts.name,
|
|
@@ -23,6 +30,7 @@ export function createPluginContext<TConfig>(opts: CreatePluginContextOptions<TC
|
|
|
23
30
|
config: opts.config,
|
|
24
31
|
logger: opts.logger,
|
|
25
32
|
permissions: opts.permissions,
|
|
33
|
+
github: { resolveTokenForRepo: opts.resolveGithubTokenForRepo ?? githubTokenUnavailable },
|
|
26
34
|
spawnSubagent: async (name: string, payload?: unknown, options?: SpawnSubagentOptions) => {
|
|
27
35
|
if (!opts.isBooted()) {
|
|
28
36
|
throw new Error(
|
package/src/plugin/manager.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
|
+
import type { ResolveGithubTokenForRepo } from '@/channels/github-token-bridge'
|
|
3
4
|
import type { CronJob } from '@/cron'
|
|
4
5
|
import {
|
|
5
6
|
createPermissionService,
|
|
@@ -20,6 +21,7 @@ export type LoadPluginsOptions = {
|
|
|
20
21
|
configsByName: Record<string, unknown>
|
|
21
22
|
loadEntry?: LoadPluginEntryFn
|
|
22
23
|
roles?: RolesConfig
|
|
24
|
+
resolveGithubTokenForRepo?: ResolveGithubTokenForRepo
|
|
23
25
|
// Bundled plugins resolved by the runtime (not from typeclaw.json). Loaded
|
|
24
26
|
// before user-declared `entries` so a config block named after a bundled
|
|
25
27
|
// plugin (e.g. "memory") is consumed by the bundled plugin, and so plugin-
|
|
@@ -101,6 +103,7 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
|
|
|
101
103
|
config: validatedConfig as never,
|
|
102
104
|
logger,
|
|
103
105
|
permissions,
|
|
106
|
+
resolveGithubTokenForRepo: opts.resolveGithubTokenForRepo,
|
|
104
107
|
spawnSubagent: (name, payload, options) => spawnSubagentImpl(name, payload, options),
|
|
105
108
|
isBooted: () => booted,
|
|
106
109
|
})
|
package/src/plugin/types.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { z } from 'zod'
|
|
|
2
2
|
|
|
3
3
|
import type { SessionOrigin } from '@/agent/session-origin'
|
|
4
4
|
import type { SubagentShared } from '@/agent/subagents'
|
|
5
|
+
import type { ResolveGithubTokenForRepo } from '@/channels/github-token-bridge'
|
|
5
6
|
import type { PermissionService } from '@/permissions'
|
|
6
7
|
|
|
7
8
|
export type ContentPart = { type: 'text'; text: string } | { type: 'image'; mimeType: string; data: string }
|
|
@@ -273,9 +274,14 @@ export type PluginContext<TConfig = never> = {
|
|
|
273
274
|
readonly config: TConfig
|
|
274
275
|
readonly logger: PluginLogger
|
|
275
276
|
readonly permissions: PermissionService
|
|
277
|
+
readonly github: PluginGithubServices
|
|
276
278
|
spawnSubagent: (name: string, payload?: unknown, options?: SpawnSubagentOptions) => Promise<void>
|
|
277
279
|
}
|
|
278
280
|
|
|
281
|
+
export type PluginGithubServices = {
|
|
282
|
+
resolveTokenForRepo: ResolveGithubTokenForRepo
|
|
283
|
+
}
|
|
284
|
+
|
|
279
285
|
export type PluginExports = {
|
|
280
286
|
tools?: Record<string, Tool<any>>
|
|
281
287
|
subagents?: Record<string, Subagent<any>>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import agentBrowserPlugin from '@/bundled-plugins/agent-browser'
|
|
2
2
|
import backupPlugin from '@/bundled-plugins/backup'
|
|
3
3
|
import explorerPlugin from '@/bundled-plugins/explorer'
|
|
4
|
+
import githubCliAuthPlugin from '@/bundled-plugins/github-cli-auth'
|
|
4
5
|
import guardPlugin from '@/bundled-plugins/guard'
|
|
5
6
|
import memoryPlugin from '@/bundled-plugins/memory'
|
|
6
7
|
import operatorPlugin from '@/bundled-plugins/operator'
|
|
@@ -28,6 +29,13 @@ import type { ResolvedPlugin } from '@/plugin'
|
|
|
28
29
|
// Reversing this order would make guard advise on the full oversized payload
|
|
29
30
|
// and then tool-result-cap would clobber the advice text along with the rest.
|
|
30
31
|
//
|
|
32
|
+
// `github-cli-auth` is registered AFTER `security` so security's `tool.before`
|
|
33
|
+
// runs its exfil/secret scanners on the bash command first. github-cli-auth
|
|
34
|
+
// injects the minted token via an env overlay (TYPECLAW_INTERNAL_BASH_ENV), not
|
|
35
|
+
// by rewriting the command string, so the token never enters argv or logs — but
|
|
36
|
+
// ordering security first still matters so a blocked command never reaches the
|
|
37
|
+
// mint path at all.
|
|
38
|
+
//
|
|
31
39
|
// `memory` is registered before `backup` so memory's dreaming commits always
|
|
32
40
|
// land in the same git index window before backup's commit-and-push cycle.
|
|
33
41
|
// They commit disjoint paths today (memory/ vs sessions/ + agent changes),
|
|
@@ -37,6 +45,7 @@ export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
|
|
|
37
45
|
{ name: 'security', version: undefined, source: '<bundled>', defined: securityPlugin },
|
|
38
46
|
{ name: 'tool-result-cap', version: undefined, source: '<bundled>', defined: toolResultCapPlugin },
|
|
39
47
|
{ name: 'guard', version: undefined, source: '<bundled>', defined: guardPlugin },
|
|
48
|
+
{ name: 'github-cli-auth', version: undefined, source: '<bundled>', defined: githubCliAuthPlugin },
|
|
40
49
|
{ name: 'memory', version: undefined, source: '<bundled>', defined: memoryPlugin },
|
|
41
50
|
{ name: 'backup', version: undefined, source: '<bundled>', defined: backupPlugin },
|
|
42
51
|
{ name: 'agent-browser', version: undefined, source: '<bundled>', defined: agentBrowserPlugin },
|
package/src/run/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { resolveCapOptionsFromConfig } from '@/bundled-plugins/tool-result-cap'
|
|
|
19
19
|
import {
|
|
20
20
|
createChannelManager,
|
|
21
21
|
createChannelsReloadable,
|
|
22
|
+
createGithubTokenBridge,
|
|
22
23
|
createSubagentCompletionBridge,
|
|
23
24
|
type ChannelManager,
|
|
24
25
|
type SubagentCompletionBridge,
|
|
@@ -138,11 +139,13 @@ export async function startAgent({
|
|
|
138
139
|
|
|
139
140
|
const pluginConfigsByName = loadPluginConfigsSync(cwd)
|
|
140
141
|
const cwdConfig = loadConfigSync(cwd)
|
|
142
|
+
const githubTokenBridge = createGithubTokenBridge()
|
|
141
143
|
const pluginsLoaded = await loadPlugins({
|
|
142
144
|
entries: cwdConfig.plugins,
|
|
143
145
|
agentDir: cwd,
|
|
144
146
|
configsByName: pluginConfigsByName,
|
|
145
147
|
bundled: BUNDLED_PLUGINS,
|
|
148
|
+
resolveGithubTokenForRepo: githubTokenBridge.resolveTokenForRepo,
|
|
146
149
|
...(cwdConfig.roles !== undefined ? { roles: cwdConfig.roles } : {}),
|
|
147
150
|
})
|
|
148
151
|
|
|
@@ -255,6 +258,7 @@ export async function startAgent({
|
|
|
255
258
|
}),
|
|
256
259
|
permissions: pluginsLoaded.permissions,
|
|
257
260
|
claimHandler: claimController.claimHandler,
|
|
261
|
+
githubTokenBridge,
|
|
258
262
|
stream,
|
|
259
263
|
})
|
|
260
264
|
|