typeclaw 0.3.1 → 0.5.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/README.md +20 -15
- package/auth.schema.json +113 -0
- package/package.json +1 -1
- package/secrets.schema.json +113 -0
- package/src/agent/auth.ts +4 -2
- package/src/agent/index.ts +16 -28
- package/src/agent/model-fallback.ts +127 -0
- package/src/agent/session-meta.ts +1 -1
- package/src/agent/session-origin.ts +3 -2
- package/src/agent/tools/curl-impersonate.ts +300 -0
- package/src/agent/tools/ddg.ts +13 -88
- package/src/agent/tools/webfetch/fetch.ts +105 -2
- package/src/agent/tools/webfetch/tool.ts +4 -0
- package/src/bundled-plugins/agent-browser/shim.ts +47 -0
- package/src/bundled-plugins/backup/subagents.ts +2 -0
- package/src/bundled-plugins/memory/README.md +49 -12
- package/src/bundled-plugins/memory/citation-superset.ts +63 -0
- package/src/bundled-plugins/memory/dreaming.ts +105 -17
- package/src/bundled-plugins/memory/index.ts +2 -2
- package/src/bundled-plugins/memory/memory-logger.ts +45 -26
- package/src/bundled-plugins/memory/strength.ts +127 -0
- package/src/bundled-plugins/memory/topics.ts +75 -0
- package/src/bundled-plugins/security/index.ts +88 -43
- package/src/bundled-plugins/security/permissions.ts +36 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
- package/src/channels/adapters/github/auth-app.ts +120 -0
- package/src/channels/adapters/github/auth-pat.ts +50 -0
- package/src/channels/adapters/github/auth.ts +33 -0
- package/src/channels/adapters/github/channel-resolver.ts +30 -0
- package/src/channels/adapters/github/dedup.ts +26 -0
- package/src/channels/adapters/github/event-allowlist.ts +8 -0
- package/src/channels/adapters/github/fetch-attachment.ts +5 -0
- package/src/channels/adapters/github/history.ts +63 -0
- package/src/channels/adapters/github/inbound.ts +286 -0
- package/src/channels/adapters/github/index.ts +370 -0
- package/src/channels/adapters/github/managed-path.ts +54 -0
- package/src/channels/adapters/github/membership.ts +35 -0
- package/src/channels/adapters/github/outbound.ts +145 -0
- package/src/channels/adapters/github/webhook-register.ts +349 -0
- package/src/channels/manager.ts +94 -9
- package/src/channels/router.ts +194 -28
- package/src/channels/schema.ts +31 -1
- package/src/channels/tunnel-bridge.ts +51 -0
- package/src/channels/types.ts +3 -1
- package/src/cli/builtins.ts +28 -0
- package/src/cli/channel.ts +511 -25
- package/src/cli/container-command-client.ts +244 -0
- package/src/cli/cron.ts +173 -0
- package/src/cli/host-command-runner.ts +150 -0
- package/src/cli/index.ts +42 -1
- package/src/cli/init.ts +400 -67
- package/src/cli/model.ts +14 -4
- package/src/cli/oauth-callbacks.ts +49 -0
- package/src/cli/plugin-command-help.ts +49 -0
- package/src/cli/plugin-commands-dispatch.ts +112 -0
- package/src/cli/plugin-commands.ts +118 -0
- package/src/cli/provider.ts +3 -20
- package/src/cli/tui.ts +10 -2
- package/src/cli/tunnel.ts +533 -0
- package/src/cli/ui.ts +8 -3
- package/src/config/config.ts +134 -24
- package/src/config/models-mutation.ts +42 -8
- package/src/config/providers-mutation.ts +12 -8
- package/src/container/start.ts +48 -4
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +174 -48
- package/src/cron/index.ts +19 -2
- package/src/cron/list.ts +105 -0
- package/src/cron/scheduler.ts +12 -3
- package/src/cron/schema.ts +11 -3
- package/src/doctor/checks.ts +0 -50
- package/src/init/dockerfile.ts +165 -13
- package/src/init/ensure-deps.ts +15 -4
- package/src/init/github-webhook-install.ts +109 -0
- package/src/init/hatching.ts +2 -2
- package/src/init/index.ts +519 -12
- package/src/init/oauth-login.ts +17 -3
- package/src/init/run-bun-install.ts +17 -3
- package/src/init/run-owner-claim.ts +11 -2
- package/src/permissions/builtins.ts +29 -2
- package/src/permissions/match-rule.ts +24 -2
- package/src/permissions/permissions.ts +24 -7
- package/src/permissions/resolve.ts +1 -0
- package/src/plugin/define.ts +44 -1
- package/src/plugin/index.ts +18 -3
- package/src/plugin/manager.ts +16 -0
- package/src/plugin/registry.ts +85 -3
- package/src/plugin/types.ts +144 -1
- package/src/plugin/zod-introspect.ts +100 -0
- package/src/role-claim/match-rule.ts +2 -1
- package/src/run/index.ts +112 -4
- package/src/secrets/index.ts +1 -1
- package/src/secrets/schema.ts +21 -0
- package/src/server/command-runner.ts +476 -0
- package/src/server/index.ts +388 -5
- package/src/shared/index.ts +8 -0
- package/src/shared/protocol.ts +80 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
- package/src/skills/typeclaw-config/SKILL.md +27 -26
- package/src/skills/typeclaw-cron/SKILL.md +234 -3
- package/src/skills/typeclaw-memory/SKILL.md +25 -15
- package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
- package/src/skills/typeclaw-permissions/SKILL.md +35 -16
- package/src/skills/typeclaw-plugins/SKILL.md +251 -5
- package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
- package/src/test-helpers/wait-for.ts +50 -0
- package/src/tui/index.ts +70 -7
- package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
- package/src/tunnels/events.ts +14 -0
- package/src/tunnels/index.ts +12 -0
- package/src/tunnels/log-ring.ts +54 -0
- package/src/tunnels/manager.ts +139 -0
- package/src/tunnels/providers/cloudflare-quick.ts +189 -0
- package/src/tunnels/providers/external.ts +53 -0
- package/src/tunnels/quick-url-parser.ts +5 -0
- package/src/tunnels/types.ts +43 -0
- package/src/usage/report.ts +15 -12
- package/typeclaw.schema.json +311 -26
package/src/init/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
|
3
3
|
import { basename, dirname, join, relative, resolve } from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
|
|
6
|
+
import { DEFAULT_GITHUB_EVENT_ALLOWLIST } from '@/channels/schema'
|
|
6
7
|
import { config, configSchema, migrateLegacyConfigShape, type Config } from '@/config'
|
|
7
8
|
import {
|
|
8
9
|
DEFAULT_MODEL_REF,
|
|
@@ -18,6 +19,7 @@ import { createTui } from '@/tui'
|
|
|
18
19
|
|
|
19
20
|
import { resolveBaseImageVersion, resolveScaffoldVersion } from './cli-version'
|
|
20
21
|
import { buildDockerfile, DOCKERFILE } from './dockerfile'
|
|
22
|
+
import { installGithubWebhooksEagerly, type EagerGithubWebhookInstallResult } from './github-webhook-install'
|
|
21
23
|
import { buildGitignore, GITIGNORE_FILE } from './gitignore'
|
|
22
24
|
import { HATCHING_PROMPT } from './hatching'
|
|
23
25
|
import type { OAuthLoginRunner, OAuthLoginResult } from './oauth-login'
|
|
@@ -26,6 +28,9 @@ import { type InstallResult, type InstallRunner, runBunInstall } from './run-bun
|
|
|
26
28
|
|
|
27
29
|
export { type InstallResult, type InstallRunner, runBunInstall } from './run-bun-install'
|
|
28
30
|
|
|
31
|
+
export type { EagerGithubWebhookInstallResult } from './github-webhook-install'
|
|
32
|
+
export { formatEagerGithubWebhookInstallResult, installGithubWebhooksEagerly } from './github-webhook-install'
|
|
33
|
+
|
|
29
34
|
export { GITKEEP_FILE, PACKAGES_DIR } from './paths'
|
|
30
35
|
|
|
31
36
|
const CONFIG_FILE = 'typeclaw.json'
|
|
@@ -51,6 +56,7 @@ export type InitStep =
|
|
|
51
56
|
| 'oauth-login'
|
|
52
57
|
| 'scaffold'
|
|
53
58
|
| 'kakaotalk-auth'
|
|
59
|
+
| 'github-webhooks'
|
|
54
60
|
| 'install'
|
|
55
61
|
| 'dockerfile'
|
|
56
62
|
| 'git'
|
|
@@ -58,6 +64,20 @@ export type InitStep =
|
|
|
58
64
|
|
|
59
65
|
export type KakaotalkAuthResult = { ok: true } | { ok: false; reason: string }
|
|
60
66
|
|
|
67
|
+
// Structured credential block for the GitHub channel adapter. Mirrors the
|
|
68
|
+
// shape `runAddChannel({ channel: 'github', ... })` consumes so the wizard
|
|
69
|
+
// can hand off without re-encoding the auth union or webhook fields.
|
|
70
|
+
export type GithubInitCredentials = {
|
|
71
|
+
webhookSecret: string
|
|
72
|
+
tunnelProvider: GithubTunnelProvider
|
|
73
|
+
webhookUrl?: string
|
|
74
|
+
webhookPort?: number
|
|
75
|
+
repos: string[]
|
|
76
|
+
auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type GithubTunnelProvider = 'cloudflare-quick' | 'external' | 'none'
|
|
80
|
+
|
|
61
81
|
export type InitStepEvent =
|
|
62
82
|
| { step: 'preflight'; phase: 'start' }
|
|
63
83
|
| { step: 'preflight'; phase: 'done'; result: DockerAvailability }
|
|
@@ -67,6 +87,8 @@ export type InitStepEvent =
|
|
|
67
87
|
| { step: 'scaffold'; phase: 'done' }
|
|
68
88
|
| { step: 'kakaotalk-auth'; phase: 'start' }
|
|
69
89
|
| { step: 'kakaotalk-auth'; phase: 'done'; result: KakaotalkAuthResult }
|
|
90
|
+
| { step: 'github-webhooks'; phase: 'start' }
|
|
91
|
+
| { step: 'github-webhooks'; phase: 'done'; result: EagerGithubWebhookInstallResult }
|
|
70
92
|
| { step: 'install'; phase: 'start' }
|
|
71
93
|
| { step: 'install'; phase: 'done'; result: InstallResult }
|
|
72
94
|
| { step: 'dockerfile'; phase: 'start' }
|
|
@@ -99,7 +121,18 @@ export type KakaotalkAuthRunner = (options: { cwd: string }) => Promise<Kakaotal
|
|
|
99
121
|
// API-key provider". Optional model defaults to DEFAULT_MODEL_REF, which is
|
|
100
122
|
// an OpenAI api-key provider — so test fixtures that omit both fields keep
|
|
101
123
|
// working under the api-key path.
|
|
102
|
-
|
|
124
|
+
//
|
|
125
|
+
// `oauth-completed` is the CLI wizard's signal that the browser login already
|
|
126
|
+
// happened up-front (right after the user picked the auth method) and the
|
|
127
|
+
// resulting credentials are already in `secrets.json`. `runInit` then skips
|
|
128
|
+
// the `oauth-login` step but still treats this as an OAuth provider (no API
|
|
129
|
+
// key written, etc.). The wizard runs OAuth eagerly so the browser opens the
|
|
130
|
+
// moment the user picks "OAuth (browser login)" instead of waiting until the
|
|
131
|
+
// end of the wizard — see `collectWizardInputs` in `src/cli/init.ts`.
|
|
132
|
+
export type LLMAuth =
|
|
133
|
+
| { kind: 'api-key'; apiKey: string }
|
|
134
|
+
| { kind: 'oauth'; runLogin: OAuthLoginRunner }
|
|
135
|
+
| { kind: 'oauth-completed' }
|
|
103
136
|
|
|
104
137
|
export type InitOptions = {
|
|
105
138
|
cwd: string
|
|
@@ -131,7 +164,16 @@ export type InitOptions = {
|
|
|
131
164
|
withSlack?: boolean
|
|
132
165
|
withTelegram?: boolean
|
|
133
166
|
withKakaotalk?: boolean
|
|
167
|
+
withGithub?: boolean
|
|
134
168
|
runKakaotalkAuth?: KakaotalkAuthRunner
|
|
169
|
+
// Structured GitHub credentials collected by the wizard. When omitted and
|
|
170
|
+
// `withGithub` is true, the existing secrets.json#channels.github block is
|
|
171
|
+
// reused as-is (the wizard's "reuse existing credentials" path).
|
|
172
|
+
githubCredentials?: GithubInitCredentials
|
|
173
|
+
// Test seam for the eager-webhook-install path that fires after the github
|
|
174
|
+
// channel block is written. Production callers leave this undefined so the
|
|
175
|
+
// global `fetch` is used.
|
|
176
|
+
githubFetchImpl?: typeof fetch
|
|
135
177
|
onProgress?: (event: InitStepEvent) => void
|
|
136
178
|
runHatching?: HatchRunner
|
|
137
179
|
runBunInstall?: InstallRunner
|
|
@@ -159,7 +201,10 @@ export async function runInit({
|
|
|
159
201
|
withSlack,
|
|
160
202
|
withTelegram,
|
|
161
203
|
withKakaotalk = false,
|
|
204
|
+
withGithub = false,
|
|
162
205
|
runKakaotalkAuth,
|
|
206
|
+
githubCredentials,
|
|
207
|
+
githubFetchImpl,
|
|
163
208
|
onProgress,
|
|
164
209
|
runHatching = defaultRunHatching,
|
|
165
210
|
runBunInstall: installRunner = runBunInstall,
|
|
@@ -189,8 +234,8 @@ export async function runInit({
|
|
|
189
234
|
// Same trap as kakaotalk-auth: scaffold-then-fail-auth would leave
|
|
190
235
|
// typeclaw.json without working credentials and the runtime would silently
|
|
191
236
|
// refuse to boot. The login itself doesn't need the agent folder to exist
|
|
192
|
-
// — pi-ai's OAuth helper just needs a writable path for secrets.json,
|
|
193
|
-
//
|
|
237
|
+
// — pi-ai's OAuth helper just needs a writable path for secrets.json, and
|
|
238
|
+
// the `mkdir` below creates it on demand before the login runs.
|
|
194
239
|
if (resolvedAuth.kind === 'oauth') {
|
|
195
240
|
emit({ step: 'oauth-login', phase: 'start' })
|
|
196
241
|
await mkdir(cwd, { recursive: true })
|
|
@@ -263,6 +308,30 @@ export async function runInit({
|
|
|
263
308
|
}
|
|
264
309
|
}
|
|
265
310
|
|
|
311
|
+
// Write the structured github channel block alongside scaffold's bot-token
|
|
312
|
+
// blocks. We do NOT delegate to runAddChannel because that's the `channel
|
|
313
|
+
// add` semantics — strict no-overwrite, throws when secrets.json#channels
|
|
314
|
+
// .github already exists. Init is a different contract: re-running it
|
|
315
|
+
// regenerates config from the wizard's current inputs (scaffold() already
|
|
316
|
+
// overwrites typeclaw.json wholesale on every run), so failing on an
|
|
317
|
+
// existing secret block here would brick the re-init recovery path the
|
|
318
|
+
// bot-token adapters all support.
|
|
319
|
+
if (withGithub && githubCredentials !== undefined) {
|
|
320
|
+
await writeGithubChannelForInit(cwd, githubCredentials)
|
|
321
|
+
if (githubCredentials.webhookUrl !== undefined && githubCredentials.repos.length > 0) {
|
|
322
|
+
emit({ step: 'github-webhooks', phase: 'start' })
|
|
323
|
+
const result = await installGithubWebhooksEagerly({
|
|
324
|
+
webhookUrl: githubCredentials.webhookUrl,
|
|
325
|
+
webhookSecret: githubCredentials.webhookSecret,
|
|
326
|
+
repos: githubCredentials.repos,
|
|
327
|
+
auth: githubCredentials.auth,
|
|
328
|
+
agentDir: cwd,
|
|
329
|
+
...(githubFetchImpl !== undefined ? { fetchImpl: githubFetchImpl } : {}),
|
|
330
|
+
})
|
|
331
|
+
emit({ step: 'github-webhooks', phase: 'done', result })
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
266
335
|
emit({ step: 'install', phase: 'start' })
|
|
267
336
|
const install = await installRunner(cwd)
|
|
268
337
|
emit({ step: 'install', phase: 'done', result: install })
|
|
@@ -280,6 +349,7 @@ export async function runInit({
|
|
|
280
349
|
if (wantsSlack) configuredChannels.push('slack-bot')
|
|
281
350
|
if (wantsTelegram) configuredChannels.push('telegram-bot')
|
|
282
351
|
if (withKakaotalk) configuredChannels.push('kakaotalk')
|
|
352
|
+
if (withGithub) configuredChannels.push('github')
|
|
283
353
|
|
|
284
354
|
emit({ step: 'hatching', phase: 'start' })
|
|
285
355
|
const hatching = await runHatching({
|
|
@@ -721,7 +791,7 @@ export async function readExistingProviderApiKey(root: string, providerId: Known
|
|
|
721
791
|
// kakaotalk` anyway — better to re-auth now during init.
|
|
722
792
|
export async function hasExistingChannelSecrets(
|
|
723
793
|
root: string,
|
|
724
|
-
channel: 'discord' | 'slack' | 'telegram' | 'kakaotalk',
|
|
794
|
+
channel: 'discord' | 'slack' | 'telegram' | 'kakaotalk' | 'github',
|
|
725
795
|
): Promise<boolean> {
|
|
726
796
|
const channels = new SecretsBackend(join(root, 'secrets.json')).tryReadChannelsSync()
|
|
727
797
|
if (channels === null) return false
|
|
@@ -732,6 +802,15 @@ export async function hasExistingChannelSecrets(
|
|
|
732
802
|
return hasSecretField(channels['slack-bot'], 'botToken') && hasSecretField(channels['slack-bot'], 'appToken')
|
|
733
803
|
case 'telegram':
|
|
734
804
|
return hasSecretField(channels['telegram-bot'], 'token')
|
|
805
|
+
case 'github':
|
|
806
|
+
// GitHub credentials alone are not enough to scaffold a working
|
|
807
|
+
// channel: typeclaw.json#channels.github also needs webhookUrl and
|
|
808
|
+
// webhookPort, which only the user can supply. Always force a fresh
|
|
809
|
+
// prompt in the wizard so those fields end up in typeclaw.json. The
|
|
810
|
+
// existing `secrets.json#channels.github` (if any) is detected and
|
|
811
|
+
// surfaced as a hard error inside `runAddChannel` to prevent silent
|
|
812
|
+
// overwrites.
|
|
813
|
+
return false
|
|
735
814
|
case 'kakaotalk': {
|
|
736
815
|
const block = channels.kakaotalk
|
|
737
816
|
if (!isObjectRecord(block)) return false
|
|
@@ -802,15 +881,21 @@ function ignoreExists(error: NodeJS.ErrnoException): void {
|
|
|
802
881
|
// scaffold-test cases above demonstrates how easy it is to lose a single
|
|
803
882
|
// behavior under a mode flag.
|
|
804
883
|
|
|
805
|
-
export type ChannelKind = 'discord-bot' | 'slack-bot' | 'telegram-bot' | 'kakaotalk'
|
|
884
|
+
export type ChannelKind = 'discord-bot' | 'slack-bot' | 'telegram-bot' | 'kakaotalk' | 'github'
|
|
806
885
|
|
|
807
886
|
// Public adapter names match the typeclaw.json `channels.*` keys exactly.
|
|
808
887
|
// The CLI takes these as the optional positional arg, the picker shows
|
|
809
888
|
// these labels, and they're the keys we use to detect "already configured"
|
|
810
889
|
// when reading typeclaw.json.
|
|
811
|
-
export const CHANNEL_KINDS: ReadonlyArray<ChannelKind> = [
|
|
890
|
+
export const CHANNEL_KINDS: ReadonlyArray<ChannelKind> = [
|
|
891
|
+
'slack-bot',
|
|
892
|
+
'discord-bot',
|
|
893
|
+
'telegram-bot',
|
|
894
|
+
'kakaotalk',
|
|
895
|
+
'github',
|
|
896
|
+
]
|
|
812
897
|
|
|
813
|
-
export type AddChannelStep = 'kakaotalk-auth' | 'config' | 'secrets'
|
|
898
|
+
export type AddChannelStep = 'kakaotalk-auth' | 'config' | 'secrets' | 'github-webhooks'
|
|
814
899
|
|
|
815
900
|
export type AddChannelStepEvent =
|
|
816
901
|
| { step: 'config'; phase: 'start' }
|
|
@@ -819,6 +904,8 @@ export type AddChannelStepEvent =
|
|
|
819
904
|
| { step: 'kakaotalk-auth'; phase: 'done'; result: KakaotalkAuthResult }
|
|
820
905
|
| { step: 'secrets'; phase: 'start' }
|
|
821
906
|
| { step: 'secrets'; phase: 'done' }
|
|
907
|
+
| { step: 'github-webhooks'; phase: 'start' }
|
|
908
|
+
| { step: 'github-webhooks'; phase: 'done'; result: EagerGithubWebhookInstallResult }
|
|
822
909
|
|
|
823
910
|
// Discriminated union per channel so the type system enforces "you must pass
|
|
824
911
|
// the right credentials for the channel you're adding". The CLI builds these
|
|
@@ -831,6 +918,16 @@ export type AddChannelOptions = {
|
|
|
831
918
|
| { channel: 'slack-bot'; slackBotToken: string; slackAppToken: string }
|
|
832
919
|
| { channel: 'telegram-bot'; telegramBotToken: string }
|
|
833
920
|
| { channel: 'kakaotalk'; runKakaotalkAuth: KakaotalkAuthRunner }
|
|
921
|
+
| {
|
|
922
|
+
channel: 'github'
|
|
923
|
+
webhookSecret: string
|
|
924
|
+
tunnelProvider: GithubTunnelProvider
|
|
925
|
+
webhookUrl?: string
|
|
926
|
+
webhookPort?: number
|
|
927
|
+
repos: string[]
|
|
928
|
+
auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
|
|
929
|
+
fetchImpl?: typeof fetch
|
|
930
|
+
}
|
|
834
931
|
)
|
|
835
932
|
|
|
836
933
|
export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
@@ -852,7 +949,7 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
|
852
949
|
}
|
|
853
950
|
|
|
854
951
|
emit({ step: 'config', phase: 'start' })
|
|
855
|
-
await mergeChannelIntoConfig(options.cwd, options
|
|
952
|
+
await mergeChannelIntoConfig(options.cwd, options)
|
|
856
953
|
emit({ step: 'config', phase: 'done' })
|
|
857
954
|
|
|
858
955
|
emit({ step: 'secrets', phase: 'start' })
|
|
@@ -860,8 +957,16 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
|
860
957
|
if (Object.keys(tokens).length > 0) {
|
|
861
958
|
await appendChannelSecrets(options.cwd, options.channel, tokens)
|
|
862
959
|
}
|
|
960
|
+
if (options.channel === 'github') {
|
|
961
|
+
await appendGithubSecrets(options.cwd, options)
|
|
962
|
+
}
|
|
863
963
|
emit({ step: 'secrets', phase: 'done' })
|
|
864
964
|
|
|
965
|
+
if (options.channel === 'github') {
|
|
966
|
+
await appendGithubMatchRules(options.cwd, options.repos)
|
|
967
|
+
await maybeInstallGithubWebhooks(options, emit)
|
|
968
|
+
}
|
|
969
|
+
|
|
865
970
|
// Commit the typeclaw.json change so the agent folder isn't silently
|
|
866
971
|
// dirty after `typeclaw channel add`. Same `commitSystemFile` contract as
|
|
867
972
|
// every other host-side rewrite: no-op outside a git repo, when Bun is
|
|
@@ -870,6 +975,30 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
|
870
975
|
await commitSystemFile(options.cwd, CONFIG_FILE, `channel: add ${options.channel}`)
|
|
871
976
|
}
|
|
872
977
|
|
|
978
|
+
// Eager webhook registration is best-effort: a failure here MUST NOT roll
|
|
979
|
+
// back the typeclaw.json / secrets.json writes. The container-side adapter
|
|
980
|
+
// re-runs registration on every start, so a missing PAT scope or a transient
|
|
981
|
+
// 5xx today gets retried automatically on the next `typeclaw start`. We
|
|
982
|
+
// surface the per-repo outcome as a structured event so the CLI can render
|
|
983
|
+
// it, but we never throw.
|
|
984
|
+
async function maybeInstallGithubWebhooks(
|
|
985
|
+
options: Extract<AddChannelOptions, { channel: 'github' }>,
|
|
986
|
+
emit: (event: AddChannelStepEvent) => void,
|
|
987
|
+
): Promise<void> {
|
|
988
|
+
if (options.webhookUrl === undefined) return
|
|
989
|
+
if (options.repos.length === 0) return
|
|
990
|
+
emit({ step: 'github-webhooks', phase: 'start' })
|
|
991
|
+
const result = await installGithubWebhooksEagerly({
|
|
992
|
+
webhookUrl: options.webhookUrl,
|
|
993
|
+
webhookSecret: options.webhookSecret,
|
|
994
|
+
repos: options.repos,
|
|
995
|
+
auth: options.auth,
|
|
996
|
+
agentDir: options.cwd,
|
|
997
|
+
...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}),
|
|
998
|
+
})
|
|
999
|
+
emit({ step: 'github-webhooks', phase: 'done', result })
|
|
1000
|
+
}
|
|
1001
|
+
|
|
873
1002
|
function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
|
|
874
1003
|
switch (options.channel) {
|
|
875
1004
|
case 'discord-bot':
|
|
@@ -882,6 +1011,9 @@ function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
|
|
|
882
1011
|
// KakaoTalk auth writes its structured multi-account block directly to
|
|
883
1012
|
// secrets.json#channels.kakaotalk before config mutation.
|
|
884
1013
|
return {}
|
|
1014
|
+
case 'github':
|
|
1015
|
+
// GitHub stores a structured PAT + webhook secret block directly.
|
|
1016
|
+
return {}
|
|
885
1017
|
}
|
|
886
1018
|
}
|
|
887
1019
|
|
|
@@ -908,7 +1040,7 @@ export async function readConfiguredChannels(cwd: string): Promise<Set<ChannelKi
|
|
|
908
1040
|
return present
|
|
909
1041
|
}
|
|
910
1042
|
|
|
911
|
-
async function mergeChannelIntoConfig(cwd: string,
|
|
1043
|
+
async function mergeChannelIntoConfig(cwd: string, options: AddChannelOptions): Promise<void> {
|
|
912
1044
|
const path = join(cwd, CONFIG_FILE)
|
|
913
1045
|
let parsed: Record<string, unknown>
|
|
914
1046
|
try {
|
|
@@ -928,19 +1060,152 @@ async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind): Promis
|
|
|
928
1060
|
? (parsed.channels as Record<string, unknown>)
|
|
929
1061
|
: {}
|
|
930
1062
|
|
|
931
|
-
if (channel in existingChannels) {
|
|
1063
|
+
if (options.channel in existingChannels) {
|
|
932
1064
|
// Defense in depth — the CLI already filters configured channels out of
|
|
933
1065
|
// the picker and rejects them as the positional arg. Hitting this branch
|
|
934
1066
|
// means a programmatic caller passed a duplicate; better to fail loudly
|
|
935
1067
|
// than silently overwrite the user's existing config.
|
|
936
|
-
throw new Error(`Channel "${channel}" is already configured in ${CONFIG_FILE}.`)
|
|
1068
|
+
throw new Error(`Channel "${options.channel}" is already configured in ${CONFIG_FILE}.`)
|
|
937
1069
|
}
|
|
938
1070
|
|
|
1071
|
+
const nextChannelConfig = options.channel === 'github' ? buildGithubChannelConfig(options) : {}
|
|
1072
|
+
|
|
939
1073
|
parsed.channels = {
|
|
940
1074
|
...existingChannels,
|
|
941
|
-
[channel]:
|
|
1075
|
+
[options.channel]: nextChannelConfig,
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
if (options.channel === 'github') mergeGithubTunnelConfig(parsed, options)
|
|
1079
|
+
|
|
1080
|
+
await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function buildGithubChannelConfig(options: Extract<AddChannelOptions, { channel: 'github' }>): Record<string, unknown> {
|
|
1084
|
+
return {
|
|
1085
|
+
...(options.webhookUrl !== undefined ? { webhookUrl: options.webhookUrl } : {}),
|
|
1086
|
+
webhookPort: options.webhookPort ?? 8975,
|
|
1087
|
+
eventAllowlist: [...DEFAULT_GITHUB_EVENT_ALLOWLIST],
|
|
1088
|
+
repos: options.repos,
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function mergeGithubTunnelConfig(
|
|
1093
|
+
parsed: Record<string, unknown>,
|
|
1094
|
+
options: Extract<AddChannelOptions, { channel: 'github' }>,
|
|
1095
|
+
): void {
|
|
1096
|
+
if (options.tunnelProvider === 'none') return
|
|
1097
|
+
if (options.tunnelProvider === 'external' && options.webhookUrl === undefined) {
|
|
1098
|
+
throw new Error('GitHub external tunnel requires webhookUrl')
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const existingTunnels = Array.isArray(parsed.tunnels) ? parsed.tunnels : []
|
|
1102
|
+
const tunnel =
|
|
1103
|
+
options.tunnelProvider === 'external'
|
|
1104
|
+
? {
|
|
1105
|
+
name: 'github-webhook',
|
|
1106
|
+
provider: 'external',
|
|
1107
|
+
externalUrl: options.webhookUrl,
|
|
1108
|
+
for: { kind: 'channel', name: 'github' },
|
|
1109
|
+
}
|
|
1110
|
+
: {
|
|
1111
|
+
name: 'github-webhook',
|
|
1112
|
+
provider: 'cloudflare-quick',
|
|
1113
|
+
for: { kind: 'channel', name: 'github' },
|
|
1114
|
+
}
|
|
1115
|
+
parsed.tunnels = [...existingTunnels, tunnel]
|
|
1116
|
+
|
|
1117
|
+
if (options.tunnelProvider === 'cloudflare-quick') {
|
|
1118
|
+
const docker = isObjectRecord(parsed.docker) ? { ...parsed.docker } : {}
|
|
1119
|
+
const file = isObjectRecord(docker.file) ? { ...docker.file } : {}
|
|
1120
|
+
file.cloudflared = true
|
|
1121
|
+
docker.file = file
|
|
1122
|
+
parsed.docker = docker
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Init-side counterpart of runAddChannel's github branch. Same three writes
|
|
1127
|
+
// (typeclaw.json#channels.github, secrets.json#channels.github, roles.member
|
|
1128
|
+
// .match[]) but with overwrite semantics on the secrets/config side so a
|
|
1129
|
+
// re-run of `typeclaw init` after a partial failure works the same way it
|
|
1130
|
+
// does for the bot-token adapters. The match-rule writer is reused as-is
|
|
1131
|
+
// because its set-union is already idempotent.
|
|
1132
|
+
async function writeGithubChannelForInit(cwd: string, credentials: GithubInitCredentials): Promise<void> {
|
|
1133
|
+
const configPath = join(cwd, CONFIG_FILE)
|
|
1134
|
+
const parsed = JSON.parse(await readFile(configPath, 'utf8')) as Record<string, unknown>
|
|
1135
|
+
const existingChannels = isObjectRecord(parsed.channels) ? { ...parsed.channels } : {}
|
|
1136
|
+
existingChannels.github = {
|
|
1137
|
+
...(credentials.webhookUrl !== undefined ? { webhookUrl: credentials.webhookUrl } : {}),
|
|
1138
|
+
webhookPort: credentials.webhookPort ?? 8975,
|
|
1139
|
+
eventAllowlist: [...DEFAULT_GITHUB_EVENT_ALLOWLIST],
|
|
1140
|
+
repos: credentials.repos,
|
|
942
1141
|
}
|
|
1142
|
+
parsed.channels = existingChannels
|
|
1143
|
+
mergeGithubTunnelConfig(parsed, { channel: 'github', ...credentials, cwd })
|
|
1144
|
+
await writeFile(configPath, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
943
1145
|
|
|
1146
|
+
const backend = new SecretsBackend(join(cwd, 'secrets.json'))
|
|
1147
|
+
const channels: Record<string, unknown> = backend.readChannelsSync()
|
|
1148
|
+
channels.github = {
|
|
1149
|
+
auth:
|
|
1150
|
+
credentials.auth.type === 'pat'
|
|
1151
|
+
? { type: 'pat', token: { value: credentials.auth.pat } satisfies Secret }
|
|
1152
|
+
: {
|
|
1153
|
+
type: 'app',
|
|
1154
|
+
appId: credentials.auth.appId,
|
|
1155
|
+
privateKey: { value: credentials.auth.privateKey } satisfies Secret,
|
|
1156
|
+
...(credentials.auth.installationId !== undefined
|
|
1157
|
+
? { installationId: credentials.auth.installationId }
|
|
1158
|
+
: {}),
|
|
1159
|
+
},
|
|
1160
|
+
webhookSecret: { value: credentials.webhookSecret } satisfies Secret,
|
|
1161
|
+
}
|
|
1162
|
+
backend.writeChannelsSync(channels as Channels)
|
|
1163
|
+
|
|
1164
|
+
await appendGithubMatchRules(cwd, credentials.repos)
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
async function appendGithubSecrets(
|
|
1168
|
+
cwd: string,
|
|
1169
|
+
options: Extract<AddChannelOptions, { channel: 'github' }>,
|
|
1170
|
+
): Promise<void> {
|
|
1171
|
+
if (!existsSync(join(cwd, CONFIG_FILE))) {
|
|
1172
|
+
throw new Error(
|
|
1173
|
+
`${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` before adding channels, or run this command from inside an agent folder.`,
|
|
1174
|
+
)
|
|
1175
|
+
}
|
|
1176
|
+
const backend = new SecretsBackend(join(cwd, 'secrets.json'))
|
|
1177
|
+
const channels: Record<string, unknown> = backend.readChannelsSync()
|
|
1178
|
+
if (channels.github !== undefined) {
|
|
1179
|
+
throw new Error(
|
|
1180
|
+
'github is already set in secrets.json. Remove it before re-adding the channel, or edit it by hand.',
|
|
1181
|
+
)
|
|
1182
|
+
}
|
|
1183
|
+
channels.github = {
|
|
1184
|
+
auth:
|
|
1185
|
+
options.auth.type === 'pat'
|
|
1186
|
+
? { type: 'pat', token: { value: options.auth.pat } satisfies Secret }
|
|
1187
|
+
: {
|
|
1188
|
+
type: 'app',
|
|
1189
|
+
appId: options.auth.appId,
|
|
1190
|
+
privateKey: { value: options.auth.privateKey } satisfies Secret,
|
|
1191
|
+
...(options.auth.installationId !== undefined ? { installationId: options.auth.installationId } : {}),
|
|
1192
|
+
},
|
|
1193
|
+
webhookSecret: { value: options.webhookSecret } satisfies Secret,
|
|
1194
|
+
}
|
|
1195
|
+
backend.writeChannelsSync(channels as Channels)
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
async function appendGithubMatchRules(cwd: string, repos: readonly string[]): Promise<void> {
|
|
1199
|
+
const path = join(cwd, CONFIG_FILE)
|
|
1200
|
+
const parsed = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
|
|
1201
|
+
const roles = isObjectRecord(parsed.roles) ? { ...parsed.roles } : {}
|
|
1202
|
+
const member = isObjectRecord(roles.member) ? { ...roles.member } : {}
|
|
1203
|
+
const existing = Array.isArray(member.match) ? member.match.filter((v): v is string => typeof v === 'string') : []
|
|
1204
|
+
const merged = new Set(existing)
|
|
1205
|
+
for (const repo of repos) merged.add(`github:${repo}`)
|
|
1206
|
+
member.match = Array.from(merged)
|
|
1207
|
+
roles.member = member
|
|
1208
|
+
parsed.roles = roles
|
|
944
1209
|
await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
945
1210
|
}
|
|
946
1211
|
|
|
@@ -976,3 +1241,245 @@ async function appendChannelSecrets(cwd: string, channel: ChannelKind, tokens: C
|
|
|
976
1241
|
channels[channel] = slot
|
|
977
1242
|
backend.writeChannelsSync(channels as Channels)
|
|
978
1243
|
}
|
|
1244
|
+
|
|
1245
|
+
// ----------------------------------------------------------------------------
|
|
1246
|
+
// `typeclaw channel set`
|
|
1247
|
+
//
|
|
1248
|
+
// Rotate credentials of an already-configured channel. Symmetric with
|
|
1249
|
+
// `typeclaw provider set` (see `setProvider` in src/config/providers-mutation.ts):
|
|
1250
|
+
// `add` is "add for the first time, refuse if already present", `set` is
|
|
1251
|
+
// "rotate the value, refuse if NOT yet present". Two separate verbs keep the
|
|
1252
|
+
// "add by mistake" footgun and the "rotate by mistake" footgun on opposite
|
|
1253
|
+
// sides of the CLI namespace.
|
|
1254
|
+
//
|
|
1255
|
+
// Per the env-wins / file-never-auto-mutated rule in AGENTS.md#secrets, these
|
|
1256
|
+
// helpers only touch the fields the user explicitly asked to rotate. Any
|
|
1257
|
+
// untouched field — including a sibling field bound to a custom env var —
|
|
1258
|
+
// keeps its existing Secret envelope verbatim.
|
|
1259
|
+
//
|
|
1260
|
+
// Kakaotalk has its own auth flow (encryption envelope + device_uuid + phone
|
|
1261
|
+
// passcode) and is rotated via `typeclaw channel reauth kakaotalk`, NOT via
|
|
1262
|
+
// these helpers. Trying to set('kakaotalk') would bypass the encryption
|
|
1263
|
+
// bridge and corrupt the per-account block; the CLI layer rejects it before
|
|
1264
|
+
// reaching here.
|
|
1265
|
+
|
|
1266
|
+
export type SetChannelTokensResult = { ok: true } | { ok: false; reason: string }
|
|
1267
|
+
|
|
1268
|
+
type BotTokenAdapter = 'discord-bot' | 'slack-bot' | 'telegram-bot'
|
|
1269
|
+
|
|
1270
|
+
// Required credential fields per adapter. Used post-merge to refuse a
|
|
1271
|
+
// rotation that would leave the adapter half-configured — e.g. rotating
|
|
1272
|
+
// only `botToken` when the on-disk slot was `{}` would silently leave
|
|
1273
|
+
// `appToken` missing and the Slack adapter would fail to start. Listing
|
|
1274
|
+
// the contract explicitly here keeps it close to the writer.
|
|
1275
|
+
const REQUIRED_CHANNEL_FIELDS: Record<BotTokenAdapter, readonly string[]> = {
|
|
1276
|
+
'discord-bot': ['token'],
|
|
1277
|
+
'slack-bot': ['botToken', 'appToken'],
|
|
1278
|
+
'telegram-bot': ['token'],
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// Preserve a user-authored `{ env: 'CUSTOM_NAME' }` rebinding when rotating
|
|
1282
|
+
// the value behind it. Mirrors `buildSecret` in providers-mutation.ts for
|
|
1283
|
+
// the case where the prior credential had an explicit env-name binding —
|
|
1284
|
+
// the rotated value is written as `{ value, env }` so env-wins still works
|
|
1285
|
+
// at runtime. Without this, every `channel set` would silently strip the
|
|
1286
|
+
// rebinding and `process.env[<custom>]` would no longer override.
|
|
1287
|
+
function rotatedSecret(previous: unknown, value: string): Secret {
|
|
1288
|
+
if (isObjectRecord(previous)) {
|
|
1289
|
+
const env = (previous as { env?: unknown }).env
|
|
1290
|
+
if (typeof env === 'string' && env.length > 0) {
|
|
1291
|
+
return { value, env }
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
return { value }
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// Rotate one or more credential fields on an already-configured bot-token
|
|
1298
|
+
// adapter (discord-bot, slack-bot, telegram-bot). Refuses when the adapter
|
|
1299
|
+
// has no existing entry in secrets.json — callers must use `runAddChannel`
|
|
1300
|
+
// for first-time setup, so a typo in the adapter name can't silently create
|
|
1301
|
+
// a half-configured channel. Also refuses when the rotation would leave any
|
|
1302
|
+
// required field for the adapter unset (e.g. rotating only Slack's
|
|
1303
|
+
// `botToken` when `appToken` is missing from disk).
|
|
1304
|
+
export async function setChannelSecrets(
|
|
1305
|
+
cwd: string,
|
|
1306
|
+
channel: BotTokenAdapter,
|
|
1307
|
+
tokens: ChannelSecrets,
|
|
1308
|
+
): Promise<SetChannelTokensResult> {
|
|
1309
|
+
if (!existsSync(join(cwd, CONFIG_FILE))) {
|
|
1310
|
+
return {
|
|
1311
|
+
ok: false,
|
|
1312
|
+
reason: `${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` before rotating channel credentials, or run this command from inside an agent folder.`,
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
if (Object.keys(tokens).length === 0) return { ok: true }
|
|
1317
|
+
|
|
1318
|
+
return await runChannelMutation(cwd, async (current) => {
|
|
1319
|
+
const existingSlot = current[channel]
|
|
1320
|
+
if (!isObjectRecord(existingSlot)) {
|
|
1321
|
+
return {
|
|
1322
|
+
result: {
|
|
1323
|
+
ok: false,
|
|
1324
|
+
reason: `${channel} is not configured in secrets.json. Run \`typeclaw channel add ${channel}\` first.`,
|
|
1325
|
+
},
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
const slot: Record<string, unknown> = { ...existingSlot }
|
|
1329
|
+
for (const [k, v] of Object.entries(tokens)) {
|
|
1330
|
+
slot[k] = rotatedSecret(existingSlot[k], v)
|
|
1331
|
+
}
|
|
1332
|
+
const missing = REQUIRED_CHANNEL_FIELDS[channel].filter((field) => !isSecretFieldSet(slot[field]))
|
|
1333
|
+
if (missing.length > 0) {
|
|
1334
|
+
return {
|
|
1335
|
+
result: {
|
|
1336
|
+
ok: false,
|
|
1337
|
+
reason: `${channel} would be left half-configured after this rotation: missing required field(s) ${missing.join(', ')} in secrets.json. Run \`typeclaw channel add ${channel}\` to re-add the channel, or fix secrets.json by hand.`,
|
|
1338
|
+
},
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
const next: Record<string, unknown> = { ...current, [channel]: slot }
|
|
1342
|
+
return { result: { ok: true }, next }
|
|
1343
|
+
})
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// Discriminated union of what GitHub credentials the user wants to rotate.
|
|
1347
|
+
// The three secrets (PAT/private-key, webhook secret) rotate independently,
|
|
1348
|
+
// so the CLI lets the user pick which one(s) to touch in a single call.
|
|
1349
|
+
// `auth.type` must match the existing on-disk auth type — flipping between
|
|
1350
|
+
// PAT and App auth is a structural change, not a credential rotation, and
|
|
1351
|
+
// belongs in a future `channel migrate-auth` or hand-edit of secrets.json.
|
|
1352
|
+
export type GithubCredentialPatch = {
|
|
1353
|
+
webhookSecret?: string
|
|
1354
|
+
auth?: { type: 'pat'; pat: string } | { type: 'app'; privateKey: string; appId?: number; installationId?: number }
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// Rotate one or more credential fields on an already-configured GitHub
|
|
1358
|
+
// channel. Like setChannelSecrets, refuses when secrets.json has no
|
|
1359
|
+
// existing github entry. Additionally refuses when the requested auth.type
|
|
1360
|
+
// doesn't match the on-disk type — see `GithubCredentialPatch` above.
|
|
1361
|
+
export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch): Promise<SetChannelTokensResult> {
|
|
1362
|
+
if (!existsSync(join(cwd, CONFIG_FILE))) {
|
|
1363
|
+
return {
|
|
1364
|
+
ok: false,
|
|
1365
|
+
reason: `${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` before rotating channel credentials, or run this command from inside an agent folder.`,
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
if (patch.webhookSecret === undefined && patch.auth === undefined) return { ok: true }
|
|
1370
|
+
|
|
1371
|
+
return await runChannelMutation(cwd, async (current) => {
|
|
1372
|
+
const existing = current.github
|
|
1373
|
+
if (!isObjectRecord(existing)) {
|
|
1374
|
+
return {
|
|
1375
|
+
result: {
|
|
1376
|
+
ok: false,
|
|
1377
|
+
reason: 'github is not configured in secrets.json. Run `typeclaw channel add github` first.',
|
|
1378
|
+
},
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
const block: Record<string, unknown> = { ...existing }
|
|
1382
|
+
|
|
1383
|
+
if (patch.auth !== undefined) {
|
|
1384
|
+
const existingAuth = block.auth
|
|
1385
|
+
const existingAuthType = readGithubAuthTypeFromObject(existingAuth)
|
|
1386
|
+
if (existingAuthType !== patch.auth.type) {
|
|
1387
|
+
return {
|
|
1388
|
+
result: {
|
|
1389
|
+
ok: false,
|
|
1390
|
+
reason: `github auth type mismatch: secrets.json currently uses "${existingAuthType ?? 'unknown'}" auth, but you tried to rotate a "${patch.auth.type}" credential. Edit secrets.json by hand to migrate between PAT and App auth.`,
|
|
1391
|
+
},
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
if (patch.auth.type === 'pat') {
|
|
1395
|
+
const previousToken = isObjectRecord(existingAuth) ? (existingAuth as { token?: unknown }).token : undefined
|
|
1396
|
+
block.auth = { type: 'pat', token: rotatedSecret(previousToken, patch.auth.pat) }
|
|
1397
|
+
} else {
|
|
1398
|
+
const existingApp = isObjectRecord(existingAuth) ? (existingAuth as Record<string, unknown>) : {}
|
|
1399
|
+
const appId = patch.auth.appId ?? (existingApp.appId as number | undefined)
|
|
1400
|
+
const installationId = patch.auth.installationId ?? (existingApp.installationId as number | undefined)
|
|
1401
|
+
if (typeof appId !== 'number') {
|
|
1402
|
+
return {
|
|
1403
|
+
result: {
|
|
1404
|
+
ok: false,
|
|
1405
|
+
reason:
|
|
1406
|
+
'github App auth requires appId, but it is missing from secrets.json. Re-run `typeclaw channel add github` to re-establish the App auth block.',
|
|
1407
|
+
},
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
block.auth = {
|
|
1411
|
+
type: 'app',
|
|
1412
|
+
appId,
|
|
1413
|
+
privateKey: rotatedSecret(existingApp.privateKey, patch.auth.privateKey),
|
|
1414
|
+
...(installationId !== undefined ? { installationId } : {}),
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
if (patch.webhookSecret !== undefined) {
|
|
1420
|
+
block.webhookSecret = rotatedSecret(block.webhookSecret, patch.webhookSecret)
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const next: Record<string, unknown> = { ...current, github: block }
|
|
1424
|
+
return { result: { ok: true }, next }
|
|
1425
|
+
})
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// Wrapper that converts a thrown schema-validation error (from a hand-edited
|
|
1429
|
+
// malformed secrets.json) into a structured `{ ok: false }` result, so CLI
|
|
1430
|
+
// callers can render a clean message instead of a stack trace. The
|
|
1431
|
+
// `updateChannelsAsync` path itself is atomic; this wrapper only catches the
|
|
1432
|
+
// READ stage (envelope parse) failures.
|
|
1433
|
+
async function runChannelMutation(
|
|
1434
|
+
cwd: string,
|
|
1435
|
+
fn: (current: Record<string, unknown>) => Promise<{ result: SetChannelTokensResult; next?: Record<string, unknown> }>,
|
|
1436
|
+
): Promise<SetChannelTokensResult> {
|
|
1437
|
+
const backend = new SecretsBackend(join(cwd, 'secrets.json'))
|
|
1438
|
+
try {
|
|
1439
|
+
return await backend.updateChannelsAsync<SetChannelTokensResult>(fn)
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
return {
|
|
1442
|
+
ok: false,
|
|
1443
|
+
reason: `secrets.json is malformed: ${err instanceof Error ? err.message : String(err)}. Fix it by hand, then retry.`,
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function isSecretFieldSet(slot: unknown): boolean {
|
|
1449
|
+
if (typeof slot === 'string') return slot.length > 0
|
|
1450
|
+
if (isObjectRecord(slot)) {
|
|
1451
|
+
const value = (slot as { value?: unknown }).value
|
|
1452
|
+
if (typeof value === 'string' && value.length > 0) return true
|
|
1453
|
+
const env = (slot as { env?: unknown }).env
|
|
1454
|
+
if (typeof env === 'string' && env.length > 0) return true
|
|
1455
|
+
}
|
|
1456
|
+
return false
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function readGithubAuthTypeFromObject(auth: unknown): 'pat' | 'app' | undefined {
|
|
1460
|
+
if (!isObjectRecord(auth)) return undefined
|
|
1461
|
+
const type = (auth as { type?: unknown }).type
|
|
1462
|
+
if (type === 'pat' || type === 'app') return type
|
|
1463
|
+
return undefined
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// Lightweight read-only probe used by the `channel set` CLI to drive its
|
|
1467
|
+
// "which secret do you want to rotate?" menu for GitHub. Returns the
|
|
1468
|
+
// current auth type ('pat' | 'app') so the prompt knows whether to ask for
|
|
1469
|
+
// a PAT or an App private key, without forcing the user to re-select auth
|
|
1470
|
+
// type when they're rotating a credential of the same kind. Returns `null`
|
|
1471
|
+
// when secrets.json is missing, malformed, or has no github entry — the
|
|
1472
|
+
// CLI surfaces that as a single user-facing "fix the file by hand" error.
|
|
1473
|
+
export function readGithubAuthType(cwd: string): 'pat' | 'app' | null {
|
|
1474
|
+
let channels: Channels | null
|
|
1475
|
+
try {
|
|
1476
|
+
channels = new SecretsBackend(join(cwd, 'secrets.json')).tryReadChannelsSync()
|
|
1477
|
+
} catch {
|
|
1478
|
+
return null
|
|
1479
|
+
}
|
|
1480
|
+
if (channels === null) return null
|
|
1481
|
+
const github = channels.github
|
|
1482
|
+
if (!isObjectRecord(github)) return null
|
|
1483
|
+
const auth = (github as { auth?: unknown }).auth
|
|
1484
|
+
return readGithubAuthTypeFromObject(auth) ?? null
|
|
1485
|
+
}
|