typeclaw 0.17.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/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/cli/channel.ts +0 -12
- package/src/cli/init.ts +0 -9
- package/src/init/github-webhook-install.ts +1 -2
- package/src/init/index.ts +4 -10
- package/src/run/index.ts +2 -0
- package/src/secrets/schema.ts +0 -1
- package/src/server/command-runner.ts +14 -0
package/auth.schema.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typeclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"homepage": "https://github.com/typeclaw/typeclaw#readme",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/typeclaw/typeclaw/issues"
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"@mariozechner/pi-coding-agent": "^0.67.3",
|
|
47
47
|
"@mariozechner/pi-tui": "^0.67.3",
|
|
48
48
|
"@mozilla/readability": "^0.6.0",
|
|
49
|
-
"agent-messenger": "2.19.
|
|
49
|
+
"agent-messenger": "2.19.1",
|
|
50
50
|
"cheerio": "^1.2.0",
|
|
51
51
|
"citty": "^0.2.2",
|
|
52
52
|
"cron-parser": "^5.5.0",
|
package/secrets.schema.json
CHANGED
|
@@ -12,6 +12,7 @@ export type InboundDropReason =
|
|
|
12
12
|
| 'self_author' // event.author.id === botUserId; we never route our own messages back to ourselves
|
|
13
13
|
| 'empty_content' // SDK delivered content: '' — usually missing MessageContent intent
|
|
14
14
|
| 'pre_connect' // bot identity is not known yet, so mention/self/reply classification cannot be trusted
|
|
15
|
+
| 'thread_created_system' // Discord's THREAD_CREATED system notice posted to the PARENT channel when a public thread is created from a message
|
|
15
16
|
|
|
16
17
|
export type InboundClassification =
|
|
17
18
|
| { kind: 'drop'; reason: InboundDropReason }
|
|
@@ -38,6 +39,10 @@ export function classifyInbound(
|
|
|
38
39
|
const { text, attachments } = splitInbound(event)
|
|
39
40
|
if (text === '') return { kind: 'drop', reason: 'empty_content' }
|
|
40
41
|
|
|
42
|
+
if (isThreadCreatedSystemMessage(event)) {
|
|
43
|
+
return { kind: 'drop', reason: 'thread_created_system' }
|
|
44
|
+
}
|
|
45
|
+
|
|
41
46
|
const isDm = event.guild_id === undefined
|
|
42
47
|
const workspace = isDm ? '@dm' : event.guild_id!
|
|
43
48
|
|
|
@@ -108,6 +113,24 @@ function isReplyToBot(event: DiscordGatewayMessageCreateEvent, botUserId: string
|
|
|
108
113
|
return (event.mentions ?? []).some((m) => m.id === botUserId)
|
|
109
114
|
}
|
|
110
115
|
|
|
116
|
+
// Creating a public thread from a message fires TWO MESSAGE_CREATE events: a
|
|
117
|
+
// THREAD_CREATED notice in the parent channel (content = thread name) and a
|
|
118
|
+
// THREAD_STARTER_MESSAGE inside the thread. Each opens its own router session,
|
|
119
|
+
// so the agent replies twice. The numeric Discord message type (18) that would
|
|
120
|
+
// filter this cleanly is destroyed by the agent-messenger listener (it does
|
|
121
|
+
// `{ ...d, type: t }`, overwriting `d.type` with the dispatch name), so we
|
|
122
|
+
// fingerprint the notice by its reference instead: it points at a DIFFERENT
|
|
123
|
+
// channel (the new thread) with no source `message_id`. The message_id-absent
|
|
124
|
+
// check is load-bearing — it spares normal cross-channel replies and the
|
|
125
|
+
// in-thread starter, both of which DO carry a message_id.
|
|
126
|
+
function isThreadCreatedSystemMessage(event: DiscordGatewayMessageCreateEvent): boolean {
|
|
127
|
+
const ref = event.message_reference
|
|
128
|
+
if (ref?.channel_id === undefined) return false
|
|
129
|
+
if (ref.channel_id === event.channel_id) return false
|
|
130
|
+
if (ref.message_id !== undefined) return false
|
|
131
|
+
return ref.guild_id === undefined || event.guild_id === undefined || ref.guild_id === event.guild_id
|
|
132
|
+
}
|
|
133
|
+
|
|
111
134
|
type SplitInbound = { text: string; attachments: InboundAttachment[] }
|
|
112
135
|
|
|
113
136
|
function splitInbound(event: DiscordGatewayMessageCreateEvent): SplitInbound {
|
|
@@ -2,33 +2,43 @@ import { createPrivateKey } from 'node:crypto'
|
|
|
2
2
|
|
|
3
3
|
import { resolveSecret, type Secret } from '@/secrets/resolve'
|
|
4
4
|
|
|
5
|
-
import type { GithubAuthStrategy, GithubInstallationGrants, GithubSelfUser } from './auth'
|
|
5
|
+
import type { GithubAuthContext, GithubAuthStrategy, GithubInstallationGrants, GithubSelfUser } from './auth'
|
|
6
6
|
import { GITHUB_API_BASE, githubJsonHeaders, githubPublicHeaders } from './auth-pat'
|
|
7
7
|
|
|
8
|
+
type TokenCacheEntry = { value: string; expiresAt: number }
|
|
9
|
+
|
|
8
10
|
export class AppAuthStrategy implements GithubAuthStrategy {
|
|
9
11
|
private readonly appId: number
|
|
10
12
|
private readonly privateKeyPem: string
|
|
11
|
-
private readonly installationId: number | null
|
|
12
13
|
private readonly fetchImpl: typeof fetch
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
// Keyed by installation id: a single App may span multiple owners, each a
|
|
15
|
+
// separate installation with its own short-lived token.
|
|
16
|
+
private readonly tokenCache = new Map<number, TokenCacheEntry>()
|
|
17
|
+
private readonly repoInstallationCache = new Map<string, number>()
|
|
18
|
+
private soleInstallationId: number | null = null
|
|
15
19
|
private _selfUser: GithubSelfUser | null = null
|
|
16
20
|
|
|
17
|
-
constructor(options: { appId: number; privateKey: Secret;
|
|
21
|
+
constructor(options: { appId: number; privateKey: Secret; fetchImpl?: typeof fetch }) {
|
|
18
22
|
const privateKeyPem = resolveSecret(options.privateKey, undefined, process.env)
|
|
19
23
|
if (privateKeyPem === undefined || privateKeyPem.trim() === '') throw new Error('GitHub App private key is missing')
|
|
20
24
|
this.appId = options.appId
|
|
21
25
|
this.privateKeyPem = privateKeyPem
|
|
22
|
-
this.installationId = options.installationId ?? null
|
|
23
26
|
this.fetchImpl = options.fetchImpl ?? fetch
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
async token(): Promise<string> {
|
|
27
|
-
if (this.cachedToken && Date.now() < this.cachedToken.expiresAt - 5 * 60 * 1000) {
|
|
28
|
-
return this.cachedToken.value
|
|
29
|
-
}
|
|
29
|
+
async token(context?: GithubAuthContext): Promise<string> {
|
|
30
30
|
const jwt = await this.mintJwt()
|
|
31
|
-
const installId = await this.resolveInstallationId(jwt)
|
|
31
|
+
const installId = await this.resolveInstallationId(jwt, context)
|
|
32
|
+
return this.installationToken(jwt, installId)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async authHeaders(context?: GithubAuthContext): Promise<HeadersInit> {
|
|
36
|
+
return githubJsonHeaders(await this.token(context))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private async installationToken(jwt: string, installId: number): Promise<string> {
|
|
40
|
+
const cached = this.tokenCache.get(installId)
|
|
41
|
+
if (cached && Date.now() < cached.expiresAt - 5 * 60 * 1000) return cached.value
|
|
32
42
|
const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations/${installId}/access_tokens`, {
|
|
33
43
|
method: 'POST',
|
|
34
44
|
headers: githubJsonHeaders(jwt),
|
|
@@ -37,14 +47,10 @@ export class AppAuthStrategy implements GithubAuthStrategy {
|
|
|
37
47
|
const raw = (await response.json()) as { token?: unknown; expires_at?: unknown }
|
|
38
48
|
if (typeof raw.token !== 'string') throw new Error('GitHub App token response missing token')
|
|
39
49
|
const expiresAt = typeof raw.expires_at === 'string' ? Date.parse(raw.expires_at) : Date.now() + 60 * 60 * 1000
|
|
40
|
-
this.
|
|
50
|
+
this.tokenCache.set(installId, { value: raw.token, expiresAt })
|
|
41
51
|
return raw.token
|
|
42
52
|
}
|
|
43
53
|
|
|
44
|
-
async authHeaders(): Promise<HeadersInit> {
|
|
45
|
-
return githubJsonHeaders(await this.token())
|
|
46
|
-
}
|
|
47
|
-
|
|
48
54
|
async getSelf(): Promise<GithubSelfUser> {
|
|
49
55
|
if (this._selfUser) return this._selfUser
|
|
50
56
|
const jwt = await this.mintJwt()
|
|
@@ -69,9 +75,9 @@ export class AppAuthStrategy implements GithubAuthStrategy {
|
|
|
69
75
|
return this._selfUser
|
|
70
76
|
}
|
|
71
77
|
|
|
72
|
-
async getInstallationGrants(): Promise<GithubInstallationGrants> {
|
|
78
|
+
async getInstallationGrants(context?: GithubAuthContext): Promise<GithubInstallationGrants> {
|
|
73
79
|
const jwt = await this.mintJwt()
|
|
74
|
-
const installId = await this.resolveInstallationId(jwt)
|
|
80
|
+
const installId = await this.resolveInstallationId(jwt, context)
|
|
75
81
|
const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations/${installId}`, {
|
|
76
82
|
headers: githubJsonHeaders(jwt),
|
|
77
83
|
})
|
|
@@ -88,7 +94,8 @@ export class AppAuthStrategy implements GithubAuthStrategy {
|
|
|
88
94
|
}
|
|
89
95
|
|
|
90
96
|
async dispose(): Promise<void> {
|
|
91
|
-
this.
|
|
97
|
+
this.tokenCache.clear()
|
|
98
|
+
this.repoInstallationCache.clear()
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
private async mintJwt(): Promise<string> {
|
|
@@ -107,25 +114,41 @@ export class AppAuthStrategy implements GithubAuthStrategy {
|
|
|
107
114
|
return `${signingInput}.${base64url(Buffer.from(signature))}`
|
|
108
115
|
}
|
|
109
116
|
|
|
110
|
-
private async resolveInstallationId(jwt: string): Promise<number> {
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
117
|
+
private async resolveInstallationId(jwt: string, context?: GithubAuthContext): Promise<number> {
|
|
118
|
+
if (context?.repoSlug !== undefined && context.repoSlug !== '') {
|
|
119
|
+
return this.resolveInstallationByEndpoint(jwt, `repos/${context.repoSlug}/installation`, context.repoSlug)
|
|
120
|
+
}
|
|
121
|
+
if (context?.owner !== undefined && context.owner !== '') {
|
|
122
|
+
return this.resolveInstallationByEndpoint(jwt, `orgs/${context.owner}/installation`, context.owner)
|
|
115
123
|
}
|
|
124
|
+
if (this.soleInstallationId !== null) return this.soleInstallationId
|
|
116
125
|
const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations`, { headers: githubJsonHeaders(jwt) })
|
|
117
126
|
if (!response.ok) throw new Error(`GitHub App installations fetch failed: ${response.status}`)
|
|
118
127
|
const list = (await response.json()) as Array<{ id?: unknown }>
|
|
119
128
|
if (list.length === 0) throw new Error('GitHub App has no installations')
|
|
120
129
|
if (list.length > 1) {
|
|
121
130
|
const ids = list.map((installation) => installation.id).join(', ')
|
|
122
|
-
throw new Error(`GitHub App has multiple installations (${ids});
|
|
131
|
+
throw new Error(`GitHub App has multiple installations (${ids}); a repo must be specified to select one`)
|
|
123
132
|
}
|
|
124
133
|
const id = list[0]?.id
|
|
125
134
|
if (typeof id !== 'number') throw new Error('GitHub App installation missing id')
|
|
126
|
-
this.
|
|
135
|
+
this.soleInstallationId = id
|
|
127
136
|
return id
|
|
128
137
|
}
|
|
138
|
+
|
|
139
|
+
private async resolveInstallationByEndpoint(jwt: string, path: string, target: string): Promise<number> {
|
|
140
|
+
const cached = this.repoInstallationCache.get(target)
|
|
141
|
+
if (cached !== undefined) return cached
|
|
142
|
+
const response = await this.fetchImpl(`${GITHUB_API_BASE}/${path}`, { headers: githubJsonHeaders(jwt) })
|
|
143
|
+
if (response.status === 404) {
|
|
144
|
+
throw new Error(`GitHub App is not installed for ${target} or lacks access to that repository`)
|
|
145
|
+
}
|
|
146
|
+
if (!response.ok) throw new Error(`GitHub App installation lookup for ${target} failed: ${response.status}`)
|
|
147
|
+
const raw = (await response.json()) as { id?: unknown }
|
|
148
|
+
if (typeof raw.id !== 'number') throw new Error(`GitHub App installation for ${target} missing id`)
|
|
149
|
+
this.repoInstallationCache.set(target, raw.id)
|
|
150
|
+
return raw.id
|
|
151
|
+
}
|
|
129
152
|
}
|
|
130
153
|
|
|
131
154
|
function base64url(input: string | Buffer): string {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolveSecret, type Secret } from '@/secrets/resolve'
|
|
2
2
|
|
|
3
|
-
import type { GithubAuthStrategy, GithubSelfUser } from './auth'
|
|
3
|
+
import type { GithubAuthContext, GithubAuthStrategy, GithubSelfUser } from './auth'
|
|
4
4
|
|
|
5
5
|
export const GITHUB_API_BASE = 'https://api.github.com'
|
|
6
6
|
|
|
@@ -15,11 +15,11 @@ export class PatAuthStrategy implements GithubAuthStrategy {
|
|
|
15
15
|
this.fetchImpl = options.fetchImpl ?? fetch
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
async token(): Promise<string> {
|
|
18
|
+
async token(_context?: GithubAuthContext): Promise<string> {
|
|
19
19
|
return this._token
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
async authHeaders(): Promise<HeadersInit> {
|
|
22
|
+
async authHeaders(_context?: GithubAuthContext): Promise<HeadersInit> {
|
|
23
23
|
return githubJsonHeaders(this._token)
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -3,15 +3,30 @@ import type { GithubAppAuthBlock, GithubPatAuthBlock } from '@/secrets/schema'
|
|
|
3
3
|
import { AppAuthStrategy } from './auth-app'
|
|
4
4
|
import { PatAuthStrategy } from './auth-pat'
|
|
5
5
|
|
|
6
|
+
// Repo identity threaded through every auth call so App auth can pick the
|
|
7
|
+
// correct installation. `repoSlug` is the canonical input ("owner/name"); App
|
|
8
|
+
// auth resolves it to an installation via GET /repos/{owner}/{repo}/installation
|
|
9
|
+
// and caches the result. PAT auth ignores it entirely. Omitted context means
|
|
10
|
+
// "no specific repo" — App auth then falls back to a single discoverable
|
|
11
|
+
// installation (and errors if the App spans multiple installations).
|
|
12
|
+
export type GithubAuthContext = {
|
|
13
|
+
repoSlug?: string
|
|
14
|
+
// Org login, for operations that aren't repo-scoped (e.g. team-membership
|
|
15
|
+
// lookups under GET /orgs/{org}/...). App auth resolves it to an
|
|
16
|
+
// installation via GET /orgs/{org}/installation. Ignored when repoSlug is set.
|
|
17
|
+
owner?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
export type GithubAuthStrategy = {
|
|
7
|
-
token: () => Promise<string>
|
|
8
|
-
authHeaders: () => Promise<HeadersInit>
|
|
21
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
22
|
+
authHeaders: (context?: GithubAuthContext) => Promise<HeadersInit>
|
|
9
23
|
getSelf: () => Promise<GithubSelfUser>
|
|
10
24
|
// App-only: returns the installation's granted-permissions map and declared
|
|
11
25
|
// events so the adapter can preflight against the configured eventAllowlist
|
|
12
26
|
// before any webhook arrives. PATs return access via token scopes, not an
|
|
13
|
-
// installation grant, so they leave this undefined.
|
|
14
|
-
|
|
27
|
+
// installation grant, so they leave this undefined. Context selects which
|
|
28
|
+
// installation to inspect when the App spans multiple owners.
|
|
29
|
+
getInstallationGrants?: (context?: GithubAuthContext) => Promise<GithubInstallationGrants>
|
|
15
30
|
dispose: () => Promise<void>
|
|
16
31
|
}
|
|
17
32
|
|
|
@@ -36,7 +51,6 @@ export function buildAuthStrategy(options: {
|
|
|
36
51
|
return new AppAuthStrategy({
|
|
37
52
|
appId: options.auth.appId,
|
|
38
53
|
privateKey: options.auth.privateKey,
|
|
39
|
-
installationId: options.auth.installationId,
|
|
40
54
|
fetchImpl: options.fetchImpl,
|
|
41
55
|
})
|
|
42
56
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { ChannelNameResolver, ResolvedChannelNames } from '@/channels/types'
|
|
2
2
|
|
|
3
|
+
import type { GithubAuthContext } from './auth'
|
|
3
4
|
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
4
5
|
import { parseChat, parseRepo } from './outbound'
|
|
5
6
|
|
|
6
7
|
export function createGithubChannelNameResolver(options: {
|
|
7
|
-
token: () => Promise<string>
|
|
8
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
8
9
|
fetchImpl?: typeof fetch
|
|
9
10
|
}): ChannelNameResolver {
|
|
10
11
|
const fetchImpl = options.fetchImpl ?? fetch
|
|
@@ -18,7 +19,7 @@ export function createGithubChannelNameResolver(options: {
|
|
|
18
19
|
const path = chat.kind === 'issue' ? `issues/${chat.number}` : `pulls/${chat.number}`
|
|
19
20
|
try {
|
|
20
21
|
const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/${path}`, {
|
|
21
|
-
headers: githubJsonHeaders(await options.token()),
|
|
22
|
+
headers: githubJsonHeaders(await options.token({ repoSlug: key.workspace })),
|
|
22
23
|
})
|
|
23
24
|
if (!response.ok) return names
|
|
24
25
|
const raw = (await response.json()) as { title?: string }
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { ChannelHistoryMessage, FetchHistoryArgs, FetchHistoryResult, HistoryCallback } from '@/channels/types'
|
|
2
2
|
|
|
3
|
+
import type { GithubAuthContext } from './auth'
|
|
3
4
|
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
4
5
|
import { parseChat, parseRepo } from './outbound'
|
|
5
6
|
|
|
6
7
|
export function createGithubHistoryCallback(options: {
|
|
7
|
-
token: () => Promise<string>
|
|
8
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
8
9
|
workspaceForChat: (chat: string) => string | null
|
|
9
10
|
fetchImpl?: typeof fetch
|
|
10
11
|
}): HistoryCallback {
|
|
@@ -26,7 +27,7 @@ export function createGithubHistoryCallback(options: {
|
|
|
26
27
|
const response = await fetchImpl(
|
|
27
28
|
`${endpoint}?per_page=${Math.min(Math.max(args.limit, 1), 100)}&direction=desc${cursor}`,
|
|
28
29
|
{
|
|
29
|
-
headers: githubJsonHeaders(await options.token()),
|
|
30
|
+
headers: githubJsonHeaders(await options.token({ repoSlug: workspace })),
|
|
30
31
|
},
|
|
31
32
|
)
|
|
32
33
|
if (!response.ok) return { ok: false, error: `GitHub history ${response.status}` }
|
|
@@ -3,7 +3,7 @@ import type { ChannelAdapterConfig, GithubAdapterConfig } from '@/channels/schem
|
|
|
3
3
|
import { resolveSecret } from '@/secrets/resolve'
|
|
4
4
|
import type { GithubSecretsBlock } from '@/secrets/schema'
|
|
5
5
|
|
|
6
|
-
import { buildAuthStrategy } from './auth'
|
|
6
|
+
import { buildAuthStrategy, type GithubAuthContext } from './auth'
|
|
7
7
|
import { createGithubChannelNameResolver } from './channel-resolver'
|
|
8
8
|
import { createDeliveryDedup } from './dedup'
|
|
9
9
|
import { findPermissionGaps } from './event-permissions'
|
|
@@ -99,29 +99,30 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
99
99
|
workspaceByChat.set(chat, workspace)
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
102
|
+
// Repo/owner-aware token resolver. A single GitHub App can span multiple
|
|
103
|
+
// installations (one per owner); each consumer passes its repo/owner so the
|
|
104
|
+
// right installation token is minted. Unlike the old single-token path, this
|
|
105
|
+
// does NOT mutate process.env.GH_TOKEN — that global is seeded separately and
|
|
106
|
+
// only when exactly one installation applies (see seedGhTokenIfSingle).
|
|
107
|
+
const authToken = (context?: GithubAuthContext) => auth.token(context)
|
|
107
108
|
const outbound = createGithubOutboundCallback({
|
|
108
|
-
token:
|
|
109
|
+
token: authToken,
|
|
109
110
|
authType: options.secrets.auth.type,
|
|
110
111
|
logger,
|
|
111
112
|
fetchImpl,
|
|
112
113
|
})
|
|
113
114
|
const history = createGithubHistoryCallback({
|
|
114
|
-
token:
|
|
115
|
+
token: authToken,
|
|
115
116
|
fetchImpl,
|
|
116
117
|
workspaceForChat: (chat) => workspaceByChat.get(chat) ?? null,
|
|
117
118
|
})
|
|
118
|
-
const membership = createGithubMembershipResolver({ token:
|
|
119
|
-
const channelNameResolver = createGithubChannelNameResolver({ token:
|
|
119
|
+
const membership = createGithubMembershipResolver({ token: authToken, fetchImpl })
|
|
120
|
+
const channelNameResolver = createGithubChannelNameResolver({ token: authToken, fetchImpl })
|
|
120
121
|
const fetchAttachment = createGithubFetchAttachmentCallback()
|
|
121
122
|
// No-op typing callback: GitHub has no typing indicator API.
|
|
122
123
|
const typing = async (): Promise<void> => {}
|
|
123
124
|
const dedup = createDeliveryDedup()
|
|
124
|
-
const isBotInTeam = createTeamMembershipChecker({ token:
|
|
125
|
+
const isBotInTeam = createTeamMembershipChecker({ token: authToken, fetchImpl })
|
|
125
126
|
const handler = createGithubWebhookHandler({
|
|
126
127
|
webhookSecret,
|
|
127
128
|
dedup,
|
|
@@ -174,35 +175,48 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
174
175
|
selfLogin = null
|
|
175
176
|
throw err
|
|
176
177
|
}
|
|
177
|
-
// Seed GH_TOKEN so `gh` CLI calls in the container are pre-authenticated.
|
|
178
|
-
// tokenFn keeps it current on every adapter API call; App tokens refresh
|
|
179
|
-
// automatically when within 5 minutes of expiry.
|
|
180
|
-
process.env.GH_TOKEN = await auth.token()
|
|
181
178
|
started = true
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
179
|
+
// GH_TOKEN is a single process-wide env var the container's `gh` CLI
|
|
180
|
+
// reads, but a GitHub App spanning multiple owners has no single correct
|
|
181
|
+
// token. Seed/refresh it only when exactly one repo is configured (PAT,
|
|
182
|
+
// or App with one unambiguous installation). With multiple repos we skip
|
|
183
|
+
// the global seed: ad-hoc `gh` calls must target a specific repo, and the
|
|
184
|
+
// adapter's own API calls always resolve a repo-scoped token via authToken.
|
|
185
|
+
const ghTokenRepo = ghTokenSeedRepo(options.configRef().repos ?? [])
|
|
186
|
+
const seedGhToken = async (): Promise<void> => {
|
|
187
|
+
process.env.GH_TOKEN = await auth.token(ghTokenRepo === null ? undefined : { repoSlug: ghTokenRepo })
|
|
188
|
+
}
|
|
189
|
+
if (ghTokenRepo !== null || options.secrets.auth.type === 'pat') {
|
|
190
|
+
await seedGhToken()
|
|
191
|
+
const tokenRefreshIntervalMs = options.tokenRefreshIntervalMs ?? DEFAULT_TOKEN_REFRESH_INTERVAL_MS
|
|
192
|
+
if (tokenRefreshIntervalMs > 0) {
|
|
193
|
+
const refresh = () => {
|
|
194
|
+
seedGhToken().catch((err) => {
|
|
195
|
+
logger.error(
|
|
196
|
+
`[github] periodic token refresh failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
197
|
+
)
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
const setIntervalFn =
|
|
201
|
+
options.setInterval ??
|
|
202
|
+
((handler: () => void, ms: number) => {
|
|
203
|
+
const timer = setInterval(handler, ms)
|
|
204
|
+
return { clear: () => clearInterval(timer) }
|
|
205
|
+
})
|
|
206
|
+
tokenRefreshTimer = setIntervalFn(refresh, tokenRefreshIntervalMs)
|
|
191
207
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
})
|
|
198
|
-
tokenRefreshTimer = setIntervalFn(refresh, tokenRefreshIntervalMs)
|
|
208
|
+
} else {
|
|
209
|
+
logger.info(
|
|
210
|
+
'[github] multiple repos configured across possibly-different owners; GH_TOKEN not seeded globally. ' +
|
|
211
|
+
'Ad-hoc `gh` commands should set a repo-scoped token explicitly.',
|
|
212
|
+
)
|
|
199
213
|
}
|
|
200
214
|
logger.info(`[github] webhook listening on port ${options.configRef().webhookPort} as @${self.login}`)
|
|
201
215
|
// Best-effort: App-only preflight that compares the installation's granted
|
|
202
216
|
// permissions against the configured eventAllowlist and warns about gaps.
|
|
203
217
|
// Catches the most common misconfiguration (App installed with the default
|
|
204
218
|
// metadata-only permission set) before any event fires a 403.
|
|
205
|
-
await runAppPermissionPreflight(logger, auth, options.configRef().eventAllowlist)
|
|
219
|
+
await runAppPermissionPreflight(logger, auth, options.configRef().eventAllowlist, options.configRef().repos ?? [])
|
|
206
220
|
// Repository webhook registration is best-effort: failures are logged
|
|
207
221
|
// per-repo, the adapter stays up. A misconfigured PAT or App that
|
|
208
222
|
// can't manage hooks must not prevent the adapter from accepting
|
|
@@ -225,6 +239,9 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
225
239
|
})
|
|
226
240
|
} else if (repos.length > 0) {
|
|
227
241
|
const legacyProviderHostSuffix = detectLegacyProviderHostSuffix(effectiveUrl)
|
|
242
|
+
logger.info(
|
|
243
|
+
`[github] registering webhook for ${repos.length} repo(s) [${repos.join(', ')}] -> ${effectiveUrl} (events: ${cfg.eventAllowlist.join(', ')})`,
|
|
244
|
+
)
|
|
228
245
|
if (webhookRegistrationDelayMs > 0) {
|
|
229
246
|
logger.info(
|
|
230
247
|
`[github] waiting ${webhookRegistrationDelayMs}ms before registering webhook so the Cloudflare edge can warm up`,
|
|
@@ -232,7 +249,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
232
249
|
await sleep(webhookRegistrationDelayMs)
|
|
233
250
|
}
|
|
234
251
|
const registration = await registerGithubWebhooks({
|
|
235
|
-
token:
|
|
252
|
+
token: (repoSlug: string) => auth.token({ repoSlug }),
|
|
236
253
|
webhookUrl: effectiveUrl,
|
|
237
254
|
webhookSecret,
|
|
238
255
|
repos,
|
|
@@ -263,7 +280,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
263
280
|
// last to clear the cached App-installation token.
|
|
264
281
|
if (managedHooks.length > 0) {
|
|
265
282
|
const deregistration = await deregisterGithubWebhooks({
|
|
266
|
-
token:
|
|
283
|
+
token: (repoSlug: string) => auth.token({ repoSlug }),
|
|
267
284
|
hooks: managedHooks,
|
|
268
285
|
fetchImpl,
|
|
269
286
|
})
|
|
@@ -367,18 +384,36 @@ async function runAppPermissionPreflight(
|
|
|
367
384
|
logger: GithubAdapterLogger,
|
|
368
385
|
auth: ReturnType<typeof buildAuthStrategy>,
|
|
369
386
|
eventAllowlist: readonly string[],
|
|
387
|
+
repos: readonly string[],
|
|
370
388
|
): Promise<void> {
|
|
371
389
|
if (auth.getInstallationGrants === undefined) return
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
390
|
+
const getGrants = (context: GithubAuthContext | undefined) => auth.getInstallationGrants?.(context)
|
|
391
|
+
// One grants check per distinct owner: installations are owner-scoped, so
|
|
392
|
+
// repos sharing an owner share an installation. The first repo per owner is
|
|
393
|
+
// the resolution key. With no repos, fall back to a single context-free check.
|
|
394
|
+
const reposByOwner = new Map<string, string>()
|
|
395
|
+
for (const repo of repos) {
|
|
396
|
+
const owner = repo.split('/')[0]
|
|
397
|
+
if (owner !== undefined && owner !== '' && !reposByOwner.has(owner)) reposByOwner.set(owner, repo)
|
|
398
|
+
}
|
|
399
|
+
const contexts: Array<{ label: string; context: { repoSlug: string } | undefined }> =
|
|
400
|
+
reposByOwner.size === 0
|
|
401
|
+
? [{ label: 'app', context: undefined }]
|
|
402
|
+
: [...reposByOwner.values()].map((repo) => ({ label: repo, context: { repoSlug: repo } }))
|
|
403
|
+
for (const { label, context } of contexts) {
|
|
404
|
+
let grants
|
|
405
|
+
try {
|
|
406
|
+
grants = await getGrants(context)
|
|
407
|
+
} catch (err) {
|
|
408
|
+
logger.warn(
|
|
409
|
+
`[github] permission preflight skipped for ${label}: ${err instanceof Error ? err.message : String(err)}`,
|
|
410
|
+
)
|
|
411
|
+
continue
|
|
412
|
+
}
|
|
413
|
+
if (grants === undefined) continue
|
|
414
|
+
const gaps = findPermissionGaps(eventAllowlist, grants.permissions)
|
|
415
|
+
if (gaps.length > 0) logger.warn(buildAppPermissionPreflightGuidance(gaps))
|
|
378
416
|
}
|
|
379
|
-
const gaps = findPermissionGaps(eventAllowlist, grants.permissions)
|
|
380
|
-
if (gaps.length === 0) return
|
|
381
|
-
logger.warn(buildAppPermissionPreflightGuidance(gaps))
|
|
382
417
|
}
|
|
383
418
|
|
|
384
419
|
function logDeregistrationOutcome(
|
|
@@ -392,6 +427,13 @@ function logDeregistrationOutcome(
|
|
|
392
427
|
}
|
|
393
428
|
}
|
|
394
429
|
|
|
430
|
+
// Two repos under the same owner share an installation and could in principle
|
|
431
|
+
// share a global GH_TOKEN, but the marginal value doesn't justify the special
|
|
432
|
+
// case — only a single configured repo yields an unambiguous seed.
|
|
433
|
+
function ghTokenSeedRepo(repos: readonly string[]): string | null {
|
|
434
|
+
return repos.length === 1 ? (repos[0] ?? null) : null
|
|
435
|
+
}
|
|
436
|
+
|
|
395
437
|
function defaultSleep(ms: number): Promise<void> {
|
|
396
438
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
397
439
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { MembershipResolver, MembershipResolverResult } from '@/channels/membership'
|
|
2
2
|
|
|
3
|
+
import type { GithubAuthContext } from './auth'
|
|
3
4
|
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
4
5
|
import { parseRepo } from './outbound'
|
|
5
6
|
|
|
6
7
|
export function createGithubMembershipResolver(options: {
|
|
7
|
-
token: () => Promise<string>
|
|
8
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
8
9
|
fetchImpl?: typeof fetch
|
|
9
10
|
}): MembershipResolver {
|
|
10
11
|
const fetchImpl = options.fetchImpl ?? fetch
|
|
@@ -16,7 +17,7 @@ export function createGithubMembershipResolver(options: {
|
|
|
16
17
|
const response = await fetchImpl(
|
|
17
18
|
`${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/collaborators?per_page=100`,
|
|
18
19
|
{
|
|
19
|
-
headers: githubJsonHeaders(await options.token()),
|
|
20
|
+
headers: githubJsonHeaders(await options.token({ repoSlug: key.workspace })),
|
|
20
21
|
},
|
|
21
22
|
)
|
|
22
23
|
if (!response.ok) return response.status >= 500 ? { kind: 'transient' } : { kind: 'permanent' }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OutboundCallback, OutboundMessage, SendResult } from '@/channels/types'
|
|
2
2
|
|
|
3
|
+
import type { GithubAuthContext } from './auth'
|
|
3
4
|
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
4
5
|
import {
|
|
5
6
|
buildOutboundPermissionGuidance,
|
|
@@ -11,7 +12,7 @@ import {
|
|
|
11
12
|
export type GithubOutboundLogger = { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }
|
|
12
13
|
|
|
13
14
|
export function createGithubOutboundCallback(deps: {
|
|
14
|
-
token: () => Promise<string>
|
|
15
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
15
16
|
authType: GithubAuthType
|
|
16
17
|
logger: GithubOutboundLogger
|
|
17
18
|
fetchImpl?: typeof fetch
|
|
@@ -28,9 +29,12 @@ export function createGithubOutboundCallback(deps: {
|
|
|
28
29
|
const target = parseChat(msg.chat)
|
|
29
30
|
if (target === null) return { ok: false, error: `invalid GitHub chat: ${msg.chat}` }
|
|
30
31
|
|
|
32
|
+
const token = () => deps.token({ repoSlug: msg.workspace })
|
|
33
|
+
|
|
31
34
|
if (target.kind === 'discussion') {
|
|
32
35
|
return await postDiscussionComment({
|
|
33
36
|
...deps,
|
|
37
|
+
token,
|
|
34
38
|
fetchImpl,
|
|
35
39
|
repo,
|
|
36
40
|
discussionNumber: target.number,
|
|
@@ -44,7 +48,7 @@ export function createGithubOutboundCallback(deps: {
|
|
|
44
48
|
: `${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/issues/${target.number}/comments`
|
|
45
49
|
return await postJson(
|
|
46
50
|
fetchImpl,
|
|
47
|
-
await
|
|
51
|
+
await token(),
|
|
48
52
|
endpoint,
|
|
49
53
|
{ body },
|
|
50
54
|
{
|
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
// request rebuilds it). Errors fall closed (return false): we'd rather drop
|
|
8
8
|
// a real review request than wake the agent on a team the bot isn't in.
|
|
9
9
|
|
|
10
|
+
import type { GithubAuthContext } from './auth'
|
|
11
|
+
|
|
10
12
|
const ACTIVE_MEMBERSHIP_STATE = 'active'
|
|
11
13
|
|
|
12
14
|
export type TeamMembershipChecker = (input: { org: string; slug: string; login: string }) => Promise<boolean>
|
|
13
15
|
|
|
14
16
|
export function createTeamMembershipChecker(options: {
|
|
15
|
-
token: () => Promise<string>
|
|
17
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
16
18
|
fetchImpl?: typeof fetch
|
|
17
19
|
}): TeamMembershipChecker {
|
|
18
20
|
const fetchImpl = options.fetchImpl ?? fetch
|
|
@@ -23,7 +25,7 @@ export function createTeamMembershipChecker(options: {
|
|
|
23
25
|
const cached = cache.get(key)
|
|
24
26
|
if (cached !== undefined) return cached
|
|
25
27
|
|
|
26
|
-
const result = await lookup(fetchImpl, await options.token(), org, slug, login)
|
|
28
|
+
const result = await lookup(fetchImpl, await options.token({ owner: org }), org, slug, login)
|
|
27
29
|
cache.set(key, result)
|
|
28
30
|
return result
|
|
29
31
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
2
2
|
|
|
3
3
|
export type RegisterGithubWebhooksOptions = {
|
|
4
|
-
token
|
|
4
|
+
// Resolves an installation token scoped to the given "owner/name" repo. A
|
|
5
|
+
// single GitHub App may span multiple owners (separate installations), so
|
|
6
|
+
// each repo's hook must be created/listed with that repo's own token.
|
|
7
|
+
token: (repoSlug: string) => Promise<string>
|
|
5
8
|
webhookUrl: string
|
|
6
9
|
webhookSecret: string
|
|
7
10
|
repos: readonly string[]
|
|
@@ -51,22 +54,22 @@ export async function registerGithubWebhooks(
|
|
|
51
54
|
options: RegisterGithubWebhooksOptions,
|
|
52
55
|
): Promise<WebhookRegistrationResult> {
|
|
53
56
|
const fetchImpl = options.fetchImpl ?? fetch
|
|
54
|
-
let token: string
|
|
55
|
-
try {
|
|
56
|
-
token = await options.token()
|
|
57
|
-
} catch (err) {
|
|
58
|
-
const error = describe(err)
|
|
59
|
-
return { repos: options.repos.map((repo) => ({ repo, action: 'failed' as const, error })) }
|
|
60
|
-
}
|
|
61
57
|
const repos: WebhookRepoResult[] = []
|
|
62
58
|
for (const repo of options.repos) {
|
|
59
|
+
let token: string
|
|
60
|
+
try {
|
|
61
|
+
token = await options.token(repo)
|
|
62
|
+
} catch (err) {
|
|
63
|
+
repos.push({ repo, action: 'failed', error: describe(err) })
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
63
66
|
repos.push(await registerOne(fetchImpl, token, repo, options))
|
|
64
67
|
}
|
|
65
68
|
return { repos }
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
export type DeregisterGithubWebhooksOptions = {
|
|
69
|
-
token: () => Promise<string>
|
|
72
|
+
token: (repoSlug: string) => Promise<string>
|
|
70
73
|
hooks: ReadonlyArray<{ repo: string; hookId: number }>
|
|
71
74
|
fetchImpl?: typeof fetch
|
|
72
75
|
}
|
|
@@ -79,15 +82,15 @@ export async function deregisterGithubWebhooks(
|
|
|
79
82
|
options: DeregisterGithubWebhooksOptions,
|
|
80
83
|
): Promise<WebhookDeregistrationResult> {
|
|
81
84
|
const fetchImpl = options.fetchImpl ?? fetch
|
|
82
|
-
let token: string
|
|
83
|
-
try {
|
|
84
|
-
token = await options.token()
|
|
85
|
-
} catch (err) {
|
|
86
|
-
const error = describe(err)
|
|
87
|
-
return { hooks: options.hooks.map((h) => ({ ...h, action: 'failed', error })) }
|
|
88
|
-
}
|
|
89
85
|
const hooks: WebhookDeregistrationResult['hooks'] = []
|
|
90
86
|
for (const hook of options.hooks) {
|
|
87
|
+
let token: string
|
|
88
|
+
try {
|
|
89
|
+
token = await options.token(hook.repo)
|
|
90
|
+
} catch (err) {
|
|
91
|
+
hooks.push({ ...hook, action: 'failed', error: describe(err) })
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
91
94
|
hooks.push(await deleteOne(fetchImpl, token, hook))
|
|
92
95
|
}
|
|
93
96
|
return { hooks }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SlackSocketModeSlashCommandArgs } from 'agent-messenger/slackbot'
|
|
2
2
|
|
|
3
|
+
import type { ExecuteCommandResult } from '@/channels/router'
|
|
3
4
|
import type { ChannelKey } from '@/channels/types'
|
|
4
5
|
|
|
5
6
|
// Slack channel ids: 'C' = public, 'G' = private/legacy multi-party DM,
|
|
@@ -64,13 +65,87 @@ export function parseSlashCommand(
|
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
// Slack blocks native slash commands inside threads ("/stop is not supported
|
|
69
|
+
// in threads. Sorry!"), so the only way to abort a thread-scoped turn from
|
|
70
|
+
// inside that thread is a normal message. We recognise a leading `!` as an
|
|
71
|
+
// alternate command prefix and route it through the same router.executeCommand
|
|
72
|
+
// path as native slashes. The guard is strict: only a first token that resolves
|
|
73
|
+
// to a known command name is rewritten, so casual messages like "!nice work"
|
|
74
|
+
// pass through untouched as regular agent input.
|
|
75
|
+
//
|
|
76
|
+
// Unlike native slash payloads (which never carry a thread and rely on the
|
|
77
|
+
// router's workspace+chat fallback), a thread message carries `thread_ts`,
|
|
78
|
+
// letting us target the exact thread session and skip the ambiguous-match case
|
|
79
|
+
// entirely.
|
|
80
|
+
export const THREAD_COMMAND_PREFIX = '!'
|
|
81
|
+
|
|
82
|
+
export type ThreadCommandInput = {
|
|
83
|
+
text: string
|
|
84
|
+
channel: string
|
|
85
|
+
threadTs: string | null
|
|
86
|
+
isDm: boolean
|
|
87
|
+
teamId: string
|
|
88
|
+
invokerId: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type ParseThreadCommandResult =
|
|
92
|
+
| { kind: 'parsed'; command: ParsedSlackSlashCommand }
|
|
93
|
+
| { kind: 'ignore'; reason: 'no-prefix' | 'unknown-command' }
|
|
94
|
+
|
|
95
|
+
// Both ignore reasons (`no-prefix`, `unknown-command`) are non-fatal: the
|
|
96
|
+
// caller lets the message flow through as ordinary agent input.
|
|
97
|
+
export function parseThreadCommand(
|
|
98
|
+
input: ThreadCommandInput,
|
|
99
|
+
knownCommands: ReadonlySet<string>,
|
|
100
|
+
): ParseThreadCommandResult {
|
|
101
|
+
const trimmed = input.text.trimStart()
|
|
102
|
+
if (!trimmed.startsWith(THREAD_COMMAND_PREFIX)) {
|
|
103
|
+
return { kind: 'ignore', reason: 'no-prefix' }
|
|
104
|
+
}
|
|
105
|
+
const firstToken = trimmed.slice(THREAD_COMMAND_PREFIX.length).split(/\s/, 1)[0] ?? ''
|
|
106
|
+
const name = firstToken.toLowerCase()
|
|
107
|
+
if (name === '' || !knownCommands.has(name)) {
|
|
108
|
+
return { kind: 'ignore', reason: 'unknown-command' }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const workspace = input.isDm ? '@dm' : input.teamId
|
|
112
|
+
return {
|
|
113
|
+
kind: 'parsed',
|
|
114
|
+
command: {
|
|
115
|
+
name,
|
|
116
|
+
key: { adapter: 'slack-bot', workspace, chat: input.channel, thread: input.threadTs },
|
|
117
|
+
invokerId: input.invokerId,
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
67
122
|
export const SLACK_SLASH_REPLY_ABORTED = 'Stopped the current turn.'
|
|
68
123
|
export const SLACK_SLASH_REPLY_NO_LIVE_SESSION = 'Nothing to stop — no active turn in this channel.'
|
|
69
124
|
export const SLACK_SLASH_REPLY_FAILED = 'Could not stop the current turn (internal error).'
|
|
70
125
|
export const SLACK_SLASH_REPLY_PERMISSION_DENIED =
|
|
71
126
|
'You do not have permission to stop the current turn in this channel.'
|
|
127
|
+
// Native slash commands cannot be invoked from a thread, so the only way to
|
|
128
|
+
// disambiguate is the `!stop` thread-message fallback — advise that, not the
|
|
129
|
+
// impossible `/stop`-in-thread.
|
|
72
130
|
export const SLACK_SLASH_REPLY_AMBIGUOUS =
|
|
73
|
-
'Multiple active turns in this channel. Reply
|
|
131
|
+
'Multiple active turns in this channel. Reply `!stop` inside the specific thread you want to stop.'
|
|
132
|
+
|
|
133
|
+
// Single outcome→reply mapping shared by the native-slash (ack payload) and
|
|
134
|
+
// `!cmd` thread (postMessage) delivery paths so the two never drift.
|
|
135
|
+
export function commandResultReply(result: ExecuteCommandResult): string {
|
|
136
|
+
switch (result.kind) {
|
|
137
|
+
case 'handled':
|
|
138
|
+
return SLACK_SLASH_REPLY_ABORTED
|
|
139
|
+
case 'no-live-session':
|
|
140
|
+
return SLACK_SLASH_REPLY_NO_LIVE_SESSION
|
|
141
|
+
case 'permission-denied':
|
|
142
|
+
return SLACK_SLASH_REPLY_PERMISSION_DENIED
|
|
143
|
+
case 'ambiguous':
|
|
144
|
+
return SLACK_SLASH_REPLY_AMBIGUOUS
|
|
145
|
+
case 'unknown-command':
|
|
146
|
+
return SLACK_SLASH_REPLY_FAILED
|
|
147
|
+
}
|
|
148
|
+
}
|
|
74
149
|
|
|
75
150
|
// Slack's ack callback accepts an optional response payload that becomes
|
|
76
151
|
// the user-visible reply. `response_type: 'ephemeral'` keeps the reply
|
|
@@ -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/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
|
|
|
@@ -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
|
@@ -94,7 +94,7 @@ export type GithubInitCredentials = {
|
|
|
94
94
|
hostname?: string
|
|
95
95
|
tokenEnv?: string
|
|
96
96
|
repos: string[]
|
|
97
|
-
auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string
|
|
97
|
+
auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string }
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
export type GithubTunnelProvider = 'cloudflare-quick' | 'cloudflare-named' | 'external' | 'none'
|
|
@@ -998,7 +998,7 @@ export type AddChannelOptions = {
|
|
|
998
998
|
hostname?: string
|
|
999
999
|
tokenEnv?: string
|
|
1000
1000
|
repos: string[]
|
|
1001
|
-
auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string
|
|
1001
|
+
auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string }
|
|
1002
1002
|
fetchImpl?: typeof fetch
|
|
1003
1003
|
}
|
|
1004
1004
|
)
|
|
@@ -1263,9 +1263,6 @@ async function writeGithubChannelForInit(cwd: string, credentials: GithubInitCre
|
|
|
1263
1263
|
type: 'app',
|
|
1264
1264
|
appId: credentials.auth.appId,
|
|
1265
1265
|
privateKey: { value: credentials.auth.privateKey } satisfies Secret,
|
|
1266
|
-
...(credentials.auth.installationId !== undefined
|
|
1267
|
-
? { installationId: credentials.auth.installationId }
|
|
1268
|
-
: {}),
|
|
1269
1266
|
},
|
|
1270
1267
|
webhookSecret: { value: credentials.webhookSecret } satisfies Secret,
|
|
1271
1268
|
}
|
|
@@ -1298,7 +1295,6 @@ async function appendGithubSecrets(
|
|
|
1298
1295
|
type: 'app',
|
|
1299
1296
|
appId: options.auth.appId,
|
|
1300
1297
|
privateKey: { value: options.auth.privateKey } satisfies Secret,
|
|
1301
|
-
...(options.auth.installationId !== undefined ? { installationId: options.auth.installationId } : {}),
|
|
1302
1298
|
},
|
|
1303
1299
|
webhookSecret: { value: options.webhookSecret } satisfies Secret,
|
|
1304
1300
|
}
|
|
@@ -1461,13 +1457,13 @@ export async function setChannelSecrets(
|
|
|
1461
1457
|
// previous auth type, since the two shapes share no fields beyond `type`).
|
|
1462
1458
|
export type GithubCredentialPatch = {
|
|
1463
1459
|
webhookSecret?: string
|
|
1464
|
-
auth?: { type: 'pat'; pat: string } | { type: 'app'; privateKey: string; appId?: number
|
|
1460
|
+
auth?: { type: 'pat'; pat: string } | { type: 'app'; privateKey: string; appId?: number }
|
|
1465
1461
|
}
|
|
1466
1462
|
|
|
1467
1463
|
// Update one or more credential fields on an already-configured GitHub
|
|
1468
1464
|
// channel. Like setChannelSecrets, refuses when secrets.json has no
|
|
1469
1465
|
// existing github entry. Supports both same-type rotation (preserves env
|
|
1470
|
-
// bindings, carries appId
|
|
1466
|
+
// bindings, carries appId forward when not supplied) and
|
|
1471
1467
|
// auth-type switching (replaces the entire auth block — see
|
|
1472
1468
|
// `GithubCredentialPatch` above).
|
|
1473
1469
|
export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch): Promise<SetChannelTokensResult> {
|
|
@@ -1503,7 +1499,6 @@ export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch
|
|
|
1503
1499
|
} else {
|
|
1504
1500
|
const existingApp = isSameType && isObjectRecord(existingAuth) ? (existingAuth as Record<string, unknown>) : {}
|
|
1505
1501
|
const appId = patch.auth.appId ?? (existingApp.appId as number | undefined)
|
|
1506
|
-
const installationId = patch.auth.installationId ?? (existingApp.installationId as number | undefined)
|
|
1507
1502
|
if (typeof appId !== 'number') {
|
|
1508
1503
|
return {
|
|
1509
1504
|
result: {
|
|
@@ -1518,7 +1513,6 @@ export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch
|
|
|
1518
1513
|
type: 'app',
|
|
1519
1514
|
appId,
|
|
1520
1515
|
privateKey: rotatedSecret(existingApp.privateKey, patch.auth.privateKey),
|
|
1521
|
-
...(installationId !== undefined ? { installationId } : {}),
|
|
1522
1516
|
}
|
|
1523
1517
|
}
|
|
1524
1518
|
}
|
package/src/run/index.ts
CHANGED
|
@@ -371,6 +371,7 @@ export async function startAgent({
|
|
|
371
371
|
runtimeVersion: runtimeVersionOpt.runtimeVersion,
|
|
372
372
|
containerName: containerNameOpt.containerName,
|
|
373
373
|
sessionFactory,
|
|
374
|
+
channelRouter: channelManager.router,
|
|
374
375
|
}),
|
|
375
376
|
subagent: (subName: string, payload?: unknown) =>
|
|
376
377
|
dispatchSpawnSubagent(subName, payload, {
|
|
@@ -585,6 +586,7 @@ export async function startAgent({
|
|
|
585
586
|
containerName,
|
|
586
587
|
outbound,
|
|
587
588
|
sessionFactory,
|
|
589
|
+
channelRouter: channelManager.router,
|
|
588
590
|
})
|
|
589
591
|
|
|
590
592
|
const server = createServer({
|
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
|
})
|