typeclaw 0.21.0 → 0.22.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 +2 -1
- package/src/agent/index.ts +55 -1
- package/src/agent/loop-guard.ts +180 -53
- package/src/bundled-plugins/bun-hygiene/README.md +82 -0
- package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
- package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
- package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
- package/src/bundled-plugins/memory/memory-logger.ts +6 -2
- package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
- package/src/channels/adapters/discord-bot.ts +2 -0
- package/src/channels/adapters/github/inbound.ts +23 -1
- package/src/channels/adapters/github/index.ts +1 -0
- package/src/channels/adapters/slack-bot.ts +104 -5
- package/src/channels/manager.ts +8 -0
- package/src/channels/router.ts +68 -15
- package/src/channels/schema.ts +18 -0
- package/src/cli/dreams.ts +2 -1
- package/src/cli/inspect.ts +2 -1
- package/src/cli/ui.ts +34 -0
- package/src/commands/index.ts +5 -2
- package/src/config/config.ts +89 -0
- package/src/mcp/catalog.ts +29 -0
- package/src/mcp/client.ts +236 -0
- package/src/mcp/index.ts +25 -0
- package/src/mcp/manager.ts +156 -0
- package/src/mcp/tools.ts +190 -0
- package/src/permissions/builtins.ts +9 -0
- package/src/reload/format.ts +14 -0
- package/src/reload/index.ts +1 -0
- package/src/run/bundled-plugins.ts +7 -0
- package/src/run/channel-session-factory.ts +3 -0
- package/src/run/index.ts +38 -1
- package/src/server/command-runner.ts +5 -0
- package/src/server/index.ts +4 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +83 -13
- package/typeclaw.schema.json +82 -0
|
@@ -19,6 +19,10 @@ export type GithubWebhookHandlerOptions = {
|
|
|
19
19
|
// Defaults to 'pat' when omitted. In 'app' mode classifyReviewRequest also
|
|
20
20
|
// matches the App's decoy reviewer login; see resolveDecoyReviewerLogin.
|
|
21
21
|
authType?: () => 'pat' | 'app'
|
|
22
|
+
// Defaults to true when omitted. When it returns false, every inbound carries
|
|
23
|
+
// an appended operator-policy note telling the agent not to submit an APPROVE
|
|
24
|
+
// review; the github skill keys off that note to downgrade approve→COMMENT.
|
|
25
|
+
allowApprove?: () => boolean
|
|
22
26
|
route: (message: InboundMessage) => void
|
|
23
27
|
logger: GithubInboundLogger
|
|
24
28
|
// Optional: resolves whether the bot is a member of the given team. When
|
|
@@ -75,11 +79,29 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
|
|
|
75
79
|
if (classified === null) return ok()
|
|
76
80
|
|
|
77
81
|
if (delivery !== '') options.dedup.add(delivery)
|
|
78
|
-
options.route(classified)
|
|
82
|
+
options.route(withApprovalPolicy(classified, options.allowApprove?.() ?? true))
|
|
79
83
|
return ok()
|
|
80
84
|
}
|
|
81
85
|
}
|
|
82
86
|
|
|
87
|
+
export const PR_APPROVAL_DISABLED_NOTE =
|
|
88
|
+
'Operator policy: PR approval is disabled for this agent ' +
|
|
89
|
+
'(`channels.github.review.approve: false`). If you review a PR and the ' +
|
|
90
|
+
'verdict is `approve`, submit a `COMMENT` review instead of `APPROVE` — post ' +
|
|
91
|
+
'the findings, but never formally approve.'
|
|
92
|
+
|
|
93
|
+
// Gating PR approval lives here (inbound text), not at the bash layer: the
|
|
94
|
+
// review is posted via `gh api --input <file>`, so the `event: APPROVE` value
|
|
95
|
+
// sits in a temp file the gh-cli-auth command interceptor never inspects. The
|
|
96
|
+
// note rides on every inbound (cheap: one line, only when an operator has
|
|
97
|
+
// opted out) so it reaches the agent for both webhook review requests and
|
|
98
|
+
// plain-language "@bot review this" asks, which arrive on arbitrary inbounds.
|
|
99
|
+
function withApprovalPolicy(message: InboundMessage, allowApprove: boolean): InboundMessage {
|
|
100
|
+
if (allowApprove) return message
|
|
101
|
+
const text = message.text === '' ? PR_APPROVAL_DISABLED_NOTE : `${message.text}\n\n${PR_APPROVAL_DISABLED_NOTE}`
|
|
102
|
+
return { ...message, text }
|
|
103
|
+
}
|
|
104
|
+
|
|
83
105
|
// GitHub auto-records the App as a reviewer the moment its review posts, but
|
|
84
106
|
// leaves the decoy user pinned as a perpetual "review requested". When the bot
|
|
85
107
|
// drops its own review (the self-authored event we're about to discard), fire a
|
|
@@ -149,6 +149,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
149
149
|
selfId: () => selfId,
|
|
150
150
|
selfLogin: () => selfLogin,
|
|
151
151
|
authType: () => options.secrets.auth.type,
|
|
152
|
+
allowApprove: () => options.configRef().review.approve,
|
|
152
153
|
isBotInTeam,
|
|
153
154
|
authToken,
|
|
154
155
|
fetchImpl,
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
} from 'agent-messenger/slackbot'
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
MEMBERSHIP_CACHE_TRANSIENT_TTL_MS,
|
|
10
|
+
MEMBERSHIP_CACHE_TTL_MS,
|
|
9
11
|
MEMBERSHIP_ENUMERATION_CAP,
|
|
10
12
|
type MembershipResolver,
|
|
11
13
|
type MembershipResolverFailure,
|
|
@@ -58,7 +60,7 @@ import { slackTsToMillis } from './slack-bot-time'
|
|
|
58
60
|
// slash_commands events we route vs drop. The ui.test.ts manifest-drift
|
|
59
61
|
// test asserts equality between this set and SLACK_APP_MANIFEST.features.
|
|
60
62
|
// slash_commands so the two can never silently diverge.
|
|
61
|
-
export const SLACK_SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(['help', 'stop'])
|
|
63
|
+
export const SLACK_SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(['help', 'stop', 'reload', 'restart'])
|
|
62
64
|
|
|
63
65
|
// Resolvers fall back to the raw id on failure, so a name equal to the id
|
|
64
66
|
// means resolution failed; we render the bare id rather than `id(id)`. The
|
|
@@ -404,6 +406,16 @@ type SlackUserInfoResponse = {
|
|
|
404
406
|
user?: { is_bot?: boolean; deleted?: boolean }
|
|
405
407
|
}
|
|
406
408
|
|
|
409
|
+
type SlackUsersListResponse = {
|
|
410
|
+
ok: boolean
|
|
411
|
+
error?: string
|
|
412
|
+
members?: Array<{ id?: string; is_bot?: boolean }>
|
|
413
|
+
response_metadata?: { next_cursor?: string }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const USERS_LIST_PAGE_LIMIT = 200
|
|
417
|
+
const USERS_LIST_MAX_PAGES = 50
|
|
418
|
+
|
|
407
419
|
export function createSlackMembershipResolver(deps: {
|
|
408
420
|
token: string
|
|
409
421
|
logger: SlackBotAdapterLogger
|
|
@@ -414,6 +426,43 @@ export function createSlackMembershipResolver(deps: {
|
|
|
414
426
|
const fetchFn = deps.fetchImpl ?? fetch
|
|
415
427
|
const now = deps.now ?? Date.now
|
|
416
428
|
const userBotCache = new Map<string, boolean>()
|
|
429
|
+
|
|
430
|
+
// Keyed by workspace. One resolver instance is bound to a single token/team
|
|
431
|
+
// today, but the router dispatches by adapter (not by adapter+workspace), so
|
|
432
|
+
// scoping the warm set by `key.workspace` keeps a set built for one workspace
|
|
433
|
+
// from ever classifying another's members if a multi-workspace mode is added.
|
|
434
|
+
const botSetCache = new Map<string, { ids: ReadonlySet<string>; fetchedAt: number }>()
|
|
435
|
+
const botSetFailedAt = new Map<string, number>()
|
|
436
|
+
const botSetInFlight = new Map<string, Promise<ReadonlySet<string> | null>>()
|
|
437
|
+
|
|
438
|
+
const warmBotSet = async (workspace: string): Promise<ReadonlySet<string> | null> => {
|
|
439
|
+
const cached = botSetCache.get(workspace)
|
|
440
|
+
if (cached !== undefined && now() - cached.fetchedAt < MEMBERSHIP_CACHE_TTL_MS) return cached.ids
|
|
441
|
+
// Negative-cache a failed warm so a rate-limited workspace doesn't re-run
|
|
442
|
+
// the full paginated `users.list` crawl on every membership read — that
|
|
443
|
+
// would keep the hot path expensive under the exact failure this PR fixes.
|
|
444
|
+
// Members fall back to per-id `users.info` during the cooldown.
|
|
445
|
+
const failedAt = botSetFailedAt.get(workspace)
|
|
446
|
+
if (failedAt !== undefined && now() - failedAt < MEMBERSHIP_CACHE_TRANSIENT_TTL_MS) return null
|
|
447
|
+
const inFlight = botSetInFlight.get(workspace)
|
|
448
|
+
if (inFlight !== undefined) return await inFlight
|
|
449
|
+
const promise = fetchWorkspaceBotIds(fetchFn, deps.token, deps.logger)
|
|
450
|
+
.then((ids) => {
|
|
451
|
+
if (ids !== null) {
|
|
452
|
+
botSetCache.set(workspace, { ids, fetchedAt: now() })
|
|
453
|
+
botSetFailedAt.delete(workspace)
|
|
454
|
+
} else {
|
|
455
|
+
botSetFailedAt.set(workspace, now())
|
|
456
|
+
}
|
|
457
|
+
return ids
|
|
458
|
+
})
|
|
459
|
+
.finally(() => {
|
|
460
|
+
botSetInFlight.delete(workspace)
|
|
461
|
+
})
|
|
462
|
+
botSetInFlight.set(workspace, promise)
|
|
463
|
+
return await promise
|
|
464
|
+
}
|
|
465
|
+
|
|
417
466
|
return async (key): Promise<MembershipResolverResult> => {
|
|
418
467
|
if (key.workspace === '@dm') return { humans: 1, bots: 1, fetchedAt: now(), truncated: false }
|
|
419
468
|
|
|
@@ -466,11 +515,22 @@ export function createSlackMembershipResolver(deps: {
|
|
|
466
515
|
return members.failure
|
|
467
516
|
}
|
|
468
517
|
|
|
518
|
+
// Reached only for channels at or under the cap (larger ones returned
|
|
519
|
+
// `truncated` above). `conversations.members` gives ids with no bot/human
|
|
520
|
+
// flag and Slack has no bulk-classify-ids call, so per-member `users.info`
|
|
521
|
+
// is an N+1 that exceeds the router cold-fetch timeout near the cap; the
|
|
522
|
+
// read then returns null and engagement misreads the busy channel as solo.
|
|
523
|
+
// Classify against a workspace bot-id set from one paginated `users.list`
|
|
524
|
+
// (bots are a small set, shared across channels). `users.info` stays as a
|
|
525
|
+
// per-id fallback for ids minted after the last warm, keeping `bots` and
|
|
526
|
+
// `humanMemberIds` exact for `grant_role`'s "no peer bot present" proof.
|
|
527
|
+
const memberIds = members.value.members ?? []
|
|
528
|
+
const botSet = await warmBotSet(key.workspace)
|
|
469
529
|
let bots = 0
|
|
470
530
|
const humanMemberIds: string[] = []
|
|
471
|
-
for (const userId of
|
|
472
|
-
const
|
|
473
|
-
|
|
531
|
+
for (const userId of memberIds) {
|
|
532
|
+
const isBot =
|
|
533
|
+
botSet?.has(userId) ?? (await resolveSlackUserIsBot(fetchFn, deps.token, userId, deps.logger, userBotCache))
|
|
474
534
|
if (isBot) bots++
|
|
475
535
|
else humanMemberIds.push(userId)
|
|
476
536
|
}
|
|
@@ -512,10 +572,17 @@ async function resolveSlackUserIsBot(
|
|
|
512
572
|
logger: SlackBotAdapterLogger,
|
|
513
573
|
cache: Map<string, boolean>,
|
|
514
574
|
): Promise<boolean> {
|
|
575
|
+
const cached = cache.get(userId)
|
|
576
|
+
if (cached !== undefined) return cached
|
|
515
577
|
const info = await slackApi<SlackUserInfoResponse>(fetchFn, token, 'users.info', { user: userId })
|
|
516
578
|
if (!info.ok) {
|
|
517
579
|
logger.warn(`[slack-bot] membership users.info user=${userId} failed: ${info.reason}`)
|
|
518
|
-
|
|
580
|
+
// Only a definitive answer is cached. A transient failure (429/network)
|
|
581
|
+
// must not be memoized as "human" — that would poison classification until
|
|
582
|
+
// restart and let a peer bot read as human, skewing engagement and
|
|
583
|
+
// `grant_role`'s "no peer bot" proof. Default this read to human (the
|
|
584
|
+
// safe, count-conservative direction) but let the next read retry.
|
|
585
|
+
if (info.failure.kind === 'permanent') cache.set(userId, false)
|
|
519
586
|
return false
|
|
520
587
|
}
|
|
521
588
|
const isBot = info.value.user?.is_bot === true
|
|
@@ -523,6 +590,38 @@ async function resolveSlackUserIsBot(
|
|
|
523
590
|
return isBot
|
|
524
591
|
}
|
|
525
592
|
|
|
593
|
+
// Enumerates the workspace and returns the set of bot user ids. Slack has no
|
|
594
|
+
// server-side `is_bot` filter, so we page the full `users.list` and keep only
|
|
595
|
+
// bots — a complete pass is required so silent lurking bots (never seen in
|
|
596
|
+
// history) are still counted, which `grant_role`'s "no peer bot" proof relies
|
|
597
|
+
// on. Returns null on any failure so the caller can fall back to per-id
|
|
598
|
+
// `users.info` rather than trusting an incomplete set. Page count is bounded so
|
|
599
|
+
// a pathologically large workspace cannot stall the read indefinitely.
|
|
600
|
+
async function fetchWorkspaceBotIds(
|
|
601
|
+
fetchFn: typeof fetch,
|
|
602
|
+
token: string,
|
|
603
|
+
logger: SlackBotAdapterLogger,
|
|
604
|
+
): Promise<ReadonlySet<string> | null> {
|
|
605
|
+
const botIds = new Set<string>()
|
|
606
|
+
let cursor: string | undefined
|
|
607
|
+
for (let page = 0; page < USERS_LIST_MAX_PAGES; page++) {
|
|
608
|
+
const fields: Record<string, string> = { limit: String(USERS_LIST_PAGE_LIMIT) }
|
|
609
|
+
if (cursor !== undefined && cursor !== '') fields.cursor = cursor
|
|
610
|
+
const res = await slackApi<SlackUsersListResponse>(fetchFn, token, 'users.list', fields)
|
|
611
|
+
if (!res.ok) {
|
|
612
|
+
logger.warn(`[slack-bot] users.list failed: ${res.reason}; falling back to per-member classification`)
|
|
613
|
+
return null
|
|
614
|
+
}
|
|
615
|
+
for (const member of res.value.members ?? []) {
|
|
616
|
+
if (member.is_bot === true && typeof member.id === 'string') botIds.add(member.id)
|
|
617
|
+
}
|
|
618
|
+
cursor = res.value.response_metadata?.next_cursor
|
|
619
|
+
if (cursor === undefined || cursor === '') return botIds
|
|
620
|
+
}
|
|
621
|
+
logger.warn(`[slack-bot] users.list exceeded ${USERS_LIST_MAX_PAGES} pages; bot set may be incomplete`)
|
|
622
|
+
return null
|
|
623
|
+
}
|
|
624
|
+
|
|
526
625
|
function slackFailureForError(error: string): MembershipResolverFailure {
|
|
527
626
|
if (['invalid_auth', 'not_authed', 'not_in_channel', 'channel_not_found', 'missing_scope'].includes(error)) {
|
|
528
627
|
return { kind: 'permanent' }
|
package/src/channels/manager.ts
CHANGED
|
@@ -89,6 +89,12 @@ export type ChannelManagerOptions = {
|
|
|
89
89
|
// per-repo App token minter here on start (App auth only) so plugin hooks
|
|
90
90
|
// can resolve a token for ad-hoc `gh` commands. Tests omit it.
|
|
91
91
|
githubTokenBridge?: GithubTokenBridge
|
|
92
|
+
// Forwarded to the router as the /reload and /restart command handlers.
|
|
93
|
+
// Production wiring (src/run/index.ts) supplies the reload-registry and
|
|
94
|
+
// container-restart bindings; tests omit them so the commands stay
|
|
95
|
+
// unregistered. See CreateChannelRouterOptions.onReload/onRestart.
|
|
96
|
+
onReload?: () => Promise<string>
|
|
97
|
+
onRestart?: () => Promise<string>
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
export type ChannelManager = {
|
|
@@ -125,6 +131,8 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
125
131
|
...(options.permissions ? { permissions: options.permissions } : {}),
|
|
126
132
|
...(options.claimHandler ? { claimHandler: options.claimHandler } : {}),
|
|
127
133
|
...(options.stream ? { stream: options.stream } : {}),
|
|
134
|
+
...(options.onReload ? { onReload: options.onReload } : {}),
|
|
135
|
+
...(options.onRestart ? { onRestart: options.onRestart } : {}),
|
|
128
136
|
})
|
|
129
137
|
const createDiscordAdapter = options.createDiscordAdapter ?? createDiscordBotAdapter
|
|
130
138
|
const createGithub = options.createGithubAdapter ?? createGithubAdapter
|
package/src/channels/router.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { createSession, renderTurnRoleAnchor, renderTurnTimeAnchor, type AgentSe
|
|
|
7
7
|
import { subscribeProviderErrors } from '@/agent/provider-error'
|
|
8
8
|
import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
|
|
9
9
|
import { renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
|
|
10
|
-
import { type Command, type CommandResult, createCommandRegistry } from '@/commands'
|
|
10
|
+
import { type Command, type CommandPermission, type CommandResult, createCommandRegistry } from '@/commands'
|
|
11
11
|
import { CORE_PERMISSIONS, type PermissionService } from '@/permissions'
|
|
12
12
|
import type { HookBus } from '@/plugin'
|
|
13
13
|
import { extractClaimCode } from '@/role-claim'
|
|
@@ -720,6 +720,17 @@ export type CreateChannelRouterOptions = {
|
|
|
720
720
|
// can diagnose silent drops from `typeclaw inspect` alone. Omitted in
|
|
721
721
|
// tests that don't care about inspect surfacing.
|
|
722
722
|
stream?: Stream
|
|
723
|
+
// Operate-the-agent command handlers. When set, the router registers the
|
|
724
|
+
// matching channel command (/reload, /restart) gated on session.admin
|
|
725
|
+
// (owner+trusted). Omitted means the command is not registered at all — it
|
|
726
|
+
// won't appear in /help and a text-prefix or native-slash invocation is
|
|
727
|
+
// treated as unknown. Production wiring (src/run/index.ts via the channel
|
|
728
|
+
// manager) supplies both; tests opt in per-case. `onReload` returns a short
|
|
729
|
+
// human-readable summary posted back to the channel; `onRestart` returns a
|
|
730
|
+
// confirmation string (the container exits shortly after, so the reply is
|
|
731
|
+
// best-effort).
|
|
732
|
+
onReload?: () => Promise<string>
|
|
733
|
+
onRestart?: () => Promise<string>
|
|
723
734
|
}
|
|
724
735
|
|
|
725
736
|
export type ClaimHandlerInput = {
|
|
@@ -756,6 +767,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
756
767
|
const permissions = options.permissions ?? GRANT_ALL_PERMISSIONS
|
|
757
768
|
const claimHandler = options.claimHandler
|
|
758
769
|
const stream = options.stream
|
|
770
|
+
const onReload = options.onReload
|
|
771
|
+
const onRestart = options.onRestart
|
|
759
772
|
const liveSessions = new Map<string, LiveSession>()
|
|
760
773
|
const creating = new Map<string, Promise<LiveSession>>()
|
|
761
774
|
// Bumped by tearDownAllLive() and stop() before they tear sessions down. An
|
|
@@ -779,7 +792,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
779
792
|
// The /help handler reads the live registry to enumerate commands, so it
|
|
780
793
|
// forward-references `commands`. Safe at runtime — the handler only runs on
|
|
781
794
|
// invocation, long after the assignment below completes.
|
|
782
|
-
const channelCommands:
|
|
795
|
+
const channelCommands: Command<ChannelCommandContext>[] = [
|
|
783
796
|
{
|
|
784
797
|
name: 'help',
|
|
785
798
|
description: 'List available commands.',
|
|
@@ -800,6 +813,28 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
800
813
|
},
|
|
801
814
|
},
|
|
802
815
|
]
|
|
816
|
+
// /reload and /restart are registered only when the operate-the-agent
|
|
817
|
+
// callbacks are wired (production via the channel manager). Without them the
|
|
818
|
+
// capability doesn't exist for this router, so the commands stay absent from
|
|
819
|
+
// /help and resolve as unknown — never a silent no-op.
|
|
820
|
+
if (onReload !== undefined) {
|
|
821
|
+
channelCommands.push({
|
|
822
|
+
name: 'reload',
|
|
823
|
+
description: 'Reload typeclaw config and subsystems from disk.',
|
|
824
|
+
permission: 'session.admin',
|
|
825
|
+
requiresLiveSession: false,
|
|
826
|
+
handler: async () => ({ reply: await onReload() }),
|
|
827
|
+
})
|
|
828
|
+
}
|
|
829
|
+
if (onRestart !== undefined) {
|
|
830
|
+
channelCommands.push({
|
|
831
|
+
name: 'restart',
|
|
832
|
+
description: 'Restart the typeclaw container.',
|
|
833
|
+
permission: 'session.admin',
|
|
834
|
+
requiresLiveSession: false,
|
|
835
|
+
handler: async () => ({ reply: await onRestart() }),
|
|
836
|
+
})
|
|
837
|
+
}
|
|
803
838
|
const commands = createCommandRegistry<ChannelCommandContext>(channelCommands)
|
|
804
839
|
|
|
805
840
|
// Implicit dir-name alias: agent folder basename matches Docker
|
|
@@ -1800,9 +1835,10 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1800
1835
|
logger.info(`[channels] ${keyId}: ignoring unknown command /${parsedCommand.name}`)
|
|
1801
1836
|
return
|
|
1802
1837
|
}
|
|
1803
|
-
|
|
1838
|
+
const requiredPermission = commandPermissionString(commandInfo.permission)
|
|
1839
|
+
if (requiredPermission !== null && !permissions.has(inboundAuthorOrigin(event), requiredPermission)) {
|
|
1804
1840
|
logger.info(
|
|
1805
|
-
`[channels] ${keyId}: denied command /${parsedCommand.name} by permissions (
|
|
1841
|
+
`[channels] ${keyId}: denied command /${parsedCommand.name} by permissions (${requiredPermission}) author=${event.authorId}`,
|
|
1806
1842
|
)
|
|
1807
1843
|
return
|
|
1808
1844
|
}
|
|
@@ -1913,8 +1949,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1913
1949
|
// operator can grant guest channelRespond for masked stranger turns)
|
|
1914
1950
|
// cannot /stop another speaker's in-flight turn. session.control is
|
|
1915
1951
|
// member-and-up by default.
|
|
1916
|
-
|
|
1917
|
-
|
|
1952
|
+
// Maps a command's declared permission tier to the concrete permission
|
|
1953
|
+
// string gated on both the text-prefix path (route) and the native-slash
|
|
1954
|
+
// path (executeCommand). 'none' is never gated. session.admin (owner+trusted,
|
|
1955
|
+
// not member) covers /reload and /restart, which mutate global agent state
|
|
1956
|
+
// and drop every in-flight session. Centralized so a new tier can't be
|
|
1957
|
+
// honored on one path and silently skipped on the other.
|
|
1958
|
+
const commandPermissionString = (permission: CommandPermission): string | null => {
|
|
1959
|
+
switch (permission) {
|
|
1960
|
+
case 'none':
|
|
1961
|
+
return null
|
|
1962
|
+
case 'session.control':
|
|
1963
|
+
return CORE_PERMISSIONS.sessionControl
|
|
1964
|
+
case 'session.admin':
|
|
1965
|
+
return CORE_PERMISSIONS.sessionAdmin
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1918
1968
|
|
|
1919
1969
|
const updateLoopGuard = (live: LiveSession, event: InboundMessage): void => {
|
|
1920
1970
|
if (!event.authorIsBot) {
|
|
@@ -2702,14 +2752,17 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2702
2752
|
if (commandInfo === undefined) {
|
|
2703
2753
|
return { kind: 'unknown-command', name: lowered }
|
|
2704
2754
|
}
|
|
2705
|
-
// Gates on session.control
|
|
2706
|
-
//
|
|
2707
|
-
//
|
|
2708
|
-
//
|
|
2709
|
-
//
|
|
2710
|
-
//
|
|
2711
|
-
//
|
|
2712
|
-
|
|
2755
|
+
// Gates on the command's declared tier (session.control for /stop,
|
|
2756
|
+
// session.admin for /reload and /restart) — never channel.respond — so a
|
|
2757
|
+
// respond-capable guest cannot abort another speaker's turn or bounce the
|
|
2758
|
+
// container. Runs BEFORE the live-session lookup so an unauthorized invoker
|
|
2759
|
+
// gets 'permission-denied' regardless of session state, rather than leaking
|
|
2760
|
+
// session presence via the 'no-live-session' vs 'permission-denied'
|
|
2761
|
+
// distinction. Session-less informational commands (e.g. /help) declare
|
|
2762
|
+
// permission:'none' and skip both the gate and the lookup so they work in
|
|
2763
|
+
// channels with no live turn.
|
|
2764
|
+
const requiredPermission = commandPermissionString(commandInfo.permission)
|
|
2765
|
+
if (requiredPermission !== null) {
|
|
2713
2766
|
const partial: SessionOrigin = {
|
|
2714
2767
|
kind: 'channel',
|
|
2715
2768
|
adapter: key.adapter,
|
|
@@ -2718,7 +2771,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2718
2771
|
thread: key.thread,
|
|
2719
2772
|
lastInboundAuthorId: options.invokerId,
|
|
2720
2773
|
}
|
|
2721
|
-
if (!permissions.has(partial,
|
|
2774
|
+
if (!permissions.has(partial, requiredPermission)) {
|
|
2722
2775
|
return { kind: 'permission-denied' }
|
|
2723
2776
|
}
|
|
2724
2777
|
}
|
package/src/channels/schema.ts
CHANGED
|
@@ -131,6 +131,23 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
|
|
|
131
131
|
'pull_request_review.submitted',
|
|
132
132
|
] as const
|
|
133
133
|
|
|
134
|
+
// PR-review policy knobs. Grouped under `review` so future toggles
|
|
135
|
+
// (`requestChanges`, auto-review-on-request, severity thresholds) cluster
|
|
136
|
+
// here instead of flattening onto the channel root.
|
|
137
|
+
//
|
|
138
|
+
// `approve` gates whether the agent may submit a formal review with
|
|
139
|
+
// `event: APPROVE`. When `false`, the adapter appends an operator-policy note
|
|
140
|
+
// to inbounds and the `typeclaw-channel-github` skill downgrades an `approve`
|
|
141
|
+
// verdict to a `COMMENT` review (findings still posted, no formal approval).
|
|
142
|
+
// Enforced in the inbound text rather than at the bash layer because the
|
|
143
|
+
// review posts via `gh api --input <file>`, so the `event` value lives in a
|
|
144
|
+
// temp file the command interceptor never sees.
|
|
145
|
+
const githubReviewSchema = z
|
|
146
|
+
.object({
|
|
147
|
+
approve: z.boolean().default(true),
|
|
148
|
+
})
|
|
149
|
+
.default({ approve: true })
|
|
150
|
+
|
|
134
151
|
const githubChannelSchema = adapterSchema.extend({
|
|
135
152
|
// Optional now (PR 2): when omitted and a `tunnels[]` entry with
|
|
136
153
|
// `for: { kind: 'channel', name: 'github' }` exists, the runtime resolves
|
|
@@ -146,6 +163,7 @@ const githubChannelSchema = adapterSchema.extend({
|
|
|
146
163
|
// this session is deleted so a restart with a different webhookUrl (e.g.
|
|
147
164
|
// a tunnel reassigning a URL) doesn't leave orphaned hooks on GitHub.
|
|
148
165
|
repos: z.array(z.string()).default([]),
|
|
166
|
+
review: githubReviewSchema,
|
|
149
167
|
})
|
|
150
168
|
|
|
151
169
|
// KakaoTalk uses the same shape as every other adapter. There used to be an
|
package/src/cli/dreams.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { type DreamEntry, renderListRow, runDreams, type ViewAction } from '@/dr
|
|
|
4
4
|
import { findAgentDir } from '@/init'
|
|
5
5
|
|
|
6
6
|
import { createEscController } from './inspect-controller'
|
|
7
|
-
import { c, cancel, errorLine, isCancel } from './ui'
|
|
7
|
+
import { c, cancel, errorLine, isCancel, prepareStdinForClack } from './ui'
|
|
8
8
|
|
|
9
9
|
const ESC_DEBOUNCE_MS = 50
|
|
10
10
|
const QUIT_KEY = 0x71
|
|
@@ -123,6 +123,7 @@ async function clackSelect(
|
|
|
123
123
|
initialSha: string | undefined,
|
|
124
124
|
): Promise<DreamEntry | null> {
|
|
125
125
|
const { select } = await import('@clack/prompts')
|
|
126
|
+
prepareStdinForClack()
|
|
126
127
|
const preferred = initialSha !== undefined && entries.some((e) => e.sha === initialSha) ? initialSha : entries[0]?.sha
|
|
127
128
|
const picked = await select<string>({
|
|
128
129
|
message: `Pick a dream to open (${entries.length} total)`,
|
package/src/cli/inspect.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { runInspectLoop, streamLive, type LiveSourceFactory, type SessionSummary
|
|
|
6
6
|
import { originLabel, shortSessionId } from '@/inspect/label'
|
|
7
7
|
|
|
8
8
|
import { createEscController } from './inspect-controller'
|
|
9
|
-
import { cancel, c, errorLine, isCancel } from './ui'
|
|
9
|
+
import { cancel, c, errorLine, isCancel, prepareStdinForClack } from './ui'
|
|
10
10
|
|
|
11
11
|
const ESC_LISTEN_DELAY_MS = 50
|
|
12
12
|
|
|
@@ -212,6 +212,7 @@ async function clackSelect(
|
|
|
212
212
|
initialSessionId: string | undefined,
|
|
213
213
|
): Promise<SessionSummary | null> {
|
|
214
214
|
const { select } = await import('@clack/prompts')
|
|
215
|
+
prepareStdinForClack()
|
|
215
216
|
const preferred =
|
|
216
217
|
initialSessionId !== undefined && sessions.some((s) => s.sessionId === initialSessionId)
|
|
217
218
|
? initialSessionId
|
package/src/cli/ui.ts
CHANGED
|
@@ -7,6 +7,28 @@ import { type AutoUpgradeOutcome, describeAutoUpgrade } from '@/init/auto-upgrad
|
|
|
7
7
|
|
|
8
8
|
export { cancel, intro, isCancel, log, note, outro }
|
|
9
9
|
|
|
10
|
+
type ClackInput = Pick<NodeJS.ReadStream, 'isTTY' | 'setRawMode' | 'resume'>
|
|
11
|
+
|
|
12
|
+
// Hand stdin to a clack picker in a state it can own. Over an SSH pseudo-TTY,
|
|
13
|
+
// Bun's readline keypress wiring only transitions stdin into flowing raw mode
|
|
14
|
+
// reliably once the stream has already been resumed; on a never-resumed stdin
|
|
15
|
+
// the picker renders but arrow keys echo as raw `^[[B` and never advance it.
|
|
16
|
+
// Local terminals dodge this because stdin was already flowing. So before every
|
|
17
|
+
// picker: clear any stale raw mode for a clean baseline, then resume the stream.
|
|
18
|
+
// Never pause() here — a previously-paused process.stdin does not reliably
|
|
19
|
+
// re-flow under Bun, which is the same failure this resume() is fixing.
|
|
20
|
+
export function prepareStdinForClack(input: ClackInput = process.stdin): void {
|
|
21
|
+
if (!input.isTTY) return
|
|
22
|
+
if (typeof input.setRawMode === 'function') {
|
|
23
|
+
try {
|
|
24
|
+
input.setRawMode(false)
|
|
25
|
+
} catch {
|
|
26
|
+
/* terminal already torn down */
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
input.resume()
|
|
30
|
+
}
|
|
31
|
+
|
|
10
32
|
function colorize(modifier: Parameters<typeof styleText>[0], s: string): string {
|
|
11
33
|
if (!colorsEnabled()) return s
|
|
12
34
|
return styleText(modifier, s)
|
|
@@ -169,6 +191,18 @@ export const SLACK_APP_MANIFEST = {
|
|
|
169
191
|
url: 'https://example.invalid/typeclaw-uses-socket-mode',
|
|
170
192
|
should_escape: false,
|
|
171
193
|
},
|
|
194
|
+
{
|
|
195
|
+
command: '/reload',
|
|
196
|
+
description: 'Reload typeclaw config and subsystems from disk',
|
|
197
|
+
url: 'https://example.invalid/typeclaw-uses-socket-mode',
|
|
198
|
+
should_escape: false,
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
command: '/restart',
|
|
202
|
+
description: 'Restart the typeclaw container',
|
|
203
|
+
url: 'https://example.invalid/typeclaw-uses-socket-mode',
|
|
204
|
+
should_escape: false,
|
|
205
|
+
},
|
|
172
206
|
],
|
|
173
207
|
},
|
|
174
208
|
oauth_config: {
|
package/src/commands/index.ts
CHANGED
|
@@ -13,8 +13,11 @@ export type CommandHandler<Context> = (
|
|
|
13
13
|
// dispatcher, so a new command declares its own requirements in one place:
|
|
14
14
|
// 'session.control' + requiresLiveSession:true is the control-command default
|
|
15
15
|
// (/stop); 'none' + requiresLiveSession:false is the informational default
|
|
16
|
-
// (/help).
|
|
17
|
-
|
|
16
|
+
// (/help). 'session.admin' + requiresLiveSession:false is the operate-the-agent
|
|
17
|
+
// tier (/reload, /restart) — owner+trusted only, no live session required since
|
|
18
|
+
// it acts on the container, not a channel turn. Both are optional so plain
|
|
19
|
+
// registries (tests, TUI) need not care.
|
|
20
|
+
export type CommandPermission = 'none' | 'session.control' | 'session.admin'
|
|
18
21
|
|
|
19
22
|
export type Command<Context> = {
|
|
20
23
|
name: string
|
package/src/config/config.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { z } from 'zod'
|
|
|
8
8
|
import { channelsSchema } from '@/channels/schema'
|
|
9
9
|
import { commitSystemFileSync } from '@/git/system-commit'
|
|
10
10
|
import { rolesConfigSchema } from '@/permissions/schema'
|
|
11
|
+
import { secretFieldSchema } from '@/secrets/resolve'
|
|
11
12
|
|
|
12
13
|
import {
|
|
13
14
|
DEFAULT_MODEL_REF,
|
|
@@ -30,6 +31,30 @@ const DEFAULT_PORT = 8973
|
|
|
30
31
|
// of files like `mounts/.git` or `mounts/Hello`.
|
|
31
32
|
const MOUNT_NAME_PATTERN = /^[a-z0-9][a-z0-9-_]*$/
|
|
32
33
|
|
|
34
|
+
// Shell-portable env var identifier: a leading letter or underscore followed by
|
|
35
|
+
// letters, digits, or underscores. MCP `env` keys are passed verbatim to a child
|
|
36
|
+
// process environment, so an invalid identifier (spaces, `=`, leading digit)
|
|
37
|
+
// would be silently dropped or corrupt the spawned server's env.
|
|
38
|
+
const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
39
|
+
|
|
40
|
+
// Upper bound for a per-server MCP request timeout: 10 minutes. Long-running
|
|
41
|
+
// MCP tools (large crawls, builds) can legitimately take minutes, but a ceiling
|
|
42
|
+
// guards against fat-finger values that would re-introduce the unbounded-hang
|
|
43
|
+
// failure mode the explicit timeouts exist to prevent.
|
|
44
|
+
const MCP_MAX_TIMEOUT_MS = 600_000
|
|
45
|
+
|
|
46
|
+
// URL schemes are case-insensitive (RFC 3986), and the WHATWG parser normalizes
|
|
47
|
+
// `.protocol` to lowercase. Checking the parsed protocol instead of a raw
|
|
48
|
+
// `startsWith` keeps `HTTPS://…` valid, which `z.string().url()` already accepts.
|
|
49
|
+
function isHttpProtocol(value: string): boolean {
|
|
50
|
+
try {
|
|
51
|
+
const protocol = new URL(value).protocol
|
|
52
|
+
return protocol === 'http:' || protocol === 'https:'
|
|
53
|
+
} catch {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
33
58
|
export const mountSchema = z.object({
|
|
34
59
|
name: z.string().regex(MOUNT_NAME_PATTERN, 'mount name must be lowercase alphanumeric with - or _'),
|
|
35
60
|
path: z.string().min(1),
|
|
@@ -39,6 +64,66 @@ export const mountSchema = z.object({
|
|
|
39
64
|
|
|
40
65
|
export type Mount = z.infer<typeof mountSchema>
|
|
41
66
|
|
|
67
|
+
// MCP servers are keyed by the same shell/disk-safe namespace as mounts because
|
|
68
|
+
// the name becomes the tool namespace exposed to the agent. The transport is an
|
|
69
|
+
// XOR on purpose: stdio servers are child processes (`command` + `args` + env),
|
|
70
|
+
// while Streamable HTTP servers are remote endpoints (`url`); accepting both
|
|
71
|
+
// would make ownership, lifetime, and credential injection ambiguous at boot.
|
|
72
|
+
export const mcpServerSchema = z
|
|
73
|
+
.object({
|
|
74
|
+
name: z
|
|
75
|
+
.string()
|
|
76
|
+
.regex(MOUNT_NAME_PATTERN, 'MCP server name must be lowercase alphanumeric with - or _')
|
|
77
|
+
.refine((name) => !name.includes('__'), {
|
|
78
|
+
message: "MCP server name must not contain '__' (reserved as the tool-namespace separator)",
|
|
79
|
+
}),
|
|
80
|
+
description: z.string().optional(),
|
|
81
|
+
// Default true so omitting the field keeps the server on; set false to keep config but skip connecting.
|
|
82
|
+
enabled: z.boolean().default(true),
|
|
83
|
+
timeoutMs: z.number().int().positive().max(MCP_MAX_TIMEOUT_MS).optional(),
|
|
84
|
+
command: z.string().trim().min(1).optional(),
|
|
85
|
+
args: z.array(z.string()).default([]),
|
|
86
|
+
url: z
|
|
87
|
+
.string()
|
|
88
|
+
.url()
|
|
89
|
+
.refine((u) => isHttpProtocol(u), {
|
|
90
|
+
message: 'MCP server url must use http:// or https://',
|
|
91
|
+
})
|
|
92
|
+
.optional(),
|
|
93
|
+
env: z
|
|
94
|
+
.record(z.string().regex(ENV_NAME_PATTERN, 'env var name must be a valid identifier'), secretFieldSchema)
|
|
95
|
+
.default({}),
|
|
96
|
+
})
|
|
97
|
+
.refine((server) => (server.command !== undefined) !== (server.url !== undefined), {
|
|
98
|
+
message: 'MCP server must be either stdio (command) or http (url), not both or neither',
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
export type McpServer = z.infer<typeof mcpServerSchema>
|
|
102
|
+
|
|
103
|
+
// The name becomes the `<server>__<tool>` namespace at dispatch, so duplicates
|
|
104
|
+
// would make tool lookup ambiguous and silently shadow one server behind
|
|
105
|
+
// another. Reject them with an indexed path so the error points at the
|
|
106
|
+
// offending entry instead of the whole array.
|
|
107
|
+
const mcpServersArraySchema = z
|
|
108
|
+
.array(mcpServerSchema)
|
|
109
|
+
.default([])
|
|
110
|
+
.superRefine((entries, ctx) => {
|
|
111
|
+
const seen = new Map<string, number>()
|
|
112
|
+
for (let i = 0; i < entries.length; i++) {
|
|
113
|
+
const name = entries[i]!.name
|
|
114
|
+
const prev = seen.get(name)
|
|
115
|
+
if (prev !== undefined) {
|
|
116
|
+
ctx.addIssue({
|
|
117
|
+
code: 'custom',
|
|
118
|
+
path: [i, 'name'],
|
|
119
|
+
message: `mcpServers[${i}].name duplicates mcpServers[${prev}].name ('${name}')`,
|
|
120
|
+
})
|
|
121
|
+
} else {
|
|
122
|
+
seen.set(name, i)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
42
127
|
const portNumber = z.number().int().min(1).max(65535)
|
|
43
128
|
|
|
44
129
|
// `allow` is the discriminator between "forward everything" ('*') and a fixed
|
|
@@ -391,6 +476,7 @@ export const configSchema = z
|
|
|
391
476
|
// host paths exposed) without failing the whole config load. `typeclaw
|
|
392
477
|
// init` omits this field so users don't see noise for the empty case.
|
|
393
478
|
mounts: z.array(mountSchema).default([]),
|
|
479
|
+
mcpServers: mcpServersArraySchema,
|
|
394
480
|
plugins: z.array(z.string().min(1)).default([]),
|
|
395
481
|
// Additional names the agent answers to in channel engagement, on top
|
|
396
482
|
// of `basename(agentDir)` which is always implicit. Each entry is a
|
|
@@ -538,6 +624,7 @@ export const FIELD_EFFECTS: Record<string, FieldEffect> = {
|
|
|
538
624
|
models: 'applied',
|
|
539
625
|
port: 'restart-required',
|
|
540
626
|
mounts: 'restart-required',
|
|
627
|
+
mcpServers: 'restart-required',
|
|
541
628
|
plugins: 'restart-required',
|
|
542
629
|
alias: 'applied',
|
|
543
630
|
channels: 'applied',
|
|
@@ -638,6 +725,8 @@ export function extractPluginConfigs(raw: unknown): Record<string, unknown> {
|
|
|
638
725
|
'git',
|
|
639
726
|
'roles',
|
|
640
727
|
'permissions',
|
|
728
|
+
'tunnels',
|
|
729
|
+
'mcpServers',
|
|
641
730
|
])
|
|
642
731
|
const result: Record<string, unknown> = {}
|
|
643
732
|
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|