typeclaw 0.16.0 → 0.17.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 +32 -1
- package/src/agent/session-origin.ts +54 -12
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tools/grant-role.ts +214 -0
- package/src/channels/router.ts +87 -17
- package/src/cli/role.ts +10 -1
- package/src/cli/ui.ts +6 -4
- package/src/config/reloadable.ts +10 -3
- package/src/init/index.ts +5 -33
- package/src/init/run-owner-claim.ts +21 -3
- package/src/permissions/builtins.ts +14 -4
- package/src/permissions/grant.ts +92 -16
- package/src/permissions/index.ts +8 -2
- package/src/permissions/permissions.ts +9 -0
- package/src/permissions/resolve.ts +10 -0
- package/src/role-claim/index.ts +1 -0
- package/src/role-claim/reload-after-claim.ts +34 -0
- package/src/run/channel-session-factory.ts +6 -1
- package/src/run/index.ts +18 -1
- package/src/sandbox/build.ts +32 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +15 -3
- package/src/skills/typeclaw-permissions/SKILL.md +11 -3
- package/src/skills/typeclaw-skills/SKILL.md +3 -1
package/package.json
CHANGED
package/src/agent/index.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { loadMemory } from '@/bundled-plugins/memory/load-memory'
|
|
|
9
9
|
import type { ChannelRouter } from '@/channels/router'
|
|
10
10
|
import { getConfig, resolveModel, resolveProfile } from '@/config'
|
|
11
11
|
import { defaultThinkingLevelForRef, providerForModelRef, type KnownModelRef } from '@/config/providers'
|
|
12
|
-
import type { PermissionService } from '@/permissions'
|
|
12
|
+
import type { PermissionService, RolesConfig } from '@/permissions'
|
|
13
13
|
import type {
|
|
14
14
|
BuiltinToolRef,
|
|
15
15
|
HookBus,
|
|
@@ -50,6 +50,7 @@ import { createChannelFetchAttachmentTool } from './tools/channel-fetch-attachme
|
|
|
50
50
|
import { createChannelHistoryTool } from './tools/channel-history'
|
|
51
51
|
import { createChannelReplyTool } from './tools/channel-reply'
|
|
52
52
|
import { createChannelSendTool } from './tools/channel-send'
|
|
53
|
+
import { createGrantRoleTool } from './tools/grant-role'
|
|
53
54
|
import { createRestartTool } from './tools/restart'
|
|
54
55
|
import { createSkipResponseTool } from './tools/skip-response'
|
|
55
56
|
import { createSpawnSubagentTool } from './tools/spawn-subagent'
|
|
@@ -141,6 +142,11 @@ export type CreateSessionOptions = {
|
|
|
141
142
|
// prompt is not regenerated; see `typeclaw-permissions` skill for how the
|
|
142
143
|
// agent should interpret the snapshot on later turns.
|
|
143
144
|
permissions?: PermissionService
|
|
145
|
+
// Re-reads roles from disk for the grant_role tool's hot-reload after a match
|
|
146
|
+
// grant. Production threads a reload-then-read (reloadConfig + getConfig);
|
|
147
|
+
// must not be an in-memory snapshot or the grant reapplies stale roles.
|
|
148
|
+
// Omitted when no grant_role tool is wired (the tool requires permissions).
|
|
149
|
+
reloadRoles?: () => RolesConfig | undefined
|
|
144
150
|
// Model profile name. Resolved against `config.models` to pick the concrete
|
|
145
151
|
// model ref this session binds to. Unknown profile names fall back to
|
|
146
152
|
// `default` with a one-time console warning. Omitted → `default`. Threaded
|
|
@@ -322,6 +328,12 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
322
328
|
permissions: options.permissions,
|
|
323
329
|
stream: options.stream,
|
|
324
330
|
}),
|
|
331
|
+
...buildRoleGrantTools({
|
|
332
|
+
agentDir: options.plugins?.agentDir,
|
|
333
|
+
getOrigin,
|
|
334
|
+
permissions: options.permissions,
|
|
335
|
+
reloadRoles: options.reloadRoles,
|
|
336
|
+
}),
|
|
325
337
|
]
|
|
326
338
|
// Hook coverage for pi's builtin coding tools (read/bash/edit/write/grep/
|
|
327
339
|
// find/ls) — pi 0.67.3 ignores `tools:` for implementation, so the only
|
|
@@ -577,6 +589,25 @@ export function buildSubagentOrchestrationTools(opts: {
|
|
|
577
589
|
]
|
|
578
590
|
}
|
|
579
591
|
|
|
592
|
+
export function buildRoleGrantTools(opts: {
|
|
593
|
+
agentDir: string | undefined
|
|
594
|
+
getOrigin: () => SessionOrigin | undefined
|
|
595
|
+
permissions: PermissionService | undefined
|
|
596
|
+
reloadRoles: (() => RolesConfig | undefined) | undefined
|
|
597
|
+
}): ToolDefinition[] {
|
|
598
|
+
if (opts.agentDir === undefined || opts.permissions === undefined || opts.reloadRoles === undefined) {
|
|
599
|
+
return []
|
|
600
|
+
}
|
|
601
|
+
return [
|
|
602
|
+
createGrantRoleTool({
|
|
603
|
+
agentDir: opts.agentDir,
|
|
604
|
+
getOrigin: opts.getOrigin,
|
|
605
|
+
permissions: opts.permissions,
|
|
606
|
+
reloadRoles: opts.reloadRoles,
|
|
607
|
+
}),
|
|
608
|
+
]
|
|
609
|
+
}
|
|
610
|
+
|
|
580
611
|
function wrapRegistryTools(
|
|
581
612
|
plugins: PluginSessionWiring | undefined,
|
|
582
613
|
getOrigin: () => SessionOrigin | undefined,
|
|
@@ -111,11 +111,12 @@ function getPlatformInfo(adapter: AdapterId): PlatformInfo {
|
|
|
111
111
|
// TUI is always `owner` by construction — annotating it would add noise to
|
|
112
112
|
// every interactive session for zero new information.
|
|
113
113
|
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
114
|
+
// Channel origins do NOT render this concrete role. A channel session is
|
|
115
|
+
// keyed by chat/thread, so the opener's role is wrong for every later
|
|
116
|
+
// speaker and printing their permission list leaks it into shared context.
|
|
117
|
+
// Channel origins render renderChannelRolePolicy() instead, and the
|
|
118
|
+
// authoritative per-turn role rides in the non-cacheable `<your-role>`
|
|
119
|
+
// turn anchor (renderTurnRoleAnchor in system-prompt.ts).
|
|
119
120
|
export type SessionRoleContext = {
|
|
120
121
|
role: string
|
|
121
122
|
permissions: readonly string[]
|
|
@@ -128,21 +129,22 @@ export function renderSessionOrigin(
|
|
|
128
129
|
): string {
|
|
129
130
|
switch (origin.kind) {
|
|
130
131
|
case 'tui':
|
|
131
|
-
return withRoleContext(renderTuiOrigin(), roleContext)
|
|
132
|
+
return withRoleContext(renderTuiOrigin(), roleContext, origin.kind)
|
|
132
133
|
case 'cron':
|
|
133
|
-
return withRoleContext(renderCronOrigin(origin), roleContext)
|
|
134
|
+
return withRoleContext(renderCronOrigin(origin), roleContext, origin.kind)
|
|
134
135
|
case 'channel':
|
|
135
|
-
return withRoleContext(renderChannelOrigin(origin, now), roleContext)
|
|
136
|
+
return withRoleContext(renderChannelOrigin(origin, now), roleContext, origin.kind)
|
|
136
137
|
case 'subagent':
|
|
137
|
-
return withRoleContext(renderSubagentOrigin(origin), roleContext)
|
|
138
|
+
return withRoleContext(renderSubagentOrigin(origin), roleContext, origin.kind)
|
|
138
139
|
case 'system':
|
|
139
|
-
return withRoleContext(renderSystemOrigin(origin), roleContext)
|
|
140
|
+
return withRoleContext(renderSystemOrigin(origin), roleContext, origin.kind)
|
|
140
141
|
}
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
function withRoleContext(block: string, ctx: SessionRoleContext | undefined): string {
|
|
144
|
+
function withRoleContext(block: string, ctx: SessionRoleContext | undefined, kind: SessionOrigin['kind']): string {
|
|
144
145
|
if (ctx === undefined) return block
|
|
145
|
-
|
|
146
|
+
const roleBlock = kind === 'channel' ? renderChannelRolePolicy() : renderRoleContext(ctx)
|
|
147
|
+
return `${block}\n\n${roleBlock}`
|
|
146
148
|
}
|
|
147
149
|
|
|
148
150
|
function renderRoleContext(ctx: SessionRoleContext): string {
|
|
@@ -160,6 +162,30 @@ function renderRoleContext(ctx: SessionRoleContext): string {
|
|
|
160
162
|
].join('\n')
|
|
161
163
|
}
|
|
162
164
|
|
|
165
|
+
// Channel sessions are keyed by chat/thread, not by author: one session can see
|
|
166
|
+
// many speakers with different roles. Rendering the opener's concrete role here
|
|
167
|
+
// would (1) be wrong for every later speaker and (2) leak the opener's full
|
|
168
|
+
// permission list into shared context. So channel origins get a cache-stable
|
|
169
|
+
// policy instead of a resolved identity; the authoritative per-turn role rides
|
|
170
|
+
// in the non-cacheable `<your-role>` turn anchor.
|
|
171
|
+
function renderChannelRolePolicy(): string {
|
|
172
|
+
return [
|
|
173
|
+
'## Your role in this session',
|
|
174
|
+
'',
|
|
175
|
+
'This is a channel conversation that may include multiple speakers. Do not',
|
|
176
|
+
'assume one speaker’s role applies to later messages. For each user turn the',
|
|
177
|
+
'current speaker’s effective role is provided in the turn context as a',
|
|
178
|
+
'`<your-role>` tag; that per-turn role is authoritative for the current',
|
|
179
|
+
'message and overrides any role implied by session-opening context. An absent',
|
|
180
|
+
'`<your-role>` tag means the current speaker is the unconstrained default.',
|
|
181
|
+
'',
|
|
182
|
+
'Tool calls and channel admission are gated by the current speaker’s',
|
|
183
|
+
'permissions; a `blocked:` or "denied by permissions" message means that',
|
|
184
|
+
'speaker lacks the permission the guard wanted. See the',
|
|
185
|
+
'`typeclaw-permissions` skill for what each role can do.',
|
|
186
|
+
].join('\n')
|
|
187
|
+
}
|
|
188
|
+
|
|
163
189
|
function renderTuiOrigin(): string {
|
|
164
190
|
return [
|
|
165
191
|
'## Session origin',
|
|
@@ -262,6 +288,22 @@ function renderChannelOrigin(
|
|
|
262
288
|
'is a tool call. Plain-text output is invisible.',
|
|
263
289
|
]
|
|
264
290
|
|
|
291
|
+
// GitHub has no separate "chat" surface — channel_reply IS a public comment
|
|
292
|
+
// on this PR/issue. Without saying so, models default to the Slack-style
|
|
293
|
+
// two-surface split and post operator-facing meta-commentary ("Posted review
|
|
294
|
+
// result for PR #511") straight into the PR thread, where it reads absurdly.
|
|
295
|
+
if (origin.adapter === 'github') {
|
|
296
|
+
lines.push(
|
|
297
|
+
'',
|
|
298
|
+
'**`channel_reply` posts a public comment directly on this PR/issue.** It',
|
|
299
|
+
'is not a side-report to an operator — the reply lands in this exact',
|
|
300
|
+
'thread, read by everyone on the PR. Write the substance for that',
|
|
301
|
+
'audience: post the answer (or review summary) itself, never a status',
|
|
302
|
+
'line about having posted it elsewhere. A narrated "Posted review result',
|
|
303
|
+
'for PR #N: …" inside the PR is exactly the failure to avoid.',
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
265
307
|
const conversationLine = renderConversationLine(origin)
|
|
266
308
|
if (conversationLine !== null) lines.push('', conversationLine)
|
|
267
309
|
|
|
@@ -173,7 +173,7 @@ export function renderTurnTimeAnchor(now: Date = new Date()): string {
|
|
|
173
173
|
// block for a TUI owner.
|
|
174
174
|
export function renderTurnRoleAnchor(role: string): string | undefined {
|
|
175
175
|
if (role === 'owner') return undefined
|
|
176
|
-
return `<your-role>${role}</your-role
|
|
176
|
+
return `<your-role authority="current-speaker">${role}</your-role> (authoritative for this message; overrides any role implied by the system prompt)`
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
// Compact replacement for DEFAULT_SYSTEM_PROMPT, used by non-interactive
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { Type } from '@mariozechner/pi-ai'
|
|
2
|
+
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
grantRole,
|
|
6
|
+
grantRolePermission,
|
|
7
|
+
isDmChannelOrigin,
|
|
8
|
+
parseMatchRule,
|
|
9
|
+
type PermissionService,
|
|
10
|
+
type RolesConfig,
|
|
11
|
+
} from '@/permissions'
|
|
12
|
+
|
|
13
|
+
import type { SessionOrigin } from '../session-origin'
|
|
14
|
+
|
|
15
|
+
export type GrantRoleToolDetails =
|
|
16
|
+
| { ok: true; mode: 'match' | 'permission'; role: string; value: string; added: boolean; restartRequired: boolean }
|
|
17
|
+
| { ok: false; error: string }
|
|
18
|
+
|
|
19
|
+
export type CreateGrantRoleToolOptions = {
|
|
20
|
+
agentDir: string
|
|
21
|
+
getOrigin: () => SessionOrigin | undefined
|
|
22
|
+
permissions: PermissionService
|
|
23
|
+
// Re-reads roles FROM DISK and returns the fresh set, for hot-reloading a
|
|
24
|
+
// match-grant after grantRole writes typeclaw.json. Must NOT read an
|
|
25
|
+
// in-memory config snapshot: grantRole writes the file directly, so a
|
|
26
|
+
// snapshot taken before the live config pointer is reloaded would be stale
|
|
27
|
+
// and replaceRoles would reapply the pre-grant table. Production wires this
|
|
28
|
+
// to reloadConfig(agentDir) + getConfig().roles, matching the config
|
|
29
|
+
// reloadable. A permission-grant is restart-required, so the reload has no
|
|
30
|
+
// runtime effect for it, but we reload anyway to keep a same-session
|
|
31
|
+
// subsequent match-grant reading a consistent table.
|
|
32
|
+
reloadRoles: () => RolesConfig | undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Roles this tool may target, lowest to highest. `guest` is a valid target
|
|
36
|
+
// (an operator opening guest channel.respond) but never a granter — the gate
|
|
37
|
+
// below requires the caller to resolve to owner/trusted.
|
|
38
|
+
const TIER_ORDER = ['guest', 'member', 'trusted', 'owner'] as const
|
|
39
|
+
type TierRole = (typeof TIER_ORDER)[number]
|
|
40
|
+
|
|
41
|
+
function tierOf(role: string): number {
|
|
42
|
+
return TIER_ORDER.indexOf(role as TierRole)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isTierRole(role: string): role is TierRole {
|
|
46
|
+
return (TIER_ORDER as readonly string[]).includes(role)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A single-principal turn carries only the principal's own first-party words:
|
|
50
|
+
// the TUI (a human typing directly) or a 1:1 DM (principal + bot, no
|
|
51
|
+
// third-party messages buffered in). A group/open channel turn always mixes in
|
|
52
|
+
// other authors' messages, which is the confused-deputy surface that lets a
|
|
53
|
+
// guest prompt-inject a trusted turn into rewriting the access-control table.
|
|
54
|
+
// Role grants are confined to single-principal turns so that surface does not
|
|
55
|
+
// exist.
|
|
56
|
+
function isSinglePrincipalOrigin(origin: SessionOrigin | undefined): boolean {
|
|
57
|
+
if (origin === undefined) return false
|
|
58
|
+
if (origin.kind === 'tui') return true
|
|
59
|
+
if (origin.kind === 'channel') return isDmChannelOrigin(origin)
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createGrantRoleTool(options: CreateGrantRoleToolOptions) {
|
|
64
|
+
const { agentDir, getOrigin, permissions, reloadRoles } = options
|
|
65
|
+
|
|
66
|
+
return defineTool({
|
|
67
|
+
name: 'grant_role',
|
|
68
|
+
label: 'Grant Role',
|
|
69
|
+
description:
|
|
70
|
+
'Assign an author to a role (match grant) or give a role a capability (permission grant), by editing typeclaw.json#roles. ' +
|
|
71
|
+
'Use this to onboard a teammate ("respond to author U_X" → grant them member) or to open the agent to a wider audience ' +
|
|
72
|
+
'("let anyone in this channel message you" → grant guest channel.respond). ' +
|
|
73
|
+
'Only callable from the TUI or a 1:1 DM by an owner or trusted user — group-channel turns cannot use it. ' +
|
|
74
|
+
'Permission grants are restart-required: they land in typeclaw.json but take effect on the next `typeclaw restart`.',
|
|
75
|
+
parameters: Type.Object({
|
|
76
|
+
role: Type.Union(
|
|
77
|
+
[Type.Literal('owner'), Type.Literal('trusted'), Type.Literal('member'), Type.Literal('guest')],
|
|
78
|
+
{
|
|
79
|
+
description: 'The role to grant TO.',
|
|
80
|
+
},
|
|
81
|
+
),
|
|
82
|
+
match: Type.Optional(
|
|
83
|
+
Type.String({
|
|
84
|
+
description:
|
|
85
|
+
'A match rule assigning an author/scope to the role, e.g. "slack:T0123 author:U_WIFE". ' +
|
|
86
|
+
'Provide exactly one of match or permission.',
|
|
87
|
+
}),
|
|
88
|
+
),
|
|
89
|
+
permission: Type.Optional(
|
|
90
|
+
Type.String({
|
|
91
|
+
description:
|
|
92
|
+
'A capability to add to the role, e.g. "channel.respond". Provide exactly one of match or permission. ' +
|
|
93
|
+
'security.bypass.* permissions cannot be granted through this tool.',
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
}),
|
|
97
|
+
|
|
98
|
+
async execute(_toolCallId, params): Promise<ToolReturn> {
|
|
99
|
+
const origin = getOrigin()
|
|
100
|
+
|
|
101
|
+
if (!isSinglePrincipalOrigin(origin)) {
|
|
102
|
+
return err(
|
|
103
|
+
'grant_role is only available from the TUI or a 1:1 DM. A group-channel turn cannot change roles, ' +
|
|
104
|
+
'because it mixes in other participants\u2019 messages (prompt-injection surface).',
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const callerRole = permissions.resolveRole(origin)
|
|
109
|
+
if (callerRole !== 'owner' && callerRole !== 'trusted') {
|
|
110
|
+
return err(`grant_role denied: caller resolves to '${callerRole}'; only owner or trusted may grant roles.`)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const hasMatch = typeof params.match === 'string' && params.match.length > 0
|
|
114
|
+
const hasPermission = typeof params.permission === 'string' && params.permission.length > 0
|
|
115
|
+
if (hasMatch === hasPermission) {
|
|
116
|
+
return err('Provide exactly one of `match` or `permission`.')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!isTierRole(params.role)) {
|
|
120
|
+
return err(`Unknown target role '${params.role}'.`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Tier ceiling: a granter may not assign or empower a role ABOVE its own.
|
|
124
|
+
// owner(3) ≥ trusted(2) ≥ member(1) ≥ guest(0).
|
|
125
|
+
if (tierOf(params.role) > tierOf(callerRole)) {
|
|
126
|
+
return err(`grant_role denied: a ${callerRole} caller cannot grant the higher '${params.role}' role.`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return hasMatch
|
|
130
|
+
? grantMatch(params.role, params.match as string)
|
|
131
|
+
: grantPermission(callerRole, params.role, params.permission as string)
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
function grantMatch(role: string, matchRule: string): ToolReturn {
|
|
136
|
+
const parsed = parseMatchRule(matchRule)
|
|
137
|
+
if (!parsed.ok) {
|
|
138
|
+
return err(`Invalid match rule '${matchRule}': ${parsed.error}`)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const result = grantRole({ cwd: agentDir, roleName: role, matchRule })
|
|
142
|
+
if (!result.ok) return err(result.reason)
|
|
143
|
+
|
|
144
|
+
reload()
|
|
145
|
+
return ok('match', role, matchRule, result.added, false)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function grantPermission(callerRole: string, role: string, permission: string): ToolReturn {
|
|
149
|
+
// security.bypass.* defeats guards rather than enabling a feature; never
|
|
150
|
+
// grantable through an agent tool. It stays a deliberate hand-edit gated by
|
|
151
|
+
// the rolePromotion guard + an explicit ack.
|
|
152
|
+
if (permission.startsWith('security.bypass.')) {
|
|
153
|
+
return err(
|
|
154
|
+
`grant_role refuses to grant '${permission}': security.bypass.* permissions disable security guards and ` +
|
|
155
|
+
'must be set by hand-editing typeclaw.json (gated by the rolePromotion guard), not via this tool.',
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// "Grant only what you hold": the caller cannot confer a capability it does
|
|
160
|
+
// not itself possess. Owner's resolved set is the expanded wildcard, so an
|
|
161
|
+
// owner can grant any non-bypass core permission; trusted is capped at its
|
|
162
|
+
// literal set.
|
|
163
|
+
const callerPerms = permissions.describe(getOrigin()).permissions
|
|
164
|
+
if (!callerPerms.includes(permission)) {
|
|
165
|
+
return err(
|
|
166
|
+
`grant_role denied: a ${callerRole} caller cannot grant '${permission}' because it does not hold that permission.`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const result = grantRolePermission({ cwd: agentDir, roleName: role, permission })
|
|
171
|
+
if (!result.ok) return err(result.reason)
|
|
172
|
+
|
|
173
|
+
reload()
|
|
174
|
+
return ok('permission', role, permission, result.added, true)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function reload(): void {
|
|
178
|
+
try {
|
|
179
|
+
permissions.replaceRoles(reloadRoles())
|
|
180
|
+
} catch {
|
|
181
|
+
// Best-effort hot-reload of match-grants. A failure here does not undo
|
|
182
|
+
// the on-disk write; the next config reload / restart picks it up.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function ok(
|
|
188
|
+
mode: 'match' | 'permission',
|
|
189
|
+
role: string,
|
|
190
|
+
value: string,
|
|
191
|
+
added: boolean,
|
|
192
|
+
restartRequired: boolean,
|
|
193
|
+
): ToolReturn {
|
|
194
|
+
const details: GrantRoleToolDetails = { ok: true, mode, role, value, added, restartRequired }
|
|
195
|
+
const note = added ? '' : ' (already on file)'
|
|
196
|
+
const restart = restartRequired
|
|
197
|
+
? ' This permission grant is restart-required; run `typeclaw restart` for it to take effect.'
|
|
198
|
+
: ''
|
|
199
|
+
const text =
|
|
200
|
+
mode === 'match'
|
|
201
|
+
? `Granted role '${role}' the match rule '${value}'${note}.${restart}`
|
|
202
|
+
: `Granted role '${role}' the permission '${value}'${note}.${restart}`
|
|
203
|
+
return { content: [{ type: 'text', text }], details }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function err(message: string): ToolReturn {
|
|
207
|
+
const details: GrantRoleToolDetails = { ok: false, error: message }
|
|
208
|
+
return { content: [{ type: 'text', text: message }], details }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
type ToolReturn = {
|
|
212
|
+
content: { type: 'text'; text: string }[]
|
|
213
|
+
details: GrantRoleToolDetails
|
|
214
|
+
}
|
package/src/channels/router.ts
CHANGED
|
@@ -164,6 +164,19 @@ export const SESSION_FRESHNESS_TTL_MS = 5 * 60 * 1000
|
|
|
164
164
|
// instead of awaiting the same dead promise forever.
|
|
165
165
|
export const ENSURE_LIVE_TIMEOUT_MS = 30_000
|
|
166
166
|
|
|
167
|
+
// Thrown by ensureLive() when a teardown (roles reload or shutdown) raced
|
|
168
|
+
// ahead of an in-flight creation. route() has no special handling — it
|
|
169
|
+
// propagates to the adapter's outer catch, dropping this one inbound. The
|
|
170
|
+
// next inbound creates a fresh, post-reload session, which is the intended
|
|
171
|
+
// outcome: a message that arrived mid-reload is cheap to drop, far cheaper
|
|
172
|
+
// than answering it through a session built with the stale role.
|
|
173
|
+
export class StaleLiveSessionError extends Error {
|
|
174
|
+
constructor(keyId: string) {
|
|
175
|
+
super(`[channels] ${keyId}: live session creation raced a teardown; discarded`)
|
|
176
|
+
this.name = 'StaleLiveSessionError'
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
167
180
|
// Per-callback ceilings inside the ensureLive chain. The outer watchdog
|
|
168
181
|
// catches the worst case, but per-step timeouts give better log
|
|
169
182
|
// attribution (which step hung) AND graceful degradation: a hung name
|
|
@@ -562,6 +575,7 @@ export type ChannelRouter = {
|
|
|
562
575
|
| { kind: 'recorded-after-send'; keyId: string }
|
|
563
576
|
| { kind: 'no-live-session' }
|
|
564
577
|
stop: () => Promise<void>
|
|
578
|
+
tearDownAllLive: () => Promise<void>
|
|
565
579
|
liveCount: () => number
|
|
566
580
|
__testing?: {
|
|
567
581
|
flushDebounce: (key: ChannelKey) => Promise<void>
|
|
@@ -691,6 +705,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
691
705
|
const stream = options.stream
|
|
692
706
|
const liveSessions = new Map<string, LiveSession>()
|
|
693
707
|
const creating = new Map<string, Promise<LiveSession>>()
|
|
708
|
+
// Bumped by tearDownAllLive() and stop() before they tear sessions down. An
|
|
709
|
+
// in-flight ensureLive() captures the value at creation start and re-checks
|
|
710
|
+
// it right before installing into liveSessions; if it changed, a teardown
|
|
711
|
+
// raced ahead of this creation (e.g. a roles.match reload), so the session
|
|
712
|
+
// was built with stale role context and must self-dispose instead of
|
|
713
|
+
// installing — otherwise it would reintroduce the very staleness the
|
|
714
|
+
// teardown was meant to clear.
|
|
715
|
+
let liveGeneration = 0
|
|
694
716
|
const outboundCallbacks = new Map<ChannelKey['adapter'], Set<OutboundCallback>>()
|
|
695
717
|
const typingCallbacks = new Map<ChannelKey['adapter'], Set<TypingCallback>>()
|
|
696
718
|
const channelNameResolvers = new Map<ChannelKey['adapter'], Set<ChannelNameResolver>>()
|
|
@@ -909,6 +931,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
909
931
|
const inFlight = creating.get(keyId)
|
|
910
932
|
if (inFlight) return inFlight
|
|
911
933
|
|
|
934
|
+
const generation = liveGeneration
|
|
935
|
+
|
|
912
936
|
const promise = (async () => {
|
|
913
937
|
await ensureLoaded()
|
|
914
938
|
const record = mappings ? findRecord(mappings, key) : undefined
|
|
@@ -1073,6 +1097,17 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1073
1097
|
live.unsubTypingActivity = subscribeTypingActivity(created.session, live)
|
|
1074
1098
|
installChannelReplyTerminalHook(live)
|
|
1075
1099
|
installChannelOutputCap(live)
|
|
1100
|
+
|
|
1101
|
+
// A teardown (roles reload / shutdown) ran while this session was being
|
|
1102
|
+
// built, so it carries stale role context. Dispose it instead of
|
|
1103
|
+
// installing — installing here is the exact window the race exploits.
|
|
1104
|
+
if (generation !== liveGeneration) {
|
|
1105
|
+
logger.info(
|
|
1106
|
+
`[channels] ${keyId}: discarding session created across a teardown (gen ${generation} → ${liveGeneration})`,
|
|
1107
|
+
)
|
|
1108
|
+
await tearDownLive(live)
|
|
1109
|
+
throw new StaleLiveSessionError(keyId)
|
|
1110
|
+
}
|
|
1076
1111
|
liveSessions.set(keyId, live)
|
|
1077
1112
|
|
|
1078
1113
|
if (isColdStart) {
|
|
@@ -1632,6 +1667,12 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1632
1667
|
logger.info(`[channels] ${keyId}: ignoring unknown command /${parsedCommand.name}`)
|
|
1633
1668
|
return
|
|
1634
1669
|
}
|
|
1670
|
+
if (isSessionControlDenied(event)) {
|
|
1671
|
+
logger.info(
|
|
1672
|
+
`[channels] ${keyId}: denied command /${parsedCommand.name} by permissions (session.control) author=${event.authorId}`,
|
|
1673
|
+
)
|
|
1674
|
+
return
|
|
1675
|
+
}
|
|
1635
1676
|
const existingLive = liveSessions.get(keyId)
|
|
1636
1677
|
if (!existingLive || existingLive.destroyed) {
|
|
1637
1678
|
logger.info(`[channels] ${keyId}: ignoring command /${parsedCommand.name} with no live session`)
|
|
@@ -1714,17 +1755,24 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1714
1755
|
scheduleDebouncedDrain(live)
|
|
1715
1756
|
}
|
|
1716
1757
|
|
|
1717
|
-
const
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1758
|
+
const inboundAuthorOrigin = (event: InboundMessage): SessionOrigin => ({
|
|
1759
|
+
kind: 'channel',
|
|
1760
|
+
adapter: event.adapter,
|
|
1761
|
+
workspace: event.workspace,
|
|
1762
|
+
chat: event.chat,
|
|
1763
|
+
thread: event.thread,
|
|
1764
|
+
lastInboundAuthorId: event.authorId,
|
|
1765
|
+
})
|
|
1766
|
+
|
|
1767
|
+
const isChannelRespondDenied = (event: InboundMessage): boolean =>
|
|
1768
|
+
!permissions.has(inboundAuthorOrigin(event), CORE_PERMISSIONS.channelRespond)
|
|
1769
|
+
|
|
1770
|
+
// Gated separately from channelRespond so a respond-capable guest (an
|
|
1771
|
+
// operator can grant guest channelRespond for masked stranger turns)
|
|
1772
|
+
// cannot /stop another speaker's in-flight turn. session.control is
|
|
1773
|
+
// member-and-up by default.
|
|
1774
|
+
const isSessionControlDenied = (event: InboundMessage): boolean =>
|
|
1775
|
+
!permissions.has(inboundAuthorOrigin(event), CORE_PERMISSIONS.sessionControl)
|
|
1728
1776
|
|
|
1729
1777
|
const updateLoopGuard = (live: LiveSession, event: InboundMessage): void => {
|
|
1730
1778
|
if (!event.authorIsBot) {
|
|
@@ -2334,6 +2382,27 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2334
2382
|
const stop = async (): Promise<void> => {
|
|
2335
2383
|
if (gcTimer) clearInterval(gcTimer)
|
|
2336
2384
|
gcTimer = null
|
|
2385
|
+
liveGeneration++
|
|
2386
|
+
const all = Array.from(liveSessions.values())
|
|
2387
|
+
liveSessions.clear()
|
|
2388
|
+
for (const live of all) {
|
|
2389
|
+
await tearDownLive(live)
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
// Drops every in-memory session but KEEPS the on-disk records, so the next
|
|
2394
|
+
// inbound per channel rehydrates the same transcript through a fresh
|
|
2395
|
+
// createSession() — which re-renders the frozen system-prompt role block.
|
|
2396
|
+
// This is how a `roles.<name>.match` reload reaches live channel sessions.
|
|
2397
|
+
// Unlike stop() it leaves the GC timer running; unlike stale-rollover it
|
|
2398
|
+
// keeps the sessionId, so history survives.
|
|
2399
|
+
//
|
|
2400
|
+
// Bumping liveGeneration BEFORE the snapshot is what makes this race-free:
|
|
2401
|
+
// a session mid-creation (in `creating` but not yet in `liveSessions`) won't
|
|
2402
|
+
// appear in the snapshot below, but it captured the old generation and will
|
|
2403
|
+
// self-dispose at its install guard instead of resurrecting stale role state.
|
|
2404
|
+
const tearDownAllLive = async (): Promise<void> => {
|
|
2405
|
+
liveGeneration++
|
|
2337
2406
|
const all = Array.from(liveSessions.values())
|
|
2338
2407
|
liveSessions.clear()
|
|
2339
2408
|
for (const live of all) {
|
|
@@ -2350,11 +2419,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2350
2419
|
if (!commands.has(lowered)) {
|
|
2351
2420
|
return { kind: 'unknown-command', name: lowered }
|
|
2352
2421
|
}
|
|
2353
|
-
//
|
|
2354
|
-
//
|
|
2355
|
-
//
|
|
2356
|
-
// session
|
|
2357
|
-
// distinction.
|
|
2422
|
+
// Gates on session.control (not channel.respond) so a respond-capable
|
|
2423
|
+
// guest cannot abort another speaker's turn. Runs BEFORE the live-session
|
|
2424
|
+
// lookup so an unauthorized invoker gets 'permission-denied' regardless of
|
|
2425
|
+
// session state, rather than leaking session presence via the
|
|
2426
|
+
// 'no-live-session' vs 'permission-denied' distinction.
|
|
2358
2427
|
const partial: SessionOrigin = {
|
|
2359
2428
|
kind: 'channel',
|
|
2360
2429
|
adapter: key.adapter,
|
|
@@ -2363,7 +2432,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2363
2432
|
thread: key.thread,
|
|
2364
2433
|
lastInboundAuthorId: options.invokerId,
|
|
2365
2434
|
}
|
|
2366
|
-
if (!permissions.has(partial, CORE_PERMISSIONS.
|
|
2435
|
+
if (!permissions.has(partial, CORE_PERMISSIONS.sessionControl)) {
|
|
2367
2436
|
return { kind: 'permission-denied' }
|
|
2368
2437
|
}
|
|
2369
2438
|
const resolved = resolveLiveSessionForCommand(liveSessions, key)
|
|
@@ -2476,6 +2545,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2476
2545
|
injectSubagentCompletionReminder,
|
|
2477
2546
|
markTurnSkipped,
|
|
2478
2547
|
stop,
|
|
2548
|
+
tearDownAllLive,
|
|
2479
2549
|
liveCount: () => liveSessions.size,
|
|
2480
2550
|
__testing: {
|
|
2481
2551
|
flushDebounce: async (key: ChannelKey) => {
|
package/src/cli/role.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { defineCommand } from 'citty'
|
|
|
3
3
|
|
|
4
4
|
import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
|
|
5
5
|
import { findAgentDir } from '@/init'
|
|
6
|
-
import { runClaimSession } from '@/role-claim'
|
|
6
|
+
import { reloadAfterClaim, runClaimSession } from '@/role-claim'
|
|
7
7
|
|
|
8
8
|
import { c, errorLine } from './ui'
|
|
9
9
|
|
|
@@ -76,6 +76,15 @@ const claimSub = defineCommand({
|
|
|
76
76
|
|
|
77
77
|
if (result.kind === 'completed') {
|
|
78
78
|
s.stop(c.green(`Paired as ${result.payload.role}.`))
|
|
79
|
+
s.start('Reloading config so the new match rule takes effect...')
|
|
80
|
+
const reloaded = await reloadAfterClaim({ url })
|
|
81
|
+
if (reloaded.ok) {
|
|
82
|
+
s.stop(c.green('Config reloaded.'))
|
|
83
|
+
} else {
|
|
84
|
+
// The role is already persisted; a reload failure is non-fatal.
|
|
85
|
+
s.stop(c.yellow(`Config reload failed: ${reloaded.reason}`))
|
|
86
|
+
console.log(c.dim('Run `typeclaw reload` manually to apply the new match rule.'))
|
|
87
|
+
}
|
|
79
88
|
outro(`Match rule added: ${c.bold(result.payload.matchRule)}`)
|
|
80
89
|
return
|
|
81
90
|
}
|
package/src/cli/ui.ts
CHANGED
|
@@ -252,16 +252,18 @@ export function printSlackAppManifestSetup(output: NodeJS.WritableStream = proce
|
|
|
252
252
|
// the exact permission bitfield the adapter uses. No-ops when the token isn't
|
|
253
253
|
// parseable as a Discord bot token so we never block onboarding on best-effort
|
|
254
254
|
// guidance.
|
|
255
|
-
export function printDiscordInviteHint(token: string): void {
|
|
255
|
+
export function printDiscordInviteHint(token: string, output: NodeJS.WritableStream = process.stdout): void {
|
|
256
256
|
const appId = deriveAppIdFromBotToken(token)
|
|
257
257
|
if (appId === null) return
|
|
258
|
+
// URL stays OUT of note(): clack wraps long lines with a `│` gutter that
|
|
259
|
+
// corrupts copy-pasted URLs. Same fix as src/cli/oauth-callbacks.ts.
|
|
258
260
|
note(
|
|
259
261
|
[
|
|
260
|
-
|
|
261
|
-
'',
|
|
262
|
-
'Open it, pick a server, click Authorize.',
|
|
262
|
+
'Open the URL below, pick a server, click Authorize.',
|
|
263
263
|
"The bot won't receive messages until it's in at least one server.",
|
|
264
264
|
].join('\n'),
|
|
265
265
|
'Invite the bot to a server',
|
|
266
266
|
)
|
|
267
|
+
output.write(`${buildDiscordInviteUrl(appId)}\n`)
|
|
268
|
+
output.write('\n')
|
|
267
269
|
}
|
package/src/config/reloadable.ts
CHANGED
|
@@ -11,6 +11,10 @@ export type CreateConfigReloadableOptions = {
|
|
|
11
11
|
// hand-edits) take effect without a container restart. `roles.<name>.permissions`
|
|
12
12
|
// changes still require a restart — see FIELD_EFFECTS in config.ts.
|
|
13
13
|
permissions?: PermissionService
|
|
14
|
+
// Fired after replaceRoles when a `roles.<name>.match` edit is applied. The
|
|
15
|
+
// run stage wires this to the channel router so live sessions are recreated
|
|
16
|
+
// and pick up the new role in their (otherwise frozen) system prompt.
|
|
17
|
+
onRolesChanged?: () => void | Promise<void>
|
|
14
18
|
// Skip the mount-path accessibility check inside validateConfig. Mount paths
|
|
15
19
|
// in typeclaw.json are host paths — they don't resolve inside the container,
|
|
16
20
|
// so the check would always fail on any agent that declares mounts. `mounts`
|
|
@@ -22,18 +26,20 @@ export type CreateConfigReloadableOptions = {
|
|
|
22
26
|
export function createConfigReloadable({
|
|
23
27
|
cwd,
|
|
24
28
|
permissions,
|
|
29
|
+
onRolesChanged,
|
|
25
30
|
skipMountValidation = false,
|
|
26
31
|
}: CreateConfigReloadableOptions): Reloadable {
|
|
27
32
|
return {
|
|
28
33
|
scope: 'config',
|
|
29
34
|
description: 'typeclaw.json runtime config',
|
|
30
|
-
reload: async () => doReload(cwd, permissions, skipMountValidation),
|
|
35
|
+
reload: async () => doReload(cwd, permissions, onRolesChanged, skipMountValidation),
|
|
31
36
|
}
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
async function doReload(
|
|
35
40
|
cwd: string,
|
|
36
41
|
permissions: PermissionService | undefined,
|
|
42
|
+
onRolesChanged: (() => void | Promise<void>) | undefined,
|
|
37
43
|
skipMountValidation: boolean,
|
|
38
44
|
): Promise<ReloadResult> {
|
|
39
45
|
// Mount accessibility belongs to the validation surface, not loadConfigSync —
|
|
@@ -59,8 +65,9 @@ async function doReload(
|
|
|
59
65
|
return { scope: 'config', ok: false, reason: message }
|
|
60
66
|
}
|
|
61
67
|
|
|
62
|
-
if (
|
|
63
|
-
permissions
|
|
68
|
+
if (diff.applied.some((c) => c.path === 'roles.match')) {
|
|
69
|
+
permissions?.replaceRoles(getConfig().roles)
|
|
70
|
+
await onRolesChanged?.()
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
return {
|
package/src/init/index.ts
CHANGED
|
@@ -39,14 +39,6 @@ const CONFIG_FILE = 'typeclaw.json'
|
|
|
39
39
|
const CRON_FILE = 'cron.json'
|
|
40
40
|
const PACKAGE_FILE = 'package.json'
|
|
41
41
|
|
|
42
|
-
// Seeded into `typeclaw.json#roles.member.match[]` whenever a chat adapter
|
|
43
|
-
// (slack-bot, discord-bot, telegram-bot, kakaotalk) is wired. The "*" rule
|
|
44
|
-
// matches every channel session on every platform, so the built-in `member`
|
|
45
|
-
// role (which already carries `channel.respond`) covers any inbound the
|
|
46
|
-
// router sees. Without this, freshly-hatched agents silently drop every
|
|
47
|
-
// chat message — see scaffold() and ensureDefaultChatMemberMatch() below.
|
|
48
|
-
const DEFAULT_CHAT_MEMBER_MATCH_RULE = '*'
|
|
49
|
-
|
|
50
42
|
const MARKDOWN_FILES = ['AGENTS.md', 'IDENTITY.md', 'SOUL.md', 'USER.md'] as const
|
|
51
43
|
|
|
52
44
|
// `packages/` is a bun workspace root (see `workspaces` in buildPackageJson).
|
|
@@ -586,11 +578,11 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
|
|
|
586
578
|
if (options.withTelegram) channels['telegram-bot'] = {}
|
|
587
579
|
if (options.withKakaotalk) channels.kakaotalk = {}
|
|
588
580
|
if (Object.keys(channels).length > 0) config.channels = channels
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
|
|
581
|
+
// No default `member` match is seeded. A fresh chat agent starts with every
|
|
582
|
+
// inbound author resolving to `guest` (dropped) until the operator claims
|
|
583
|
+
// `owner` (runOwnerClaim, post-hatch) and explicitly grants others. GitHub is
|
|
584
|
+
// wired separately and seeds per-repo `member.match` entries scoped to the
|
|
585
|
+
// opted-in repos. See runOwnerClaim for the mute-until-claimed warning.
|
|
594
586
|
await writeFile(join(root, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
|
|
595
587
|
|
|
596
588
|
const cron = {
|
|
@@ -1046,8 +1038,6 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
|
1046
1038
|
if (options.channel === 'github') {
|
|
1047
1039
|
await appendGithubMatchRules(options.cwd, options.repos)
|
|
1048
1040
|
await maybeInstallGithubWebhooks(options, emit)
|
|
1049
|
-
} else {
|
|
1050
|
-
await ensureDefaultChatMemberMatch(options.cwd)
|
|
1051
1041
|
}
|
|
1052
1042
|
|
|
1053
1043
|
// Commit the typeclaw.json change so the agent folder isn't silently
|
|
@@ -1329,24 +1319,6 @@ async function appendGithubMatchRules(cwd: string, repos: readonly string[]): Pr
|
|
|
1329
1319
|
await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
1330
1320
|
}
|
|
1331
1321
|
|
|
1332
|
-
// Chat-adapter counterpart of appendGithubMatchRules. See
|
|
1333
|
-
// DEFAULT_CHAT_MEMBER_MATCH_RULE for the rationale. Set-union semantics: re-
|
|
1334
|
-
// running `typeclaw channel add` for additional chat adapters is a no-op on
|
|
1335
|
-
// the match list, and any pre-existing rules the operator hand-authored
|
|
1336
|
-
// (e.g. owner-claim's per-author entry on `owner`) are left intact.
|
|
1337
|
-
async function ensureDefaultChatMemberMatch(cwd: string): Promise<void> {
|
|
1338
|
-
const path = join(cwd, CONFIG_FILE)
|
|
1339
|
-
const parsed = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
|
|
1340
|
-
const roles = isObjectRecord(parsed.roles) ? { ...parsed.roles } : {}
|
|
1341
|
-
const member = isObjectRecord(roles.member) ? { ...roles.member } : {}
|
|
1342
|
-
const existing = Array.isArray(member.match) ? member.match.filter((v): v is string => typeof v === 'string') : []
|
|
1343
|
-
if (existing.includes(DEFAULT_CHAT_MEMBER_MATCH_RULE)) return
|
|
1344
|
-
member.match = [...existing, DEFAULT_CHAT_MEMBER_MATCH_RULE]
|
|
1345
|
-
roles.member = member
|
|
1346
|
-
parsed.roles = roles
|
|
1347
|
-
await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
1322
|
// Writes per-adapter field values into `secrets.json#channels.<adapter>`.
|
|
1351
1323
|
// Refuses to overwrite existing fields: if the user already has e.g.
|
|
1352
1324
|
// `botToken` recorded (from a prior `channel add` whose follow-up steps
|
|
@@ -43,7 +43,7 @@ export async function runOwnerClaim({ url, configuredChannels }: RunOwnerClaimOp
|
|
|
43
43
|
initialValue: true,
|
|
44
44
|
})
|
|
45
45
|
if (isCancel(proceed) || proceed === false) {
|
|
46
|
-
|
|
46
|
+
warnMuteUntilClaimed()
|
|
47
47
|
return
|
|
48
48
|
}
|
|
49
49
|
|
|
@@ -78,9 +78,27 @@ export async function runOwnerClaim({ url, configuredChannels }: RunOwnerClaimOp
|
|
|
78
78
|
}
|
|
79
79
|
if (result.kind === 'error') {
|
|
80
80
|
s.stop(c.red(`Claim failed: ${result.payload.reason}`))
|
|
81
|
-
|
|
81
|
+
warnMuteUntilClaimed()
|
|
82
82
|
return
|
|
83
83
|
}
|
|
84
84
|
s.stop(c.yellow(`Claim timed out — no DM received within the window.`))
|
|
85
|
-
|
|
85
|
+
warnMuteUntilClaimed()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Printed on every path that finishes init WITHOUT a successful owner claim.
|
|
89
|
+
// Since the scoped-by-default change removed the `member: ["*"]` seeding, an
|
|
90
|
+
// unclaimed chat agent resolves every inbound to `guest` and drops it — the
|
|
91
|
+
// agent answers no one. This is a footgun unless the operator is told plainly,
|
|
92
|
+
// so the warning is loud and names the exact recovery command.
|
|
93
|
+
function warnMuteUntilClaimed(): void {
|
|
94
|
+
note(
|
|
95
|
+
[
|
|
96
|
+
`Your agent will respond to ${c.bold('no one')} until you claim the owner role —`,
|
|
97
|
+
`every inbound message is currently dropped.`,
|
|
98
|
+
'',
|
|
99
|
+
`Run ${c.bold('typeclaw role claim')} to pair yourself, then grant others`,
|
|
100
|
+
`with the agent (ask it: "respond to <user>") or by editing typeclaw.json#roles.`,
|
|
101
|
+
].join('\n'),
|
|
102
|
+
c.yellow('Heads up: agent is muted'),
|
|
103
|
+
)
|
|
86
104
|
}
|
|
@@ -10,6 +10,12 @@ export const BUILTIN_ROLE_NAMES: readonly BuiltinRoleName[] = ['owner', 'trusted
|
|
|
10
10
|
// set at boot via expandOwnerWildcard.
|
|
11
11
|
export const CORE_PERMISSIONS = {
|
|
12
12
|
channelRespond: 'channel.respond',
|
|
13
|
+
// Distinct from channelRespond so a respond-capable guest cannot abort
|
|
14
|
+
// other speakers' sessions. The /stop command (both the text-prefix and
|
|
15
|
+
// native-slash paths in the router) gates on this, not on channelRespond:
|
|
16
|
+
// an operator may grant guest channelRespond to let strangers drive masked
|
|
17
|
+
// turns, but session lifecycle control stays member-and-up.
|
|
18
|
+
sessionControl: 'session.control',
|
|
13
19
|
cronSchedule: 'cron.schedule',
|
|
14
20
|
cronModify: 'cron.modify',
|
|
15
21
|
subagentSpawn: 'subagent.spawn',
|
|
@@ -17,10 +23,11 @@ export const CORE_PERMISSIONS = {
|
|
|
17
23
|
subagentOutput: 'subagent.output',
|
|
18
24
|
subagentSpawnOperator: 'subagent.spawn.operator',
|
|
19
25
|
// Phrased as capabilities to SEE, not to hide, so the role tower stays
|
|
20
|
-
// monotonic (a higher tier sees a strict superset of a lower tier)
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
26
|
+
// monotonic (a higher tier sees a strict superset of a lower tier).
|
|
27
|
+
// resolveHiddenPaths masks whatever the resolved role lacks: fsSeePrivate
|
|
28
|
+
// gates workspace/+memory/+sessions/, fsSeeSecrets gates .env+secrets.json.
|
|
29
|
+
// The fail-safe floor is the undefined origin (see has() in
|
|
30
|
+
// permissions.ts), not the guest role, which is now grantable.
|
|
24
31
|
fsSeePrivate: 'fs.see.private',
|
|
25
32
|
fsSeeSecrets: 'fs.see.secrets',
|
|
26
33
|
} as const
|
|
@@ -62,6 +69,7 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
|
|
|
62
69
|
match: [{ kind: 'tui' }],
|
|
63
70
|
permissions: [
|
|
64
71
|
CORE_PERMISSIONS.channelRespond,
|
|
72
|
+
CORE_PERMISSIONS.sessionControl,
|
|
65
73
|
CORE_PERMISSIONS.cronSchedule,
|
|
66
74
|
CORE_PERMISSIONS.cronModify,
|
|
67
75
|
CORE_PERMISSIONS.subagentSpawn,
|
|
@@ -80,6 +88,7 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
|
|
|
80
88
|
match: [],
|
|
81
89
|
permissions: [
|
|
82
90
|
CORE_PERMISSIONS.channelRespond,
|
|
91
|
+
CORE_PERMISSIONS.sessionControl,
|
|
83
92
|
CORE_PERMISSIONS.cronSchedule,
|
|
84
93
|
CORE_PERMISSIONS.subagentSpawn,
|
|
85
94
|
CORE_PERMISSIONS.subagentCancel,
|
|
@@ -95,6 +104,7 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
|
|
|
95
104
|
match: [],
|
|
96
105
|
permissions: [
|
|
97
106
|
CORE_PERMISSIONS.channelRespond,
|
|
107
|
+
CORE_PERMISSIONS.sessionControl,
|
|
98
108
|
CORE_PERMISSIONS.subagentSpawn,
|
|
99
109
|
CORE_PERMISSIONS.subagentCancel,
|
|
100
110
|
CORE_PERMISSIONS.subagentOutput,
|
package/src/permissions/grant.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
|
|
4
|
+
import { commitSystemFileSync } from '@/git/system-commit'
|
|
5
|
+
|
|
6
|
+
import { BUILTIN_ROLES, isBuiltinRoleName } from './builtins'
|
|
4
7
|
import { parseMatchRule } from './match-rule'
|
|
5
8
|
|
|
6
9
|
// Appends `rule` to `typeclaw.json#roles.<name>.match`, creating the role
|
|
@@ -23,15 +26,97 @@ export type GrantOptions = {
|
|
|
23
26
|
matchRule: string
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
export type GrantPermissionOptions = {
|
|
30
|
+
cwd: string
|
|
31
|
+
roleName: string
|
|
32
|
+
permission: string
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
export function grantRole(opts: GrantOptions): GrantResult {
|
|
27
36
|
const validation = parseMatchRule(opts.matchRule)
|
|
28
37
|
if (!validation.ok) {
|
|
29
38
|
return { ok: false, reason: `invalid match rule '${opts.matchRule}': ${validation.error}` }
|
|
30
39
|
}
|
|
31
40
|
|
|
32
|
-
const
|
|
41
|
+
const loaded = loadConfigObject(opts.cwd, opts.roleName)
|
|
42
|
+
if (!loaded.ok) return loaded
|
|
43
|
+
|
|
44
|
+
const { obj, roles, role } = loaded
|
|
45
|
+
const existingMatch = Array.isArray(role.match) ? [...(role.match as unknown[])] : []
|
|
46
|
+
|
|
47
|
+
// Dedup by exact string equality — match rules canonicalize during load,
|
|
48
|
+
// and a literal duplicate in the file is a noise source the schema doesn't
|
|
49
|
+
// currently dedupe for us.
|
|
50
|
+
if (existingMatch.includes(opts.matchRule)) {
|
|
51
|
+
return { ok: true, added: false }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
role.match = [...existingMatch, opts.matchRule]
|
|
55
|
+
roles[opts.roleName] = role
|
|
56
|
+
obj.roles = roles
|
|
57
|
+
|
|
58
|
+
const written = writeConfigObject(opts.cwd, obj)
|
|
59
|
+
if (!written.ok) return written
|
|
60
|
+
|
|
61
|
+
// Best-effort commit so a claimed role survives a fresh clone/rebuild — a
|
|
62
|
+
// failing commit leaves the on-disk grant intact (history, not correctness).
|
|
63
|
+
// Subject stays neutral: every grantRole caller hits this, not just claim.
|
|
64
|
+
commitSystemFileSync(opts.cwd, CONFIG_FILE, `${CONFIG_FILE}: grant ${opts.roleName} role`)
|
|
65
|
+
|
|
66
|
+
return written
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Appends `permission` to `typeclaw.json#roles.<name>.permissions`. For a
|
|
70
|
+
// built-in role with no explicit `permissions[]`, the runtime treats the
|
|
71
|
+
// field as "use built-in defaults" (see resolveOne in permissions.ts), so a
|
|
72
|
+
// naive write of `[permission]` would NARROW the role to a single capability,
|
|
73
|
+
// silently dropping its defaults. We materialize the current effective set
|
|
74
|
+
// (explicit field if present, else the built-in default list) and append to
|
|
75
|
+
// THAT, preserving every existing capability. Idempotent: a permission the
|
|
76
|
+
// role already holds is a no-op.
|
|
77
|
+
//
|
|
78
|
+
// NOTE: `roles.permissions` is `restart-required` (FIELD_EFFECTS); the write
|
|
79
|
+
// lands on disk but does not take effect until the next container restart.
|
|
80
|
+
// Callers must surface that to the operator.
|
|
81
|
+
export function grantRolePermission(opts: GrantPermissionOptions): GrantResult {
|
|
82
|
+
const loaded = loadConfigObject(opts.cwd, opts.roleName)
|
|
83
|
+
if (!loaded.ok) return loaded
|
|
84
|
+
|
|
85
|
+
const { obj, roles, role } = loaded
|
|
86
|
+
const effective = effectivePermissions(opts.roleName, role)
|
|
87
|
+
|
|
88
|
+
if (effective.includes(opts.permission)) {
|
|
89
|
+
return { ok: true, added: false }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
role.permissions = [...effective, opts.permission]
|
|
93
|
+
roles[opts.roleName] = role
|
|
94
|
+
obj.roles = roles
|
|
95
|
+
|
|
96
|
+
return writeConfigObject(opts.cwd, obj)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function effectivePermissions(roleName: string, role: Record<string, unknown>): string[] {
|
|
100
|
+
if (Array.isArray(role.permissions)) {
|
|
101
|
+
return role.permissions.filter((p): p is string => typeof p === 'string')
|
|
102
|
+
}
|
|
103
|
+
if (isBuiltinRoleName(roleName)) {
|
|
104
|
+
return [...BUILTIN_ROLES[roleName].permissions]
|
|
105
|
+
}
|
|
106
|
+
return []
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type LoadedConfig = {
|
|
110
|
+
ok: true
|
|
111
|
+
obj: Record<string, unknown>
|
|
112
|
+
roles: Record<string, unknown>
|
|
113
|
+
role: Record<string, unknown>
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function loadConfigObject(cwd: string, roleName: string): LoadedConfig | { ok: false; reason: string } {
|
|
117
|
+
const path = join(cwd, CONFIG_FILE)
|
|
33
118
|
if (!existsSync(path)) {
|
|
34
|
-
return { ok: false, reason: `${CONFIG_FILE} not found at ${
|
|
119
|
+
return { ok: false, reason: `${CONFIG_FILE} not found at ${cwd}` }
|
|
35
120
|
}
|
|
36
121
|
|
|
37
122
|
let raw: string
|
|
@@ -54,26 +139,17 @@ export function grantRole(opts: GrantOptions): GrantResult {
|
|
|
54
139
|
|
|
55
140
|
const obj = json as Record<string, unknown>
|
|
56
141
|
const roles = isPlainObject(obj.roles) ? { ...obj.roles } : {}
|
|
57
|
-
const role = isPlainObject(roles[
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// Dedup by exact string equality — match rules canonicalize during load,
|
|
61
|
-
// and a literal duplicate in the file is a noise source the schema doesn't
|
|
62
|
-
// currently dedupe for us.
|
|
63
|
-
if (existingMatch.includes(opts.matchRule)) {
|
|
64
|
-
return { ok: true, added: false }
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
role.match = [...existingMatch, opts.matchRule]
|
|
68
|
-
roles[opts.roleName] = role
|
|
69
|
-
obj.roles = roles
|
|
142
|
+
const role = isPlainObject(roles[roleName]) ? { ...(roles[roleName] as Record<string, unknown>) } : {}
|
|
143
|
+
return { ok: true, obj, roles, role }
|
|
144
|
+
}
|
|
70
145
|
|
|
146
|
+
function writeConfigObject(cwd: string, obj: Record<string, unknown>): GrantResult {
|
|
147
|
+
const path = join(cwd, CONFIG_FILE)
|
|
71
148
|
try {
|
|
72
149
|
writeAtomic(path, `${JSON.stringify(obj, null, 2)}\n`)
|
|
73
150
|
} catch (error) {
|
|
74
151
|
return { ok: false, reason: `failed to write ${CONFIG_FILE}: ${describeError(error)}` }
|
|
75
152
|
}
|
|
76
|
-
|
|
77
153
|
return { ok: true, added: true }
|
|
78
154
|
}
|
|
79
155
|
|
package/src/permissions/index.ts
CHANGED
|
@@ -24,6 +24,12 @@ export {
|
|
|
24
24
|
type PermissionService,
|
|
25
25
|
type UnknownPermissionWarning,
|
|
26
26
|
} from './permissions'
|
|
27
|
-
export {
|
|
28
|
-
|
|
27
|
+
export {
|
|
28
|
+
grantRole,
|
|
29
|
+
grantRolePermission,
|
|
30
|
+
type GrantOptions,
|
|
31
|
+
type GrantPermissionOptions,
|
|
32
|
+
type GrantResult,
|
|
33
|
+
} from './grant'
|
|
34
|
+
export { matchesOrigin, isDmChannelOrigin, type MatchableOrigin } from './resolve'
|
|
29
35
|
export { MATCH_RULE_JSON_SCHEMA_PATTERN, rolesConfigSchema, type RoleConfig, type RolesConfig } from './schema'
|
|
@@ -141,6 +141,15 @@ export function createPermissionService(opts: CreatePermissionServiceOptions = {
|
|
|
141
141
|
|
|
142
142
|
return {
|
|
143
143
|
has(origin, permission) {
|
|
144
|
+
// Fail-safe floor: an undefined origin holds nothing, regardless of
|
|
145
|
+
// role permissions. Previously this floor was implicit in `guest`
|
|
146
|
+
// being empty; now `guest` is grantable (an operator may grant it
|
|
147
|
+
// `channel.respond`), so the floor must live on the no-actor input
|
|
148
|
+
// instead. Keeps every downstream `has(maybeUndefined, ...)` check
|
|
149
|
+
// (bypass tiers, fs.see.*, subagent spawn) closed even after a guest
|
|
150
|
+
// grant. resolveRole/describe still report 'guest' for audit/display;
|
|
151
|
+
// only authorization is forced closed.
|
|
152
|
+
if (origin === undefined) return false
|
|
144
153
|
const roleName = resolveRole(origin)
|
|
145
154
|
const role = byName.get(roleName)
|
|
146
155
|
if (!role) return false
|
|
@@ -79,3 +79,13 @@ function matchesBucket(
|
|
|
79
79
|
if (platform === 'telegram') return origin.workspace === 'dm' || origin.workspace.startsWith('@')
|
|
80
80
|
return false
|
|
81
81
|
}
|
|
82
|
+
|
|
83
|
+
// True only for a 1:1 direct-message channel origin (a two-participant
|
|
84
|
+
// conversation: the principal + the bot). Reuses the same per-adapter
|
|
85
|
+
// workspace markers as the `dm` match-rule bucket. A DM is the only channel
|
|
86
|
+
// shape with no third-party content buffered into a turn, so role-grant tools
|
|
87
|
+
// treat an owner/trusted DM as injection-equivalent to the TUI; group/open
|
|
88
|
+
// channels never qualify.
|
|
89
|
+
export function isDmChannelOrigin(origin: { adapter: AdapterId; workspace: string }): boolean {
|
|
90
|
+
return matchesBucket('dm', { kind: 'channel', adapter: origin.adapter, workspace: origin.workspace, chat: '' })
|
|
91
|
+
}
|
package/src/role-claim/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export {
|
|
|
10
10
|
type CreateClaimControllerOptions,
|
|
11
11
|
} from './controller'
|
|
12
12
|
export { formatClaimMatchRule, type PartialChannelOrigin } from './match-rule'
|
|
13
|
+
export { reloadAfterClaim, type ReloadAfterClaimOptions, type ReloadAfterClaimResult } from './reload-after-claim'
|
|
13
14
|
export {
|
|
14
15
|
createPendingClaimRegistry,
|
|
15
16
|
type ClaimResult,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { requestReload, type ReloadResult } from '@/reload'
|
|
2
|
+
|
|
3
|
+
export interface ReloadAfterClaimOptions {
|
|
4
|
+
url: string
|
|
5
|
+
reload?: (opts: { url: string; scope: string }) => Promise<ReloadResult[]>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type ReloadAfterClaimResult = { ok: true; results: ReloadResult[] } | { ok: false; reason: string }
|
|
9
|
+
|
|
10
|
+
// Best-effort by contract: the role is already persisted to typeclaw.json by
|
|
11
|
+
// the time a claim completes, so a reload failure here must NOT fail the claim.
|
|
12
|
+
// Callers surface the reason but keep the claim successful.
|
|
13
|
+
export async function reloadAfterClaim(opts: ReloadAfterClaimOptions): Promise<ReloadAfterClaimResult> {
|
|
14
|
+
const reload = opts.reload ?? requestReload
|
|
15
|
+
let results: ReloadResult[]
|
|
16
|
+
try {
|
|
17
|
+
results = await reload({ url: opts.url, scope: 'config' })
|
|
18
|
+
} catch (err) {
|
|
19
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// requestReload resolves even when the server reports a per-scope failure, so
|
|
23
|
+
// an exception-free call is not proof the config actually reloaded. Surface
|
|
24
|
+
// any failed scope (and an empty result, which means nothing reloaded) as a
|
|
25
|
+
// failure so the caller can tell the user to reload manually.
|
|
26
|
+
const failed = results.filter((r) => !r.ok)
|
|
27
|
+
if (failed.length > 0) {
|
|
28
|
+
return { ok: false, reason: failed.map((r) => `${r.scope}: ${r.reason}`).join('; ') }
|
|
29
|
+
}
|
|
30
|
+
if (results.length === 0) {
|
|
31
|
+
return { ok: false, reason: 'no reloadable config scope responded' }
|
|
32
|
+
}
|
|
33
|
+
return { ok: true, results }
|
|
34
|
+
}
|
|
@@ -7,7 +7,7 @@ import type { CreateSessionForSubagent, SubagentRegistry } from '@/agent/subagen
|
|
|
7
7
|
import { capJsonlFileInPlace } from '@/bundled-plugins/tool-result-cap/cap-jsonl'
|
|
8
8
|
import type { CapOptions } from '@/bundled-plugins/tool-result-cap/cap-result'
|
|
9
9
|
import type { CreateSessionForChannel, ChannelRouter } from '@/channels'
|
|
10
|
-
import type { PermissionService } from '@/permissions'
|
|
10
|
+
import type { PermissionService, RolesConfig } from '@/permissions'
|
|
11
11
|
import type { ReloadRegistry } from '@/reload'
|
|
12
12
|
import type { SessionFactory } from '@/sessions'
|
|
13
13
|
import type { Stream } from '@/stream'
|
|
@@ -47,6 +47,10 @@ export type BuildChannelSessionFactoryDeps = {
|
|
|
47
47
|
// the production wiring can plumb in pluginsLoaded.permissions while tests
|
|
48
48
|
// (or stand-alone callers) keep the previous no-annotation behavior.
|
|
49
49
|
permissions?: PermissionService
|
|
50
|
+
// Re-reads roles from disk for the grant_role tool's hot-reload (reload then
|
|
51
|
+
// read, not an in-memory snapshot). Forwarded to createSession; the tool only
|
|
52
|
+
// mounts when permissions is also present.
|
|
53
|
+
reloadRoles?: () => RolesConfig | undefined
|
|
50
54
|
// Test seam: lets a fake stand in for the agent session creator so tests
|
|
51
55
|
// can assert exactly which CreateSessionOptions the factory builds without
|
|
52
56
|
// needing a live LLM, plugin runtime, or session manager on disk.
|
|
@@ -124,6 +128,7 @@ export function buildChannelSessionFactory(deps: BuildChannelSessionFactoryDeps)
|
|
|
124
128
|
...(deps.containerName !== undefined ? { containerName: deps.containerName } : {}),
|
|
125
129
|
...(deps.runtimeVersion !== undefined ? { runtimeVersion: deps.runtimeVersion } : {}),
|
|
126
130
|
...(deps.permissions !== undefined ? { permissions: deps.permissions } : {}),
|
|
131
|
+
...(deps.reloadRoles !== undefined ? { reloadRoles: deps.reloadRoles } : {}),
|
|
127
132
|
...(deps.liveSubagentRegistry !== undefined ? { liveSubagentRegistry: deps.liveSubagentRegistry } : {}),
|
|
128
133
|
...(deps.subagentRegistry !== undefined ? { subagentRegistry: deps.subagentRegistry } : {}),
|
|
129
134
|
...(deps.getCreateSessionForSubagent !== undefined
|
package/src/run/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
type SubagentCompletionBridge,
|
|
25
25
|
} from '@/channels'
|
|
26
26
|
import { createTunnelBridge, type TunnelBridge } from '@/channels/tunnel-bridge'
|
|
27
|
-
import { createConfigReloadable, getConfig, loadConfigSync, loadPluginConfigsSync } from '@/config'
|
|
27
|
+
import { createConfigReloadable, getConfig, loadConfigSync, loadPluginConfigsSync, reloadConfig } from '@/config'
|
|
28
28
|
import {
|
|
29
29
|
type CronConsumer,
|
|
30
30
|
type CronJob,
|
|
@@ -150,6 +150,7 @@ export async function startAgent({
|
|
|
150
150
|
createConfigReloadable({
|
|
151
151
|
cwd,
|
|
152
152
|
permissions: pluginsLoaded.permissions,
|
|
153
|
+
onRolesChanged: () => channelManager.router.tearDownAllLive(),
|
|
153
154
|
skipMountValidation: containerName !== undefined,
|
|
154
155
|
}),
|
|
155
156
|
)
|
|
@@ -244,6 +245,7 @@ export async function startAgent({
|
|
|
244
245
|
getChannelRouter: () => channelManager.router,
|
|
245
246
|
rehydrateCapOptions: resolveCapOptionsFromConfig(pluginConfigsByName['tool-result-cap']),
|
|
246
247
|
permissions: pluginsLoaded.permissions,
|
|
248
|
+
reloadRoles: () => reloadRolesFromDisk(cwd),
|
|
247
249
|
liveSubagentRegistry,
|
|
248
250
|
liveSessionRegistry,
|
|
249
251
|
subagentRegistry: pluginRuntime.get().subagents,
|
|
@@ -688,6 +690,21 @@ async function disposeMaterializedSkills(pluginRuntime: PluginRuntime): Promise<
|
|
|
688
690
|
await Promise.allSettled(all.map((m) => m.dispose()))
|
|
689
691
|
}
|
|
690
692
|
|
|
693
|
+
// grant_role's hot-reload hook: reload the live config FROM DISK (grantRole
|
|
694
|
+
// wrote typeclaw.json directly, bypassing the in-memory snapshot) and return
|
|
695
|
+
// the fresh roles for permissions.replaceRoles. Mirrors the config reloadable's
|
|
696
|
+
// reload-then-read order. Falls back to the current snapshot if the just-written
|
|
697
|
+
// file fails to parse — the on-disk write still stands and the next reload picks
|
|
698
|
+
// it up; replaceRoles with stale roles is no worse than not reloading.
|
|
699
|
+
function reloadRolesFromDisk(cwd: string): ReturnType<typeof getConfig>['roles'] {
|
|
700
|
+
try {
|
|
701
|
+
reloadConfig(cwd)
|
|
702
|
+
} catch {
|
|
703
|
+
// keep the current pointer; see above
|
|
704
|
+
}
|
|
705
|
+
return getConfig().roles
|
|
706
|
+
}
|
|
707
|
+
|
|
691
708
|
async function startScheduler({
|
|
692
709
|
cwd,
|
|
693
710
|
loadCron,
|
package/src/sandbox/build.ts
CHANGED
|
@@ -65,6 +65,38 @@ function buildArgv(command: string, policy: SandboxPolicy): string[] {
|
|
|
65
65
|
|
|
66
66
|
argv.push('--ro-bind', '/usr', '/usr', '--ro-bind', '/etc', '/etc', '--dev', '/dev', '--tmpfs', '/tmp')
|
|
67
67
|
|
|
68
|
+
// Recreate the usr-merge root symlinks that --ro-bind /usr does NOT bring
|
|
69
|
+
// along. On the Debian base (oven/bun:1-slim) /bin /sbin /lib /lib64 are
|
|
70
|
+
// root-level symlinks into /usr; binding /usr exposes /usr/bin etc. but the
|
|
71
|
+
// root entries themselves are absent in the sandbox. That breaks every
|
|
72
|
+
// ABSOLUTE-path reference the kernel resolves WITHOUT consulting PATH:
|
|
73
|
+
// - ELF interpreter (the dynamic loader) baked into PT_INTERP:
|
|
74
|
+
// /lib/ld-linux-aarch64.so.1 (arm64) or /lib64/ld-linux-x86-64.so.2
|
|
75
|
+
// (amd64). Missing it makes bwrap report "execvp bash: No such file or
|
|
76
|
+
// directory" — the missing file is the loader, not bash.
|
|
77
|
+
// - shebang lines: /bin/sh and /bin/bash are the most common interpreters
|
|
78
|
+
// on earth; a script with "#!/bin/sh" fails "cannot execute: required
|
|
79
|
+
// file not found" without /bin, even though /usr/bin/sh exists, because
|
|
80
|
+
// the shebang path is literal and skips PATH.
|
|
81
|
+
// --ro-bind-try, not --ro-bind: the set is arch- and base-dependent (arm64
|
|
82
|
+
// oven/bun:1-slim ships /lib but no /lib64), and a hard bind of an absent
|
|
83
|
+
// source aborts bwrap. -try binds each only when present, keeping this
|
|
84
|
+
// builder pure (no host filesystem probe) and correct across arches/bases.
|
|
85
|
+
argv.push(
|
|
86
|
+
'--ro-bind-try',
|
|
87
|
+
'/bin',
|
|
88
|
+
'/bin',
|
|
89
|
+
'--ro-bind-try',
|
|
90
|
+
'/sbin',
|
|
91
|
+
'/sbin',
|
|
92
|
+
'--ro-bind-try',
|
|
93
|
+
'/lib',
|
|
94
|
+
'/lib',
|
|
95
|
+
'--ro-bind-try',
|
|
96
|
+
'/lib64',
|
|
97
|
+
'/lib64',
|
|
98
|
+
)
|
|
99
|
+
|
|
68
100
|
if ((policy.proc ?? 'tmpfs') === 'tmpfs') {
|
|
69
101
|
// --tmpfs /proc, never --proc /proc (OrbStack's kernel blocks
|
|
70
102
|
// mount("proc",...) from user namespaces) and never --dev-bind /proc /proc
|
|
@@ -63,7 +63,11 @@ Why delegate: the `reviewer` subagent runs on the `deep` model profile, loads a
|
|
|
63
63
|
</review>
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
4. **Translate findings into a `gh api` review payload.** Each `<finding>` with `severity` of `blocker`, `concern`, or `nit` and a `location="path:line"` becomes one entry in `comments[]`. Compose the inline `body` from the reviewer's `<issue>` + `<evidence>` + `<suggestion>` — preserve the reviewer's wording, do not paraphrase. Findings whose `location` is `general` (no file:line anchor) go into the top-level review `body` instead. **Skip `praise` findings when building `comments[]`** — they are not actionable, and inline praise comments are exactly the noise the reviewer is supposed to filter out at the source; if you want to surface them, weave them into the top-level review `body` alongside the summary.
|
|
66
|
+
4. **Translate findings into a `gh api` review payload.** Each `<finding>` with `severity` of `blocker`, `concern`, or `nit` and a `location="path:line"` becomes one entry in `comments[]`. Compose the inline `body` from the reviewer's `<issue>` + `<evidence>` + `<suggestion>` — preserve the reviewer's wording, do not paraphrase. Findings whose `location` is `general` (no file:line anchor) go into the top-level review `body` instead. **Skip `praise` findings when building `comments[]`** — they are not actionable, and inline praise comments are exactly the noise the reviewer is supposed to filter out at the source; if you want to surface them, weave them into the top-level review `body` alongside the summary.
|
|
67
|
+
|
|
68
|
+
**The verdict and the inline comments are independent. The verdict sets only the `event` field; it never decides whether you post `comments[]`.** Whenever there is at least one actionable finding (`blocker`/`concern`/`nit`) with a `location="path:line"`, you MUST submit a formal review via `POST /pulls/<N>/reviews` carrying those findings in `comments[]` — including when the verdict is `approve`. An `approve` with three nits is still a formal `APPROVE` review with three inline comments, **not** a plain approval and **not** a flattened summary posted as a top-level comment. Collapsing inline findings into a single `channel_reply` or issue comment loses the line anchors the reviewer worked to produce — that is the exact failure mode this step exists to prevent.
|
|
69
|
+
|
|
70
|
+
Map the reviewer's `<verdict>` to the GitHub `event`:
|
|
67
71
|
|
|
68
72
|
| Reviewer verdict | GitHub `event` |
|
|
69
73
|
| ----------------- | ----------------- |
|
|
@@ -88,13 +92,21 @@ Why delegate: the `reviewer` subagent runs on the `deep` model profile, loads a
|
|
|
88
92
|
|
|
89
93
|
**Always use `--input -` with a quoted heredoc (`<<'JSON'`) for review bodies.** Do **not** use `-f body=...` or `-F 'comments[][body]=...'`: those go through shell argument parsing, so backticks (\`) trigger command substitution and have to be backslash-escaped, which leaks the literal `\` into the rendered comment. The quoted heredoc passes the JSON through untouched — backticks, newlines, and `${...}` all survive verbatim. The same applies to any other `gh api` POST whose body contains backticks, embedded newlines, or shell metacharacters.
|
|
90
94
|
|
|
91
|
-
5. **
|
|
95
|
+
5. **Verify the review actually landed before announcing it.** The `gh api` call can fail silently from the model's perspective — a permission denial, a bad `line` anchor, or a malformed payload returns an error you must not paper over. After submitting, confirm the review exists:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
gh api /repos/owner/repo/pulls/<N>/reviews --jq '.[-1] | {id, state, user: .user.login}'
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The returned `id`/`state` is your proof the formal review posted. If the call errored or the review is absent, do **not** fall back to a top-level `channel_reply` that _claims_ a review was posted — fix the payload (most often a `line` that isn't part of the diff; re-anchor it or move that finding to the top-level `body`) and resubmit. A trace reply that says "Posted review" when no review exists is worse than silence.
|
|
102
|
+
|
|
103
|
+
6. **End the turn with `skip_response`, not a trace reply.** The formal review from step 4 already landed _in this PR_ — it carries the summary, the verdict, and the inline comments. A `channel_reply` here does **not** go to a separate operator channel; on GitHub it posts another public comment on the same PR. A one-line "Posted review on PR #N: …" narrated into the PR thread is meta-commentary addressed to a phantom operator, and it reads absurdly next to the review it claims to point at. So once step 5 confirms the review exists, call `skip_response({ reason: "review posted via gh api" })` to close the turn silently. Only fall back to `channel_reply` when there was **no** formal review to post — the zero-actionable-findings branches in Rules below already use `channel_reply`/issue comments _as_ the substantive reply.
|
|
92
104
|
|
|
93
105
|
### Rules
|
|
94
106
|
|
|
95
107
|
- **Always delegate to the `reviewer` subagent.** Do not perform the review craft yourself. The reviewer is the source of truth for severity, evidence quality, and what counts as a finding. Your job is mechanics: spawn, wait, translate, post.
|
|
96
108
|
- **Trust the verdict.** Use the GitHub `event` mapped from the reviewer's `<verdict>`. Do not upgrade `comment` → `APPROVE` to seem agreeable, and do not downgrade `request-changes` → `COMMENT` to soften the tone. The reviewer chose deliberately.
|
|
97
|
-
- **No actionable findings → no inline review post.** A finding is "actionable" if its severity is `blocker`, `concern`, or `nit`.
|
|
109
|
+
- **No actionable findings → no inline review post.** A finding is "actionable" if its severity is `blocker`, `concern`, or `nit`. This branch applies **only when the actionable count is exactly zero** — if there is even one actionable finding with a line anchor, follow step 4 and submit a formal review with `comments[]` regardless of verdict. When the reviewer returns zero actionable findings:
|
|
98
110
|
- `approve` verdict → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array).
|
|
99
111
|
- `comment` verdict → post the summary as a top-level PR comment via `gh api -X POST /repos/.../issues/<N>/comments` instead of submitting an empty review.
|
|
100
112
|
- `request-changes` verdict → submit `REQUEST_CHANGES` with the `<summary>` as the review body and no `comments[]` array. This combination is rare (the reviewer's contract says `request-changes` requires at least one blocker or load-bearing concern), so if it happens, faithfully encode the verdict and trust the reviewer's reasoning is in the summary.
|
|
@@ -42,11 +42,19 @@ When the runtime knows your permissions, it prepends a block under your `## Sess
|
|
|
42
42
|
Role: `member`. Permissions: `channel.respond`.
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
This concrete role/permissions block renders for **cron and subagent** sessions, which have a single fixed actor. For TUI sessions the block is omitted because TUI always resolves to `owner` under severity-then-declaration ordering (built-in `owner.match` includes `tui` and is appended-to, never replaced, by user config — and `owner` is walked first). If you don't see the block in a TUI session, treat yourself as `owner`.
|
|
46
46
|
|
|
47
|
-
**
|
|
47
|
+
**Channel sessions are different.** A channel session is keyed by chat/thread, not by author, so it can see many speakers with different roles. It does NOT print one concrete role; instead the block is a policy reminder:
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
```
|
|
50
|
+
## Your role in this session
|
|
51
|
+
|
|
52
|
+
This is a channel conversation that may include multiple speakers...
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
For each user turn, the current speaker's effective role is delivered in the turn context as a `<your-role authority="current-speaker">…</your-role>` tag (omitted for `owner`, the unconstrained default). **That per-turn tag is authoritative for the current message and overrides any role implied by the system prompt.** If the user asks "what role am I right now in this channel", read the `<your-role>` tag on the current turn (or, if absent, treat them as `owner`); do not consult a session-creation role line — channel sessions no longer carry one.
|
|
56
|
+
|
|
57
|
+
**The permission list (cron/subagent block) is exhaustive at session-creation time** for the resolved role. If a permission you expect isn't listed there, the role doesn't carry it — adding it requires editing `roles.<role>.permissions[]` and restarting.
|
|
50
58
|
|
|
51
59
|
## The match-rule DSL
|
|
52
60
|
|
|
@@ -5,7 +5,9 @@ description: Use this skill whenever the user asks you to install, find, list, u
|
|
|
5
5
|
|
|
6
6
|
# typeclaw-skills
|
|
7
7
|
|
|
8
|
-
You operate inside an agent folder. Skills — markdown files with YAML frontmatter — are how this folder teaches you new procedures, conventions, and APIs without changing your code. The runtime discovers them on session start, parses each `SKILL.md`'s frontmatter, and surfaces the `name` + `description` to you so you can decide when to read the body. **You do not import or invoke skills; you
|
|
8
|
+
You operate inside an agent folder. Skills — markdown files with YAML frontmatter — are how this folder teaches you new procedures, conventions, and APIs without changing your code. The runtime discovers them on session start, parses each `SKILL.md`'s frontmatter, and surfaces the `name` + `description` (plus an absolute `<location>` path) to you in the `<available_skills>` section so you can decide when to read the body. **You do not import or invoke skills; you load one by `read`-ing the `SKILL.md` at the exact `<location>` path that section gives you.**
|
|
9
|
+
|
|
10
|
+
**Never construct or guess a skill's path.** Use the `<location>` verbatim. Do not assume a layout like `node_modules/typeclaw/src/skills/<name>/SKILL.md` — bundled skills resolve through the installed package, user skills live under `.agents/skills/`, and muscle-memory skills under `memory/skills/`. If a skill you expect is not listed in `<available_skills>`, it is not a file-based skill and has no `SKILL.md` to read — stop looking on disk. (Subagent-only skills loaded via the `load_skill` tool, e.g. the `reviewer` subagent's `code-review`, are never files.)
|
|
9
11
|
|
|
10
12
|
This skill exists so you (a) understand which skills you can edit and which you must not, (b) can install new skills cleanly when the user asks, and (c) can author your own skills without colliding with the rest of the system.
|
|
11
13
|
|