typeclaw 0.16.0 → 0.18.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/auth.schema.json +0 -5
- package/package.json +2 -2
- package/secrets.schema.json +0 -5
- 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/adapters/discord-bot-classify.ts +23 -0
- package/src/channels/adapters/discord-bot.ts +1 -0
- package/src/channels/adapters/github/auth-app.ts +49 -26
- package/src/channels/adapters/github/auth-pat.ts +3 -3
- package/src/channels/adapters/github/auth.ts +19 -5
- package/src/channels/adapters/github/channel-resolver.ts +3 -2
- package/src/channels/adapters/github/history.ts +3 -2
- package/src/channels/adapters/github/index.ts +85 -43
- package/src/channels/adapters/github/membership.ts +3 -2
- package/src/channels/adapters/github/outbound.ts +6 -2
- package/src/channels/adapters/github/team-membership.ts +4 -2
- package/src/channels/adapters/github/webhook-register.ts +19 -16
- package/src/channels/adapters/slack-bot-slash-commands.ts +76 -1
- package/src/channels/adapters/slack-bot.ts +115 -14
- package/src/channels/router.ts +87 -17
- package/src/cli/channel.ts +0 -12
- package/src/cli/init.ts +0 -9
- package/src/cli/role.ts +10 -1
- package/src/cli/ui.ts +6 -4
- package/src/config/reloadable.ts +10 -3
- package/src/init/github-webhook-install.ts +1 -2
- package/src/init/index.ts +9 -43
- 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 +20 -1
- package/src/sandbox/build.ts +32 -0
- package/src/secrets/schema.ts +0 -1
- package/src/server/command-runner.ts +14 -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
|
@@ -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,
|
|
@@ -369,6 +371,7 @@ export async function startAgent({
|
|
|
369
371
|
runtimeVersion: runtimeVersionOpt.runtimeVersion,
|
|
370
372
|
containerName: containerNameOpt.containerName,
|
|
371
373
|
sessionFactory,
|
|
374
|
+
channelRouter: channelManager.router,
|
|
372
375
|
}),
|
|
373
376
|
subagent: (subName: string, payload?: unknown) =>
|
|
374
377
|
dispatchSpawnSubagent(subName, payload, {
|
|
@@ -583,6 +586,7 @@ export async function startAgent({
|
|
|
583
586
|
containerName,
|
|
584
587
|
outbound,
|
|
585
588
|
sessionFactory,
|
|
589
|
+
channelRouter: channelManager.router,
|
|
586
590
|
})
|
|
587
591
|
|
|
588
592
|
const server = createServer({
|
|
@@ -688,6 +692,21 @@ async function disposeMaterializedSkills(pluginRuntime: PluginRuntime): Promise<
|
|
|
688
692
|
await Promise.allSettled(all.map((m) => m.dispose()))
|
|
689
693
|
}
|
|
690
694
|
|
|
695
|
+
// grant_role's hot-reload hook: reload the live config FROM DISK (grantRole
|
|
696
|
+
// wrote typeclaw.json directly, bypassing the in-memory snapshot) and return
|
|
697
|
+
// the fresh roles for permissions.replaceRoles. Mirrors the config reloadable's
|
|
698
|
+
// reload-then-read order. Falls back to the current snapshot if the just-written
|
|
699
|
+
// file fails to parse — the on-disk write still stands and the next reload picks
|
|
700
|
+
// it up; replaceRoles with stale roles is no worse than not reloading.
|
|
701
|
+
function reloadRolesFromDisk(cwd: string): ReturnType<typeof getConfig>['roles'] {
|
|
702
|
+
try {
|
|
703
|
+
reloadConfig(cwd)
|
|
704
|
+
} catch {
|
|
705
|
+
// keep the current pointer; see above
|
|
706
|
+
}
|
|
707
|
+
return getConfig().roles
|
|
708
|
+
}
|
|
709
|
+
|
|
691
710
|
async function startScheduler({
|
|
692
711
|
cwd,
|
|
693
712
|
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
|
package/src/secrets/schema.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type CreateSessionResult,
|
|
6
6
|
type SessionOrigin,
|
|
7
7
|
} from '@/agent'
|
|
8
|
+
import type { ChannelRouter } from '@/channels/router'
|
|
8
9
|
import type { PermissionService } from '@/permissions'
|
|
9
10
|
import type {
|
|
10
11
|
CommandExecResult,
|
|
@@ -44,6 +45,14 @@ export type CommandRunnerOptions = {
|
|
|
44
45
|
// `SessionManager.inMemory()` and never persist usage — see
|
|
45
46
|
// `runPromptForCommand` below.
|
|
46
47
|
sessionFactory: SessionFactory
|
|
48
|
+
// Channel router threaded into every `ctx.prompt` session so the model can
|
|
49
|
+
// call `channel_send`. Without this, `buildChannelTools` (src/agent/index.ts)
|
|
50
|
+
// receives `undefined` and emits no channel tools — a plugin command or cron
|
|
51
|
+
// handler told to post to a channel then has no tool to do it and falls back
|
|
52
|
+
// to flailing bash loops. The cron `prompt` path already passes
|
|
53
|
+
// `channelManager.router` via `createSessionForCron`; this is the matching
|
|
54
|
+
// wire for the handler/command path.
|
|
55
|
+
channelRouter: ChannelRouter | undefined
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
type CommandHandle = {
|
|
@@ -182,6 +191,7 @@ export function createCommandRunner(opts: CommandRunnerOptions): CommandRunner {
|
|
|
182
191
|
permissions: opts.permissions,
|
|
183
192
|
signal: abortController.signal,
|
|
184
193
|
sessionFactory: opts.sessionFactory,
|
|
194
|
+
channelRouter: opts.channelRouter,
|
|
185
195
|
}),
|
|
186
196
|
subagent: (subName, payload) =>
|
|
187
197
|
opts.spawnSubagent(subName, payload, {
|
|
@@ -363,6 +373,9 @@ export async function runPromptForCommand(args: {
|
|
|
363
373
|
// cron `prompt` path uses in src/run/index.ts. Passing in-memory here
|
|
364
374
|
// regresses `typeclaw usage` (see CommandRunnerOptions.sessionFactory).
|
|
365
375
|
sessionFactory: SessionFactory
|
|
376
|
+
// See CommandRunnerOptions.channelRouter. Threaded to createSessionWithDispose
|
|
377
|
+
// so the spawned session exposes `channel_send`.
|
|
378
|
+
channelRouter?: ChannelRouter
|
|
366
379
|
// Test seam for the agent-session boundary. Production passes the real
|
|
367
380
|
// `createSessionWithDispose`; tests inject a fake to verify wiring
|
|
368
381
|
// (specifically: the sessionManager handed off must be persisted, not
|
|
@@ -388,6 +401,7 @@ export async function runPromptForCommand(args: {
|
|
|
388
401
|
sessionId,
|
|
389
402
|
agentDir: args.agentDir,
|
|
390
403
|
},
|
|
404
|
+
...(args.channelRouter !== undefined ? { channelRouter: args.channelRouter } : {}),
|
|
391
405
|
...(args.runtimeVersion !== undefined ? { runtimeVersion: args.runtimeVersion } : {}),
|
|
392
406
|
...(args.containerName !== undefined ? { containerName: args.containerName } : {}),
|
|
393
407
|
})
|
|
@@ -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
|
|