typeclaw 0.1.5 → 0.1.6
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 +14 -12
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +7 -4
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +23 -9
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +799 -319
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +6 -1
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +8 -1
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +385 -12
- package/src/config/index.ts +7 -0
- package/src/config/models-mutation.ts +200 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +5 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +39 -58
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +50 -33
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +32 -6
- package/src/init/index.ts +183 -62
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +55 -6
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +68 -0
- package/src/server/index.ts +122 -11
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +38 -64
- package/src/skills/typeclaw-memory/SKILL.md +1 -1
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- package/typeclaw.schema.json +57 -45
package/src/init/index.ts
CHANGED
|
@@ -81,7 +81,15 @@ export type InitStepEvent =
|
|
|
81
81
|
// portbroker — same path `typeclaw start` takes. When omitted (test fixtures,
|
|
82
82
|
// programmatic callers that never want a daemon), `start()` skips the daemon
|
|
83
83
|
// path entirely and the container runs unmanaged.
|
|
84
|
-
export type HatchRunner = (options: {
|
|
84
|
+
export type HatchRunner = (options: {
|
|
85
|
+
cwd: string
|
|
86
|
+
port: number
|
|
87
|
+
cliEntry?: string
|
|
88
|
+
// Set when the wizard wired at least one channel adapter, so the runner
|
|
89
|
+
// can offer to run `typeclaw role claim` after the container is ready.
|
|
90
|
+
// Empty / undefined means "no channels — skip the claim flow".
|
|
91
|
+
configuredChannels?: readonly ChannelKind[]
|
|
92
|
+
}) => Promise<HatchingResult>
|
|
85
93
|
|
|
86
94
|
export type KakaotalkAuthRunner = (options: { cwd: string }) => Promise<KakaotalkAuthResult>
|
|
87
95
|
|
|
@@ -101,16 +109,27 @@ export type InitOptions = {
|
|
|
101
109
|
// defaults to the api-key path with `apiKey` (legacy field, still
|
|
102
110
|
// supported for backwards compat with the old `runInit` signature).
|
|
103
111
|
llmAuth?: LLMAuth
|
|
112
|
+
// Optional second model + auth, written as `models.vision` when the
|
|
113
|
+
// default model is text-only. Auth is reused from the default path
|
|
114
|
+
// when both refer to the same provider; the wizard enforces this
|
|
115
|
+
// pairing rule, so by the time we get here `visionAuth` is either
|
|
116
|
+
// (a) absent, or (b) the right auth for `visionModel`'s provider.
|
|
117
|
+
visionModel?: KnownModelRef
|
|
118
|
+
visionAuth?: LLMAuth
|
|
104
119
|
apiKey?: string
|
|
105
120
|
discordBotToken?: string
|
|
106
|
-
discordAllowAll?: boolean
|
|
107
121
|
slackBotToken?: string
|
|
108
122
|
slackAppToken?: string
|
|
109
|
-
slackAllowAll?: boolean
|
|
110
123
|
telegramBotToken?: string
|
|
111
|
-
|
|
124
|
+
// When reusing existing channel credentials from a pre-init `secrets.json`,
|
|
125
|
+
// the CLI passes `with<Adapter>: true` without a corresponding token so the
|
|
126
|
+
// scaffolded `typeclaw.json` wires the adapter while `writeSecrets` leaves
|
|
127
|
+
// the existing slot in `secrets.json#channels` untouched. Defaults below
|
|
128
|
+
// mirror the legacy derivation (`<token> !== undefined && !== ''`).
|
|
129
|
+
withDiscord?: boolean
|
|
130
|
+
withSlack?: boolean
|
|
131
|
+
withTelegram?: boolean
|
|
112
132
|
withKakaotalk?: boolean
|
|
113
|
-
kakaotalkAllowAll?: boolean
|
|
114
133
|
runKakaotalkAuth?: KakaotalkAuthRunner
|
|
115
134
|
onProgress?: (event: InitStepEvent) => void
|
|
116
135
|
runHatching?: HatchRunner
|
|
@@ -129,15 +148,16 @@ export async function runInit({
|
|
|
129
148
|
apiKey,
|
|
130
149
|
llmAuth,
|
|
131
150
|
model = DEFAULT_MODEL_REF,
|
|
151
|
+
visionModel,
|
|
152
|
+
visionAuth,
|
|
132
153
|
discordBotToken,
|
|
133
|
-
discordAllowAll = true,
|
|
134
154
|
slackBotToken,
|
|
135
155
|
slackAppToken,
|
|
136
|
-
slackAllowAll = true,
|
|
137
156
|
telegramBotToken,
|
|
138
|
-
|
|
157
|
+
withDiscord,
|
|
158
|
+
withSlack,
|
|
159
|
+
withTelegram,
|
|
139
160
|
withKakaotalk = false,
|
|
140
|
-
kakaotalkAllowAll = false,
|
|
141
161
|
runKakaotalkAuth,
|
|
142
162
|
onProgress,
|
|
143
163
|
runHatching = defaultRunHatching,
|
|
@@ -180,20 +200,36 @@ export async function runInit({
|
|
|
180
200
|
}
|
|
181
201
|
}
|
|
182
202
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
203
|
+
// When the vision profile uses a different provider than the default, its
|
|
204
|
+
// OAuth login runs here too, before any file write. Same-provider vision
|
|
205
|
+
// reuses the default auth (no separate login). API-key vision auth is
|
|
206
|
+
// captured in memory and persisted by writeSecrets() below.
|
|
207
|
+
if (
|
|
208
|
+
visionAuth !== undefined &&
|
|
209
|
+
visionAuth.kind === 'oauth' &&
|
|
210
|
+
visionModel !== undefined &&
|
|
211
|
+
providerForModelRef(visionModel) !== providerForModelRef(model)
|
|
212
|
+
) {
|
|
213
|
+
emit({ step: 'oauth-login', phase: 'start' })
|
|
214
|
+
await mkdir(cwd, { recursive: true })
|
|
215
|
+
const result = await visionAuth.runLogin({ cwd, model: visionModel })
|
|
216
|
+
emit({ step: 'oauth-login', phase: 'done', result })
|
|
217
|
+
if (!result.ok) {
|
|
218
|
+
throw new Error(`OAuth login failed: ${result.reason}`)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const wantsDiscord = withDiscord ?? (discordBotToken !== undefined && discordBotToken !== '')
|
|
223
|
+
const wantsSlack = withSlack ?? (slackBotToken !== undefined && slackBotToken !== '')
|
|
224
|
+
const wantsTelegram = withTelegram ?? (telegramBotToken !== undefined && telegramBotToken !== '')
|
|
186
225
|
emit({ step: 'scaffold', phase: 'start' })
|
|
187
226
|
await scaffold(cwd, {
|
|
188
227
|
model,
|
|
228
|
+
...(visionModel !== undefined ? { visionModel } : {}),
|
|
189
229
|
withDiscord: wantsDiscord,
|
|
190
|
-
discordAllowAll,
|
|
191
230
|
withSlack: wantsSlack,
|
|
192
|
-
slackAllowAll,
|
|
193
231
|
withTelegram: wantsTelegram,
|
|
194
|
-
telegramAllowAll,
|
|
195
232
|
withKakaotalk,
|
|
196
|
-
kakaotalkAllowAll,
|
|
197
233
|
})
|
|
198
234
|
// Only write the LLM API key on the api-key path. OAuth providers persist
|
|
199
235
|
// their credentials to secrets.json (via the OAuth login step above); writing
|
|
@@ -201,6 +237,9 @@ export async function runInit({
|
|
|
201
237
|
await writeSecrets(cwd, {
|
|
202
238
|
model,
|
|
203
239
|
apiKey: resolvedAuth.kind === 'api-key' ? resolvedAuth.apiKey : undefined,
|
|
240
|
+
...(visionModel !== undefined && visionAuth?.kind === 'api-key'
|
|
241
|
+
? { visionModel, visionApiKey: visionAuth.apiKey }
|
|
242
|
+
: {}),
|
|
204
243
|
discordBotToken,
|
|
205
244
|
slackBotToken,
|
|
206
245
|
slackAppToken,
|
|
@@ -235,8 +274,19 @@ export async function runInit({
|
|
|
235
274
|
const git = await initGitRepo(cwd)
|
|
236
275
|
emit({ step: 'git', phase: 'done', result: git })
|
|
237
276
|
|
|
277
|
+
const configuredChannels: ChannelKind[] = []
|
|
278
|
+
if (wantsDiscord) configuredChannels.push('discord-bot')
|
|
279
|
+
if (wantsSlack) configuredChannels.push('slack-bot')
|
|
280
|
+
if (wantsTelegram) configuredChannels.push('telegram-bot')
|
|
281
|
+
if (withKakaotalk) configuredChannels.push('kakaotalk')
|
|
282
|
+
|
|
238
283
|
emit({ step: 'hatching', phase: 'start' })
|
|
239
|
-
const hatching = await runHatching({
|
|
284
|
+
const hatching = await runHatching({
|
|
285
|
+
cwd,
|
|
286
|
+
port: config.port,
|
|
287
|
+
...(cliEntry !== undefined ? { cliEntry } : {}),
|
|
288
|
+
...(configuredChannels.length > 0 ? { configuredChannels } : {}),
|
|
289
|
+
})
|
|
240
290
|
emit({ step: 'hatching', phase: 'done', result: hatching })
|
|
241
291
|
}
|
|
242
292
|
|
|
@@ -250,16 +300,20 @@ export async function defaultRunHatching({
|
|
|
250
300
|
cwd,
|
|
251
301
|
port,
|
|
252
302
|
cliEntry,
|
|
303
|
+
configuredChannels,
|
|
253
304
|
startContainer = start,
|
|
254
305
|
tui: tuiFactory = createTui,
|
|
255
306
|
waitForAgent: waitForAgentFn = waitForAgent,
|
|
307
|
+
runClaim = defaultRunClaim,
|
|
256
308
|
}: {
|
|
257
309
|
cwd: string
|
|
258
310
|
port: number
|
|
259
311
|
cliEntry?: string
|
|
312
|
+
configuredChannels?: readonly ChannelKind[]
|
|
260
313
|
startContainer?: typeof start
|
|
261
314
|
tui?: typeof createTui
|
|
262
315
|
waitForAgent?: typeof waitForAgent
|
|
316
|
+
runClaim?: ClaimRunner
|
|
263
317
|
}): Promise<HatchingResult> {
|
|
264
318
|
try {
|
|
265
319
|
const launch = await startContainer({
|
|
@@ -276,6 +330,11 @@ export async function defaultRunHatching({
|
|
|
276
330
|
|
|
277
331
|
await waitForAgentFn(`http://127.0.0.1:${hostPort}`, { timeoutMs: 30_000 })
|
|
278
332
|
|
|
333
|
+
if (configuredChannels !== undefined && configuredChannels.length > 0) {
|
|
334
|
+
const url = buildTuiUrl(hostPort, launch.tuiToken)
|
|
335
|
+
await runClaim({ url, configuredChannels })
|
|
336
|
+
}
|
|
337
|
+
|
|
279
338
|
const tui = tuiFactory({
|
|
280
339
|
url: buildTuiUrl(hostPort, launch.tuiToken),
|
|
281
340
|
initialPrompt: HATCHING_PROMPT,
|
|
@@ -287,6 +346,13 @@ export async function defaultRunHatching({
|
|
|
287
346
|
}
|
|
288
347
|
}
|
|
289
348
|
|
|
349
|
+
export type ClaimRunner = (options: { url: string; configuredChannels: readonly ChannelKind[] }) => Promise<void>
|
|
350
|
+
|
|
351
|
+
const defaultRunClaim: ClaimRunner = async ({ url, configuredChannels }) => {
|
|
352
|
+
const { runOwnerClaim } = await import('./run-owner-claim')
|
|
353
|
+
await runOwnerClaim({ url, configuredChannels })
|
|
354
|
+
}
|
|
355
|
+
|
|
290
356
|
function buildTuiUrl(hostPort: number, token: string | null): string {
|
|
291
357
|
const url = new URL(`ws://127.0.0.1:${hostPort}`)
|
|
292
358
|
if (token !== null) url.searchParams.set('token', token)
|
|
@@ -372,14 +438,11 @@ export async function isHatched(dir: string): Promise<boolean> {
|
|
|
372
438
|
|
|
373
439
|
export type ScaffoldOptions = {
|
|
374
440
|
model?: KnownModelRef
|
|
441
|
+
visionModel?: KnownModelRef
|
|
375
442
|
withDiscord?: boolean
|
|
376
|
-
discordAllowAll?: boolean
|
|
377
443
|
withSlack?: boolean
|
|
378
|
-
slackAllowAll?: boolean
|
|
379
444
|
withTelegram?: boolean
|
|
380
|
-
telegramAllowAll?: boolean
|
|
381
445
|
withKakaotalk?: boolean
|
|
382
|
-
kakaotalkAllowAll?: boolean
|
|
383
446
|
}
|
|
384
447
|
|
|
385
448
|
export async function scaffold(root: string, options: ScaffoldOptions = {}): Promise<void> {
|
|
@@ -392,30 +455,22 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
|
|
|
392
455
|
// immediately populated, so packages/ is the only one that needs this.
|
|
393
456
|
await writeFile(join(root, PACKAGES_DIR, GITKEEP_FILE), '', { flag: 'wx' }).catch(ignoreExists)
|
|
394
457
|
|
|
395
|
-
// Only fields without sensible defaults elsewhere are emitted
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
// has to maintain in sync with the source of truth.
|
|
458
|
+
// Only fields without sensible defaults elsewhere are emitted. Everything
|
|
459
|
+
// with a schema-provided default (e.g. `network.blockInternal`, `mounts`,
|
|
460
|
+
// `memory.*`) is omitted to keep the scaffold minimal — duplicating defaults
|
|
461
|
+
// here would mean every schema change has to be mirrored in two places, and
|
|
462
|
+
// users would feel obligated to maintain values they never set.
|
|
463
|
+
const models: Record<string, KnownModelRef> = { default: options.model ?? DEFAULT_MODEL_REF }
|
|
464
|
+
if (options.visionModel !== undefined) models.vision = options.visionModel
|
|
403
465
|
const config: Record<string, unknown> = {
|
|
404
466
|
$schema: './node_modules/typeclaw/typeclaw.schema.json',
|
|
405
|
-
|
|
406
|
-
network: { blockInternal: true },
|
|
407
|
-
}
|
|
408
|
-
const channels: Record<string, { allow: string[] }> = {}
|
|
409
|
-
if (options.withDiscord) channels['discord-bot'] = { allow: options.discordAllowAll === false ? [] : ['*'] }
|
|
410
|
-
if (options.withSlack) channels['slack-bot'] = { allow: options.slackAllowAll === false ? [] : ['*'] }
|
|
411
|
-
if (options.withTelegram) channels['telegram-bot'] = { allow: options.telegramAllowAll === false ? [] : ['*'] }
|
|
412
|
-
if (options.withKakaotalk) {
|
|
413
|
-
// KakaoTalk involves a personal account, so we default to a tighter
|
|
414
|
-
// allow list (DMs only) than Slack/Discord/Telegram which scope to a
|
|
415
|
-
// workspace the user explicitly admitted the bot into. The user can
|
|
416
|
-
// broaden to `kakao:*` later by editing typeclaw.json.
|
|
417
|
-
channels.kakaotalk = { allow: options.kakaotalkAllowAll === true ? ['kakao:*'] : ['kakao:dm/*'] }
|
|
467
|
+
models,
|
|
418
468
|
}
|
|
469
|
+
const channels: Record<string, Record<string, never>> = {}
|
|
470
|
+
if (options.withDiscord) channels['discord-bot'] = {}
|
|
471
|
+
if (options.withSlack) channels['slack-bot'] = {}
|
|
472
|
+
if (options.withTelegram) channels['telegram-bot'] = {}
|
|
473
|
+
if (options.withKakaotalk) channels.kakaotalk = {}
|
|
419
474
|
if (Object.keys(channels).length > 0) config.channels = channels
|
|
420
475
|
await writeFile(join(root, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
|
|
421
476
|
|
|
@@ -578,6 +633,8 @@ export async function writeSecrets(
|
|
|
578
633
|
{
|
|
579
634
|
model = DEFAULT_MODEL_REF,
|
|
580
635
|
apiKey,
|
|
636
|
+
visionModel,
|
|
637
|
+
visionApiKey,
|
|
581
638
|
discordBotToken,
|
|
582
639
|
slackBotToken,
|
|
583
640
|
slackAppToken,
|
|
@@ -586,6 +643,8 @@ export async function writeSecrets(
|
|
|
586
643
|
model?: KnownModelRef
|
|
587
644
|
// Omitted on the OAuth path — credentials live in secrets.json via the OAuth runner.
|
|
588
645
|
apiKey?: string
|
|
646
|
+
visionModel?: KnownModelRef
|
|
647
|
+
visionApiKey?: string
|
|
589
648
|
discordBotToken?: string
|
|
590
649
|
slackBotToken?: string
|
|
591
650
|
slackAppToken?: string
|
|
@@ -594,8 +653,20 @@ export async function writeSecrets(
|
|
|
594
653
|
): Promise<void> {
|
|
595
654
|
const providerId = providerForModelRef(model)
|
|
596
655
|
const apiKeyEnv = KNOWN_PROVIDERS[providerId].apiKeyEnv
|
|
597
|
-
|
|
598
|
-
|
|
656
|
+
const wantsDefaultKey = apiKey !== undefined && apiKeyEnv !== null
|
|
657
|
+
const visionProviderId = visionModel !== undefined ? providerForModelRef(visionModel) : null
|
|
658
|
+
const wantsVisionKey =
|
|
659
|
+
visionModel !== undefined &&
|
|
660
|
+
visionApiKey !== undefined &&
|
|
661
|
+
visionProviderId !== providerId &&
|
|
662
|
+
visionProviderId !== null &&
|
|
663
|
+
KNOWN_PROVIDERS[visionProviderId].apiKeyEnv !== null
|
|
664
|
+
if (wantsDefaultKey || wantsVisionKey) {
|
|
665
|
+
const secretsStore = createSecretsStoreForAgent(join(root, 'secrets.json'))
|
|
666
|
+
if (wantsDefaultKey) secretsStore.set(providerId, { type: 'api_key', key: apiKey! })
|
|
667
|
+
if (wantsVisionKey) {
|
|
668
|
+
secretsStore.set(visionProviderId, { type: 'api_key', key: visionApiKey! })
|
|
669
|
+
}
|
|
599
670
|
}
|
|
600
671
|
|
|
601
672
|
const channelTokens: Record<string, Record<string, Secret>> = {}
|
|
@@ -630,6 +701,70 @@ export async function readExistingProviderApiKey(root: string, providerId: Known
|
|
|
630
701
|
return new SecretsBackend(join(root, 'secrets.json')).tryReadProviderApiKeySync(providerId)
|
|
631
702
|
}
|
|
632
703
|
|
|
704
|
+
// Detects whether the requested channel already has usable credentials in
|
|
705
|
+
// `secrets.json#channels`, so the init wizard can offer to reuse them
|
|
706
|
+
// instead of re-prompting for tokens. Mirrors `readExistingProviderApiKey`:
|
|
707
|
+
// returns `true` only when ALL fields the adapter needs are present in a
|
|
708
|
+
// shape `hydrateChannelEnvFromSecrets` would inject at runtime — both the
|
|
709
|
+
// `{ value }` form and the `{ env }` env-binding form count, matching the
|
|
710
|
+
// runtime resolution rules in `src/secrets/resolve.ts`. Partial slots (e.g.
|
|
711
|
+
// `slack-bot` with `botToken` but no `appToken`) return `false` so the
|
|
712
|
+
// missing field gets filled in by the normal prompt.
|
|
713
|
+
//
|
|
714
|
+
// KakaoTalk reuse is stricter: a usable block requires both a complete
|
|
715
|
+
// account (currentAccount + matching entry in accounts) AND the renewal
|
|
716
|
+
// fields (email + encryptedPassword) the hostd renewal cron needs to mint
|
|
717
|
+
// fresh tokens unattended (`src/secrets/kakao-renewal.ts`). Without those,
|
|
718
|
+
// the saved `oauth_token` will work only until KakaoTalk's ~7-day TTL
|
|
719
|
+
// expires, after which the user has to run `typeclaw channel reauth
|
|
720
|
+
// kakaotalk` anyway — better to re-auth now during init.
|
|
721
|
+
export async function hasExistingChannelSecrets(
|
|
722
|
+
root: string,
|
|
723
|
+
channel: 'discord' | 'slack' | 'telegram' | 'kakaotalk',
|
|
724
|
+
): Promise<boolean> {
|
|
725
|
+
const channels = new SecretsBackend(join(root, 'secrets.json')).tryReadChannelsSync()
|
|
726
|
+
if (channels === null) return false
|
|
727
|
+
switch (channel) {
|
|
728
|
+
case 'discord':
|
|
729
|
+
return hasSecretField(channels['discord-bot'], 'token')
|
|
730
|
+
case 'slack':
|
|
731
|
+
return hasSecretField(channels['slack-bot'], 'botToken') && hasSecretField(channels['slack-bot'], 'appToken')
|
|
732
|
+
case 'telegram':
|
|
733
|
+
return hasSecretField(channels['telegram-bot'], 'token')
|
|
734
|
+
case 'kakaotalk': {
|
|
735
|
+
const block = channels.kakaotalk
|
|
736
|
+
if (!isObjectRecord(block)) return false
|
|
737
|
+
const current = (block as { currentAccount?: unknown }).currentAccount
|
|
738
|
+
if (typeof current !== 'string' || current.length === 0) return false
|
|
739
|
+
const accounts = (block as { accounts?: unknown }).accounts
|
|
740
|
+
if (!isObjectRecord(accounts)) return false
|
|
741
|
+
const account = accounts[current]
|
|
742
|
+
if (!isObjectRecord(account)) return false
|
|
743
|
+
const email = (account as { email?: unknown }).email
|
|
744
|
+
const encryptedPassword = (account as { encryptedPassword?: unknown }).encryptedPassword
|
|
745
|
+
return typeof email === 'string' && email.length > 0 && isObjectRecord(encryptedPassword)
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Accepts either the `{ value }` form (resolves to a literal at runtime) or
|
|
751
|
+
// the `{ env }` form (resolves at runtime by reading `process.env[<env>]`).
|
|
752
|
+
// String shorthand is sugar for `{ value }`. The schema already rejects
|
|
753
|
+
// empty strings via `z.string().min(1)`, so the length checks here are
|
|
754
|
+
// defense-in-depth against forward-compat shape drift.
|
|
755
|
+
function hasSecretField(slot: unknown, field: string): boolean {
|
|
756
|
+
if (!isObjectRecord(slot)) return false
|
|
757
|
+
const secret = slot[field]
|
|
758
|
+
if (typeof secret === 'string') return secret.length > 0
|
|
759
|
+
if (isObjectRecord(secret)) {
|
|
760
|
+
const value = (secret as { value?: unknown }).value
|
|
761
|
+
if (typeof value === 'string' && value.length > 0) return true
|
|
762
|
+
const env = (secret as { env?: unknown }).env
|
|
763
|
+
if (typeof env === 'string' && env.length > 0) return true
|
|
764
|
+
}
|
|
765
|
+
return false
|
|
766
|
+
}
|
|
767
|
+
|
|
633
768
|
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
634
769
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
635
770
|
}
|
|
@@ -689,7 +824,6 @@ export type AddChannelStepEvent =
|
|
|
689
824
|
// from prompts; tests build them inline.
|
|
690
825
|
export type AddChannelOptions = {
|
|
691
826
|
cwd: string
|
|
692
|
-
allowAll?: boolean
|
|
693
827
|
onProgress?: (event: AddChannelStepEvent) => void
|
|
694
828
|
} & (
|
|
695
829
|
| { channel: 'discord-bot'; discordBotToken: string }
|
|
@@ -717,7 +851,7 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
|
717
851
|
}
|
|
718
852
|
|
|
719
853
|
emit({ step: 'config', phase: 'start' })
|
|
720
|
-
await mergeChannelIntoConfig(options.cwd, options.channel
|
|
854
|
+
await mergeChannelIntoConfig(options.cwd, options.channel)
|
|
721
855
|
emit({ step: 'config', phase: 'done' })
|
|
722
856
|
|
|
723
857
|
emit({ step: 'secrets', phase: 'start' })
|
|
@@ -728,14 +862,6 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
|
728
862
|
emit({ step: 'secrets', phase: 'done' })
|
|
729
863
|
}
|
|
730
864
|
|
|
731
|
-
// `channel add` mirrors `runInit`'s allow defaults: workspace-scoped adapters
|
|
732
|
-
// (discord/slack/telegram) default to `*` because the bot only sees what the
|
|
733
|
-
// operator invited it into, while KakaoTalk uses a personal account and
|
|
734
|
-
// defaults to DMs only.
|
|
735
|
-
function defaultAllowAll(channel: ChannelKind): boolean {
|
|
736
|
-
return channel !== 'kakaotalk'
|
|
737
|
-
}
|
|
738
|
-
|
|
739
865
|
function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
|
|
740
866
|
switch (options.channel) {
|
|
741
867
|
case 'discord-bot':
|
|
@@ -774,7 +900,7 @@ export async function readConfiguredChannels(cwd: string): Promise<Set<ChannelKi
|
|
|
774
900
|
return present
|
|
775
901
|
}
|
|
776
902
|
|
|
777
|
-
async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind
|
|
903
|
+
async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind): Promise<void> {
|
|
778
904
|
const path = join(cwd, CONFIG_FILE)
|
|
779
905
|
let parsed: Record<string, unknown>
|
|
780
906
|
try {
|
|
@@ -798,23 +924,18 @@ async function mergeChannelIntoConfig(cwd: string, channel: ChannelKind, allowAl
|
|
|
798
924
|
// Defense in depth — the CLI already filters configured channels out of
|
|
799
925
|
// the picker and rejects them as the positional arg. Hitting this branch
|
|
800
926
|
// means a programmatic caller passed a duplicate; better to fail loudly
|
|
801
|
-
// than silently overwrite the user's existing
|
|
927
|
+
// than silently overwrite the user's existing config.
|
|
802
928
|
throw new Error(`Channel "${channel}" is already configured in ${CONFIG_FILE}.`)
|
|
803
929
|
}
|
|
804
930
|
|
|
805
931
|
parsed.channels = {
|
|
806
932
|
...existingChannels,
|
|
807
|
-
[channel]: {
|
|
933
|
+
[channel]: {},
|
|
808
934
|
}
|
|
809
935
|
|
|
810
936
|
await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
811
937
|
}
|
|
812
938
|
|
|
813
|
-
function buildAllow(channel: ChannelKind, allowAll: boolean): string[] {
|
|
814
|
-
if (channel === 'kakaotalk') return allowAll ? ['kakao:*'] : ['kakao:dm/*']
|
|
815
|
-
return allowAll ? ['*'] : []
|
|
816
|
-
}
|
|
817
|
-
|
|
818
939
|
// Writes per-adapter field values into `secrets.json#channels.<adapter>`.
|
|
819
940
|
// Refuses to overwrite existing fields: if the user already has e.g.
|
|
820
941
|
// `botToken` recorded (from a prior `channel add` whose follow-up steps
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { createRequire } from 'node:module'
|
|
2
|
-
import { join } from 'node:path'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
3
|
|
|
4
|
+
import { containerNameFromCwd } from '@/container'
|
|
5
|
+
import { keysDir } from '@/hostd/paths'
|
|
6
|
+
import { encrypt } from '@/secrets/encryption'
|
|
4
7
|
import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
|
|
8
|
+
import { createKeyStore, type KeyStore } from '@/secrets/keys'
|
|
5
9
|
|
|
6
10
|
export type KakaotalkBootstrapStatus = { ok: true } | { ok: false; reason: string }
|
|
7
11
|
|
|
@@ -15,6 +19,13 @@ export type KakaotalkLoginInput = {
|
|
|
15
19
|
agentDir: string
|
|
16
20
|
callbacks: KakaotalkLoginCallbacks
|
|
17
21
|
loginFlow?: LoginFlowFn
|
|
22
|
+
// Test seam: inject a custom keystore (typically pointing at a tmpdir).
|
|
23
|
+
// Production uses ~/.typeclaw/keys/<containerName>.key.
|
|
24
|
+
keyStore?: KeyStore
|
|
25
|
+
// Test seam: override the containerName used to bind the encrypted
|
|
26
|
+
// password's AAD. Production derives it from basename(agentDir) via
|
|
27
|
+
// containerNameFromCwd to match what `typeclaw start` registers with hostd.
|
|
28
|
+
containerName?: string
|
|
18
29
|
}
|
|
19
30
|
|
|
20
31
|
export type LoginFlowOptions = {
|
|
@@ -82,6 +93,10 @@ export async function runKakaotalkBootstrap(input: KakaotalkLoginInput): Promise
|
|
|
82
93
|
|
|
83
94
|
const now = new Date().toISOString()
|
|
84
95
|
const accountId = result.credentials.user_id || 'default'
|
|
96
|
+
const containerName = input.containerName ?? containerNameFromCwd(resolve(input.agentDir))
|
|
97
|
+
const keyStore = input.keyStore ?? createKeyStore({ keysDir: keysDir() })
|
|
98
|
+
const key = await keyStore.ensure(containerName)
|
|
99
|
+
const encryptedPassword = encrypt(input.password, key, { containerName, accountId })
|
|
85
100
|
await credManager.setAccount({
|
|
86
101
|
account_id: accountId,
|
|
87
102
|
oauth_token: result.credentials.access_token,
|
|
@@ -92,6 +107,8 @@ export async function runKakaotalkBootstrap(input: KakaotalkLoginInput): Promise
|
|
|
92
107
|
auth_method: 'login',
|
|
93
108
|
created_at: now,
|
|
94
109
|
updated_at: now,
|
|
110
|
+
email: input.email,
|
|
111
|
+
encryptedPassword,
|
|
95
112
|
})
|
|
96
113
|
await credManager.setCurrentAccount(accountId)
|
|
97
114
|
await credManager.clearPendingLogin()
|
package/src/init/models-dev.ts
CHANGED
|
@@ -14,6 +14,11 @@ const PROVIDER_TO_MODELS_DEV: Record<KnownProviderId, string> = {
|
|
|
14
14
|
// entries are surfaced regardless of upstream membership.
|
|
15
15
|
'openai-codex': 'openai',
|
|
16
16
|
fireworks: 'fireworks-ai',
|
|
17
|
+
zai: 'zai',
|
|
18
|
+
// zai-coding (GLM Coding Plan) is a billing surface, not a separate model
|
|
19
|
+
// catalog. models.dev tracks the underlying model metadata under `zai`,
|
|
20
|
+
// so we route lookups there. The curated entries still get surfaced.
|
|
21
|
+
'zai-coding': 'zai',
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
export type ModelOption = {
|
|
@@ -25,6 +30,13 @@ export type ModelOption = {
|
|
|
25
30
|
reasoning: boolean
|
|
26
31
|
contextWindow: number | null
|
|
27
32
|
curated: boolean
|
|
33
|
+
// True iff the model accepts image input. Sourced from the curated
|
|
34
|
+
// `Model.input` array (which is the source of truth — pi-ai consumes it
|
|
35
|
+
// directly) with a fallback to models.dev's `modalities.input` when the
|
|
36
|
+
// curated entry omits the field. The init wizard uses this to decide
|
|
37
|
+
// whether to prompt for a separate `vision` profile after the user picks
|
|
38
|
+
// a text-only `default` model.
|
|
39
|
+
supportsVision: boolean
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
type ModelsDevModel = {
|
|
@@ -115,7 +127,10 @@ function buildOption(ref: KnownModelRef, opts: BuildOptionOpts): ModelOption {
|
|
|
115
127
|
const modelId = ref.slice(slash + 1)
|
|
116
128
|
const provider = KNOWN_PROVIDERS[providerId]
|
|
117
129
|
const curatedModel = (
|
|
118
|
-
provider.models as Record<
|
|
130
|
+
provider.models as Record<
|
|
131
|
+
string,
|
|
132
|
+
{ name: string; contextWindow?: number; reasoning?: boolean; input?: ReadonlyArray<string> }
|
|
133
|
+
>
|
|
119
134
|
)[modelId]
|
|
120
135
|
return {
|
|
121
136
|
ref,
|
|
@@ -126,5 +141,15 @@ function buildOption(ref: KnownModelRef, opts: BuildOptionOpts): ModelOption {
|
|
|
126
141
|
reasoning: opts.upstream?.reasoning ?? curatedModel?.reasoning ?? false,
|
|
127
142
|
contextWindow: opts.upstream?.limit?.context ?? curatedModel?.contextWindow ?? null,
|
|
128
143
|
curated: opts.curated,
|
|
144
|
+
supportsVision: resolveSupportsVision(curatedModel?.input, opts.upstream?.modalities?.input),
|
|
129
145
|
}
|
|
130
146
|
}
|
|
147
|
+
|
|
148
|
+
function resolveSupportsVision(
|
|
149
|
+
curatedInput: ReadonlyArray<string> | undefined,
|
|
150
|
+
upstreamInput: ReadonlyArray<string> | undefined,
|
|
151
|
+
): boolean {
|
|
152
|
+
if (curatedInput !== undefined) return curatedInput.includes('image')
|
|
153
|
+
if (upstreamInput !== undefined) return upstreamInput.includes('image')
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { confirm, isCancel, log, note, spinner } from '@clack/prompts'
|
|
2
|
+
|
|
3
|
+
import { c } from '@/cli/ui'
|
|
4
|
+
import { runClaimSession } from '@/role-claim'
|
|
5
|
+
|
|
6
|
+
import type { ChannelKind } from './index'
|
|
7
|
+
|
|
8
|
+
const CHANNEL_LABELS: Record<ChannelKind, string> = {
|
|
9
|
+
'slack-bot': 'Slack',
|
|
10
|
+
'discord-bot': 'Discord',
|
|
11
|
+
'telegram-bot': 'Telegram',
|
|
12
|
+
kakaotalk: 'KakaoTalk',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DEFAULT_TTL_MS = 10 * 60 * 1000
|
|
16
|
+
|
|
17
|
+
export type RunOwnerClaimOptions = {
|
|
18
|
+
url: string
|
|
19
|
+
configuredChannels: readonly ChannelKind[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Drives the post-hatching claim flow: ask the operator whether to pair now,
|
|
23
|
+
// run the claim handshake against the running container, print the result.
|
|
24
|
+
// Aborts (kind: 'cancel' or a clack cancel) drop straight back into the
|
|
25
|
+
// normal hatching path so the TUI still opens — the operator can run
|
|
26
|
+
// `typeclaw role claim` later.
|
|
27
|
+
export async function runOwnerClaim({ url, configuredChannels }: RunOwnerClaimOptions): Promise<void> {
|
|
28
|
+
if (configuredChannels.length === 0) return
|
|
29
|
+
|
|
30
|
+
const channelList = configuredChannels.map((c) => CHANNEL_LABELS[c] ?? c).join(', ')
|
|
31
|
+
|
|
32
|
+
const proceed = await confirm({
|
|
33
|
+
message: `Claim owner role on ${channelList} now?`,
|
|
34
|
+
initialValue: true,
|
|
35
|
+
})
|
|
36
|
+
if (isCancel(proceed) || proceed === false) {
|
|
37
|
+
log.info(`Skipping. Run ${c.bold('typeclaw role claim')} later when you're ready.`)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const s = spinner()
|
|
42
|
+
s.start('Generating your claim code...')
|
|
43
|
+
|
|
44
|
+
const result = await runClaimSession({
|
|
45
|
+
url,
|
|
46
|
+
role: 'owner',
|
|
47
|
+
ttlMs: DEFAULT_TTL_MS,
|
|
48
|
+
onStarted: (payload) => {
|
|
49
|
+
const expiresInMin = Math.max(1, Math.round((payload.expiresAt - Date.now()) / 60_000))
|
|
50
|
+
s.stop('Code ready.')
|
|
51
|
+
note(
|
|
52
|
+
[
|
|
53
|
+
`Open ${channelList} and DM your bot with this code:`,
|
|
54
|
+
'',
|
|
55
|
+
` ${c.bold(payload.code)}`,
|
|
56
|
+
'',
|
|
57
|
+
`(expires in ~${expiresInMin}m)`,
|
|
58
|
+
].join('\n'),
|
|
59
|
+
'Claim your owner role',
|
|
60
|
+
)
|
|
61
|
+
s.start('Waiting for your DM...')
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
if (result.kind === 'completed') {
|
|
66
|
+
s.stop(c.green(`Paired as owner.`))
|
|
67
|
+
log.info(`Match rule added to typeclaw.json#roles.owner.match: ${c.bold(result.payload.matchRule)}`)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
if (result.kind === 'error') {
|
|
71
|
+
s.stop(c.red(`Claim failed: ${result.payload.reason}`))
|
|
72
|
+
log.info(`You can retry with ${c.bold('typeclaw role claim')} anytime.`)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
s.stop(c.yellow(`Claim timed out — no DM received within the window.`))
|
|
76
|
+
log.info(`Run ${c.bold('typeclaw role claim')} when you're ready.`)
|
|
77
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { MatchRule } from './match-rule'
|
|
2
|
+
|
|
3
|
+
export type BuiltinRoleName = 'owner' | 'trusted' | 'member' | 'guest'
|
|
4
|
+
|
|
5
|
+
export const BUILTIN_ROLE_NAMES: readonly BuiltinRoleName[] = ['owner', 'trusted', 'member', 'guest']
|
|
6
|
+
|
|
7
|
+
// Core-owned permission strings; not contributed by plugins. The security
|
|
8
|
+
// plugin's `security.bypass.*` strings are NOT listed here — they are
|
|
9
|
+
// collected from plugin contributions and merged into `owner`'s permission
|
|
10
|
+
// set at boot via expandOwnerWildcard.
|
|
11
|
+
export const CORE_PERMISSIONS = {
|
|
12
|
+
channelRespond: 'channel.respond',
|
|
13
|
+
cronSchedule: 'cron.schedule',
|
|
14
|
+
cronModify: 'cron.modify',
|
|
15
|
+
} as const
|
|
16
|
+
|
|
17
|
+
// Sentinel that `expandOwnerWildcard` swaps for the concrete union of
|
|
18
|
+
// plugin-registered `security.bypass.*` strings. Users cannot write `*` in
|
|
19
|
+
// their own `permissions[]`; the sentinel exists only inside the built-in
|
|
20
|
+
// `owner` spec.
|
|
21
|
+
export const OWNER_SECURITY_WILDCARD = '__BUILTIN_OWNER_SECURITY_WILDCARD__'
|
|
22
|
+
|
|
23
|
+
export type BuiltinRoleSpec = {
|
|
24
|
+
readonly match: readonly MatchRule[]
|
|
25
|
+
readonly permissions: readonly string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> = {
|
|
29
|
+
owner: {
|
|
30
|
+
match: [{ kind: 'tui' }],
|
|
31
|
+
permissions: [
|
|
32
|
+
CORE_PERMISSIONS.channelRespond,
|
|
33
|
+
CORE_PERMISSIONS.cronSchedule,
|
|
34
|
+
CORE_PERMISSIONS.cronModify,
|
|
35
|
+
OWNER_SECURITY_WILDCARD,
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
trusted: {
|
|
39
|
+
match: [],
|
|
40
|
+
permissions: [CORE_PERMISSIONS.channelRespond, CORE_PERMISSIONS.cronSchedule, 'security.bypass.secretExfilBash'],
|
|
41
|
+
},
|
|
42
|
+
member: {
|
|
43
|
+
match: [],
|
|
44
|
+
permissions: [CORE_PERMISSIONS.channelRespond],
|
|
45
|
+
},
|
|
46
|
+
guest: {
|
|
47
|
+
match: [],
|
|
48
|
+
permissions: [],
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function expandOwnerWildcard(
|
|
53
|
+
ownerPermissions: readonly string[],
|
|
54
|
+
pluginContributed: readonly string[],
|
|
55
|
+
): readonly string[] {
|
|
56
|
+
const bypass = pluginContributed.filter((p) => p.startsWith('security.bypass.'))
|
|
57
|
+
const out: string[] = []
|
|
58
|
+
for (const p of ownerPermissions) {
|
|
59
|
+
if (p === OWNER_SECURITY_WILDCARD) {
|
|
60
|
+
for (const b of bypass) if (!out.includes(b)) out.push(b)
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (!out.includes(p)) out.push(p)
|
|
64
|
+
}
|
|
65
|
+
return out
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function isBuiltinRoleName(name: string): name is BuiltinRoleName {
|
|
69
|
+
return (BUILTIN_ROLE_NAMES as readonly string[]).includes(name)
|
|
70
|
+
}
|