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
|
@@ -35,12 +35,11 @@ import {
|
|
|
35
35
|
import { createSlackDedupe } from './slack-bot-dedupe'
|
|
36
36
|
import {
|
|
37
37
|
buildSlashAckPayload,
|
|
38
|
+
commandResultReply,
|
|
38
39
|
parseSlashCommand,
|
|
39
|
-
|
|
40
|
-
SLACK_SLASH_REPLY_AMBIGUOUS,
|
|
40
|
+
parseThreadCommand,
|
|
41
41
|
SLACK_SLASH_REPLY_FAILED,
|
|
42
|
-
|
|
43
|
-
SLACK_SLASH_REPLY_PERMISSION_DENIED,
|
|
42
|
+
type ThreadCommandInput,
|
|
44
43
|
} from './slack-bot-slash-commands'
|
|
45
44
|
import { slackTsToMillis } from './slack-bot-time'
|
|
46
45
|
|
|
@@ -124,16 +123,7 @@ export function createSlashCommandHandler(
|
|
|
124
123
|
return
|
|
125
124
|
}
|
|
126
125
|
|
|
127
|
-
const replyContent =
|
|
128
|
-
result.kind === 'handled'
|
|
129
|
-
? SLACK_SLASH_REPLY_ABORTED
|
|
130
|
-
: result.kind === 'no-live-session'
|
|
131
|
-
? SLACK_SLASH_REPLY_NO_LIVE_SESSION
|
|
132
|
-
: result.kind === 'permission-denied'
|
|
133
|
-
? SLACK_SLASH_REPLY_PERMISSION_DENIED
|
|
134
|
-
: result.kind === 'ambiguous'
|
|
135
|
-
? SLACK_SLASH_REPLY_AMBIGUOUS
|
|
136
|
-
: SLACK_SLASH_REPLY_FAILED
|
|
126
|
+
const replyContent = commandResultReply(result)
|
|
137
127
|
|
|
138
128
|
// Final ack on the happy path: own try/catch so a thrown ack here does
|
|
139
129
|
// NOT cascade into the error-path ack above (which would violate the
|
|
@@ -158,6 +148,67 @@ export function createSlashCommandHandler(
|
|
|
158
148
|
}
|
|
159
149
|
}
|
|
160
150
|
|
|
151
|
+
export type ThreadCommandReplyPoster = (args: { chat: string; thread: string | null; text: string }) => Promise<void>
|
|
152
|
+
|
|
153
|
+
export type ThreadCommandHandlerDeps = {
|
|
154
|
+
router: Pick<ChannelRouter, 'executeCommand'>
|
|
155
|
+
knownCommandNames: ReadonlySet<string>
|
|
156
|
+
postReply: ThreadCommandReplyPoster
|
|
157
|
+
logger: SlackBotAdapterLoggerLike
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type ThreadCommandOutcome = { kind: 'not-a-command' } | { kind: 'duplicate' } | { kind: 'executed' }
|
|
161
|
+
|
|
162
|
+
// Synchronous reservation: the adapter marks the dedupe ring inside this hook,
|
|
163
|
+
// which the handler calls before its first `await`. Two duplicate Slack
|
|
164
|
+
// deliveries can both clear `dedupe.check()` on the same JS tick; whichever
|
|
165
|
+
// reserves first wins and returns `true`, the loser returns `false` and aborts
|
|
166
|
+
// — so a control command never runs twice across the check→execute window.
|
|
167
|
+
export type ThreadCommandReserve = () => boolean
|
|
168
|
+
|
|
169
|
+
// Routes a `!cmd` thread message through the SAME router.executeCommand path as
|
|
170
|
+
// native slashes, then posts the outcome back into the thread. Returns
|
|
171
|
+
// 'not-a-command' (caller proceeds with normal classify/route), 'duplicate'
|
|
172
|
+
// (a racing delivery already reserved this event — caller stops silently), or
|
|
173
|
+
// 'executed' (command handled — caller stops; it is not agent input).
|
|
174
|
+
export function createThreadCommandHandler(
|
|
175
|
+
deps: ThreadCommandHandlerDeps,
|
|
176
|
+
): (input: ThreadCommandInput, reserve: ThreadCommandReserve) => Promise<ThreadCommandOutcome> {
|
|
177
|
+
return async (input, reserve) => {
|
|
178
|
+
const parsed = parseThreadCommand(input, deps.knownCommandNames)
|
|
179
|
+
if (parsed.kind === 'ignore') {
|
|
180
|
+
return { kind: 'not-a-command' }
|
|
181
|
+
}
|
|
182
|
+
// Reserve synchronously, before any await, to close the check→execute race.
|
|
183
|
+
if (!reserve()) {
|
|
184
|
+
return { kind: 'duplicate' }
|
|
185
|
+
}
|
|
186
|
+
const { command } = parsed
|
|
187
|
+
deps.logger.info(
|
|
188
|
+
`[slack-bot] thread-command !${command.name} invoker=${command.invokerId} team=${command.key.workspace} channel=${command.key.chat} thread=${command.key.thread ?? '(none)'}`,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
let reply: string
|
|
192
|
+
try {
|
|
193
|
+
const result = await deps.router.executeCommand(command.key, command.name, {
|
|
194
|
+
invokerId: command.invokerId,
|
|
195
|
+
})
|
|
196
|
+
reply = commandResultReply(result)
|
|
197
|
+
deps.logger.info(`[slack-bot] thread-command !${command.name} result=${result.kind}`)
|
|
198
|
+
} catch (err) {
|
|
199
|
+
deps.logger.error(`[slack-bot] thread-command !${command.name} failed: ${describe(err)}`)
|
|
200
|
+
reply = SLACK_SLASH_REPLY_FAILED
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
await deps.postReply({ chat: input.channel, thread: input.threadTs, text: reply })
|
|
205
|
+
} catch (err) {
|
|
206
|
+
deps.logger.warn(`[slack-bot] thread-command reply post failed: ${describe(err)}`)
|
|
207
|
+
}
|
|
208
|
+
return { kind: 'executed' }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
161
212
|
// app_mention payloads omit channel_type and never carry a subtype, so we
|
|
162
213
|
// promote them to a message-shaped event for the shared classifier. The
|
|
163
214
|
// promoted event is classified as a regular channel message; the
|
|
@@ -783,6 +834,24 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
783
834
|
formatChannelTag,
|
|
784
835
|
})
|
|
785
836
|
|
|
837
|
+
const handleThreadCommand = createThreadCommandHandler({
|
|
838
|
+
router: options.router,
|
|
839
|
+
knownCommandNames: SLACK_SLASH_COMMAND_NAMES,
|
|
840
|
+
logger,
|
|
841
|
+
postReply: async ({ chat, thread, text }) => {
|
|
842
|
+
const result = await outboundCallback({
|
|
843
|
+
adapter: 'slack-bot',
|
|
844
|
+
workspace: teamId ?? 'unknown',
|
|
845
|
+
chat,
|
|
846
|
+
...(thread !== null ? { thread } : {}),
|
|
847
|
+
text,
|
|
848
|
+
})
|
|
849
|
+
if (!result.ok) {
|
|
850
|
+
throw new Error(result.error)
|
|
851
|
+
}
|
|
852
|
+
},
|
|
853
|
+
})
|
|
854
|
+
|
|
786
855
|
const handleMessageEvent = async (
|
|
787
856
|
event: SlackInboundMessageEvent,
|
|
788
857
|
source: 'message' | 'app_mention',
|
|
@@ -813,6 +882,38 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
813
882
|
return
|
|
814
883
|
}
|
|
815
884
|
|
|
885
|
+
// Intercept `!cmd` thread-message commands BEFORE classifyInbound. A
|
|
886
|
+
// command is control traffic — neither dropped nor routed to the agent —
|
|
887
|
+
// so it must short-circuit here. Bypassing classifyInbound also bypasses
|
|
888
|
+
// its self_author / no_user drops, so we replicate those guards: never
|
|
889
|
+
// execute a command from our own message (echo loop) or a userless
|
|
890
|
+
// system event. The `reserve` closure marks dedupe synchronously the
|
|
891
|
+
// instant the command is recognised (before the router await), closing
|
|
892
|
+
// the check→execute race for duplicate deliveries.
|
|
893
|
+
if (event.user !== undefined && event.user !== '' && (botUserId === null || event.user !== botUserId)) {
|
|
894
|
+
const reserve = (): boolean => {
|
|
895
|
+
if (dedupe.check(event) !== null) return false
|
|
896
|
+
dedupe.mark(event)
|
|
897
|
+
return true
|
|
898
|
+
}
|
|
899
|
+
const outcome = await handleThreadCommand(
|
|
900
|
+
{
|
|
901
|
+
text: event.text ?? '',
|
|
902
|
+
channel: event.channel,
|
|
903
|
+
threadTs: event.thread_ts ?? null,
|
|
904
|
+
isDm: event.channel_type === 'im',
|
|
905
|
+
teamId,
|
|
906
|
+
invokerId: event.user,
|
|
907
|
+
},
|
|
908
|
+
reserve,
|
|
909
|
+
)
|
|
910
|
+
if (outcome.kind === 'executed') return
|
|
911
|
+
if (outcome.kind === 'duplicate') {
|
|
912
|
+
logger.info(`[slack-bot] dropped ts=${event.ts} reason=duplicate_delivery (thread-command race)`)
|
|
913
|
+
return
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
816
917
|
const verdict = classifyInbound(event, options.configRef(), {
|
|
817
918
|
teamId,
|
|
818
919
|
botUserId,
|
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/channel.ts
CHANGED
|
@@ -842,7 +842,6 @@ async function promptGithubAppAuth(): Promise<{
|
|
|
842
842
|
type: 'app'
|
|
843
843
|
appId: number
|
|
844
844
|
privateKey: string
|
|
845
|
-
installationId?: number
|
|
846
845
|
}> {
|
|
847
846
|
const appId = await text({
|
|
848
847
|
message: 'GitHub App ID',
|
|
@@ -857,21 +856,10 @@ async function promptGithubAppAuth(): Promise<{
|
|
|
857
856
|
cancel('Aborted.')
|
|
858
857
|
process.exit(0)
|
|
859
858
|
}
|
|
860
|
-
const installationId = await text({
|
|
861
|
-
message: 'Installation ID (optional; leave blank to auto-discover)',
|
|
862
|
-
validate: (value) =>
|
|
863
|
-
value === undefined || value === '' ? undefined : validatePositiveInteger(value, 'Installation ID is required'),
|
|
864
|
-
})
|
|
865
|
-
if (isCancel(installationId)) {
|
|
866
|
-
cancel('Aborted.')
|
|
867
|
-
process.exit(0)
|
|
868
|
-
}
|
|
869
|
-
const parsedInstallationId = installationId === '' ? undefined : Number(installationId)
|
|
870
859
|
return {
|
|
871
860
|
type: 'app',
|
|
872
861
|
appId: Number(appId),
|
|
873
862
|
privateKey,
|
|
874
|
-
...(parsedInstallationId !== undefined ? { installationId: parsedInstallationId } : {}),
|
|
875
863
|
}
|
|
876
864
|
}
|
|
877
865
|
|
package/src/cli/init.ts
CHANGED
|
@@ -1293,7 +1293,6 @@ async function promptGithubAppAuth(): Promise<{
|
|
|
1293
1293
|
type: 'app'
|
|
1294
1294
|
appId: number
|
|
1295
1295
|
privateKey: string
|
|
1296
|
-
installationId?: number
|
|
1297
1296
|
} | null> {
|
|
1298
1297
|
const appId = await text({
|
|
1299
1298
|
message: 'GitHub App ID',
|
|
@@ -1302,18 +1301,10 @@ async function promptGithubAppAuth(): Promise<{
|
|
|
1302
1301
|
if (isCancel(appId)) return null
|
|
1303
1302
|
const privateKey = await promptPrivateKeyPem('GitHub App private key PEM, escaped PEM, or path to .pem file')
|
|
1304
1303
|
if (privateKey === CANCEL_SYMBOL) return null
|
|
1305
|
-
const installationId = await text({
|
|
1306
|
-
message: 'Installation ID (optional; leave blank to auto-discover)',
|
|
1307
|
-
validate: (v) =>
|
|
1308
|
-
v === undefined || v === '' ? undefined : validatePositiveInteger(v, 'Installation ID is required'),
|
|
1309
|
-
})
|
|
1310
|
-
if (isCancel(installationId)) return null
|
|
1311
|
-
const parsedInstallationId = installationId === '' ? undefined : Number(installationId)
|
|
1312
1304
|
return {
|
|
1313
1305
|
type: 'app',
|
|
1314
1306
|
appId: Number(appId),
|
|
1315
1307
|
privateKey,
|
|
1316
|
-
...(parsedInstallationId !== undefined ? { installationId: parsedInstallationId } : {}),
|
|
1317
1308
|
}
|
|
1318
1309
|
}
|
|
1319
1310
|
|
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 {
|
|
@@ -57,7 +57,7 @@ export async function installGithubWebhooksEagerly(
|
|
|
57
57
|
|
|
58
58
|
try {
|
|
59
59
|
const result = await registerGithubWebhooks({
|
|
60
|
-
token: () => strategy.token(),
|
|
60
|
+
token: (repoSlug: string) => strategy.token({ repoSlug }),
|
|
61
61
|
webhookUrl,
|
|
62
62
|
webhookSecret: options.webhookSecret,
|
|
63
63
|
repos: options.repos,
|
|
@@ -86,7 +86,6 @@ function authToSecretBlock(auth: GithubInitCredentials['auth']) {
|
|
|
86
86
|
type: 'app' as const,
|
|
87
87
|
appId: auth.appId,
|
|
88
88
|
privateKey: { value: auth.privateKey },
|
|
89
|
-
...(auth.installationId !== undefined ? { installationId: auth.installationId } : {}),
|
|
90
89
|
}
|
|
91
90
|
}
|
|
92
91
|
|
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).
|
|
@@ -102,7 +94,7 @@ export type GithubInitCredentials = {
|
|
|
102
94
|
hostname?: string
|
|
103
95
|
tokenEnv?: string
|
|
104
96
|
repos: string[]
|
|
105
|
-
auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string
|
|
97
|
+
auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string }
|
|
106
98
|
}
|
|
107
99
|
|
|
108
100
|
export type GithubTunnelProvider = 'cloudflare-quick' | 'cloudflare-named' | 'external' | 'none'
|
|
@@ -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 = {
|
|
@@ -1006,7 +998,7 @@ export type AddChannelOptions = {
|
|
|
1006
998
|
hostname?: string
|
|
1007
999
|
tokenEnv?: string
|
|
1008
1000
|
repos: string[]
|
|
1009
|
-
auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string
|
|
1001
|
+
auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string }
|
|
1010
1002
|
fetchImpl?: typeof fetch
|
|
1011
1003
|
}
|
|
1012
1004
|
)
|
|
@@ -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
|
|
@@ -1273,9 +1263,6 @@ async function writeGithubChannelForInit(cwd: string, credentials: GithubInitCre
|
|
|
1273
1263
|
type: 'app',
|
|
1274
1264
|
appId: credentials.auth.appId,
|
|
1275
1265
|
privateKey: { value: credentials.auth.privateKey } satisfies Secret,
|
|
1276
|
-
...(credentials.auth.installationId !== undefined
|
|
1277
|
-
? { installationId: credentials.auth.installationId }
|
|
1278
|
-
: {}),
|
|
1279
1266
|
},
|
|
1280
1267
|
webhookSecret: { value: credentials.webhookSecret } satisfies Secret,
|
|
1281
1268
|
}
|
|
@@ -1308,7 +1295,6 @@ async function appendGithubSecrets(
|
|
|
1308
1295
|
type: 'app',
|
|
1309
1296
|
appId: options.auth.appId,
|
|
1310
1297
|
privateKey: { value: options.auth.privateKey } satisfies Secret,
|
|
1311
|
-
...(options.auth.installationId !== undefined ? { installationId: options.auth.installationId } : {}),
|
|
1312
1298
|
},
|
|
1313
1299
|
webhookSecret: { value: options.webhookSecret } satisfies Secret,
|
|
1314
1300
|
}
|
|
@@ -1329,24 +1315,6 @@ async function appendGithubMatchRules(cwd: string, repos: readonly string[]): Pr
|
|
|
1329
1315
|
await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
1330
1316
|
}
|
|
1331
1317
|
|
|
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
1318
|
// Writes per-adapter field values into `secrets.json#channels.<adapter>`.
|
|
1351
1319
|
// Refuses to overwrite existing fields: if the user already has e.g.
|
|
1352
1320
|
// `botToken` recorded (from a prior `channel add` whose follow-up steps
|
|
@@ -1489,13 +1457,13 @@ export async function setChannelSecrets(
|
|
|
1489
1457
|
// previous auth type, since the two shapes share no fields beyond `type`).
|
|
1490
1458
|
export type GithubCredentialPatch = {
|
|
1491
1459
|
webhookSecret?: string
|
|
1492
|
-
auth?: { type: 'pat'; pat: string } | { type: 'app'; privateKey: string; appId?: number
|
|
1460
|
+
auth?: { type: 'pat'; pat: string } | { type: 'app'; privateKey: string; appId?: number }
|
|
1493
1461
|
}
|
|
1494
1462
|
|
|
1495
1463
|
// Update one or more credential fields on an already-configured GitHub
|
|
1496
1464
|
// channel. Like setChannelSecrets, refuses when secrets.json has no
|
|
1497
1465
|
// existing github entry. Supports both same-type rotation (preserves env
|
|
1498
|
-
// bindings, carries appId
|
|
1466
|
+
// bindings, carries appId forward when not supplied) and
|
|
1499
1467
|
// auth-type switching (replaces the entire auth block — see
|
|
1500
1468
|
// `GithubCredentialPatch` above).
|
|
1501
1469
|
export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch): Promise<SetChannelTokensResult> {
|
|
@@ -1531,7 +1499,6 @@ export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch
|
|
|
1531
1499
|
} else {
|
|
1532
1500
|
const existingApp = isSameType && isObjectRecord(existingAuth) ? (existingAuth as Record<string, unknown>) : {}
|
|
1533
1501
|
const appId = patch.auth.appId ?? (existingApp.appId as number | undefined)
|
|
1534
|
-
const installationId = patch.auth.installationId ?? (existingApp.installationId as number | undefined)
|
|
1535
1502
|
if (typeof appId !== 'number') {
|
|
1536
1503
|
return {
|
|
1537
1504
|
result: {
|
|
@@ -1546,7 +1513,6 @@ export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch
|
|
|
1546
1513
|
type: 'app',
|
|
1547
1514
|
appId,
|
|
1548
1515
|
privateKey: rotatedSecret(existingApp.privateKey, patch.auth.privateKey),
|
|
1549
|
-
...(installationId !== undefined ? { installationId } : {}),
|
|
1550
1516
|
}
|
|
1551
1517
|
}
|
|
1552
1518
|
}
|