typeclaw 0.4.0 → 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/package.json +1 -1
- 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/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 +87 -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/index.ts +87 -3
- package/src/channels/router.ts +194 -28
- package/src/channels/types.ts +3 -1
- package/src/cli/init.ts +146 -42
- package/src/cli/model.ts +10 -2
- package/src/cli/oauth-callbacks.ts +49 -0
- package/src/cli/provider.ts +3 -20
- package/src/config/config.ts +59 -24
- package/src/config/models-mutation.ts +42 -8
- package/src/config/providers-mutation.ts +12 -8
- package/src/container/start.ts +18 -1
- package/src/cron/consumer.ts +129 -43
- package/src/init/dockerfile.ts +109 -3
- package/src/init/hatching.ts +2 -2
- package/src/init/index.ts +14 -3
- package/src/init/oauth-login.ts +17 -3
- package/src/permissions/builtins.ts +29 -7
- package/src/permissions/permissions.ts +24 -7
- package/src/plugin/define.ts +2 -0
- package/src/plugin/manager.ts +14 -0
- package/src/plugin/types.ts +6 -0
- package/src/run/index.ts +2 -1
- package/src/skills/typeclaw-memory/SKILL.md +25 -15
- package/src/skills/typeclaw-permissions/SKILL.md +35 -17
- package/src/tui/index.ts +35 -3
- package/src/usage/report.ts +15 -12
- package/typeclaw.schema.json +57 -25
package/src/cli/init.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type KnownModelRef,
|
|
12
12
|
type KnownProviderId,
|
|
13
13
|
} from '@/config/providers'
|
|
14
|
-
import type
|
|
14
|
+
import { checkDockerAvailable, type DockerAvailability } from '@/container'
|
|
15
15
|
import {
|
|
16
16
|
findAgentDir,
|
|
17
17
|
formatEagerGithubWebhookInstallResult,
|
|
@@ -29,8 +29,9 @@ import {
|
|
|
29
29
|
} from '@/init'
|
|
30
30
|
import { runKakaotalkBootstrap } from '@/init/kakaotalk-auth'
|
|
31
31
|
import { fetchModelOptions, type ModelOption } from '@/init/models-dev'
|
|
32
|
-
import { makeOAuthLoginRunner } from '@/init/oauth-login'
|
|
32
|
+
import { makeOAuthLoginRunner, type OAuthLoginResult } from '@/init/oauth-login'
|
|
33
33
|
|
|
34
|
+
import { buildOAuthCallbacks } from './oauth-callbacks'
|
|
34
35
|
import { c, done, errorLine } from './ui'
|
|
35
36
|
|
|
36
37
|
// ESC and Ctrl+C both produce clack's cancel symbol (the keypress layer
|
|
@@ -54,9 +55,15 @@ import { c, done, errorLine } from './ui'
|
|
|
54
55
|
// a clean exit. Inside an active clack prompt Ctrl+C is still aliased to
|
|
55
56
|
// cancel, so the abort hotkey is "cancel twice in a row".
|
|
56
57
|
export class WizardAbortedError extends Error {
|
|
57
|
-
|
|
58
|
+
// When the wizard ran a successful eager OAuth login before aborting, the
|
|
59
|
+
// resulting credentials are already on disk at `<cwd>/secrets.json`. The
|
|
60
|
+
// CLI surfaces this on abort so the user knows to either re-run init in
|
|
61
|
+
// the same directory (the credentials will be reused) or delete the file.
|
|
62
|
+
readonly oauthCredentialsSaved: boolean
|
|
63
|
+
constructor(options: { oauthCredentialsSaved?: boolean } = {}) {
|
|
58
64
|
super('Wizard aborted by user')
|
|
59
65
|
this.name = 'WizardAbortedError'
|
|
66
|
+
this.oauthCredentialsSaved = options.oauthCredentialsSaved === true
|
|
60
67
|
}
|
|
61
68
|
}
|
|
62
69
|
|
|
@@ -102,11 +109,37 @@ export const init = defineCommand({
|
|
|
102
109
|
intro('Initializing TypeClaw...')
|
|
103
110
|
log.info('Press ESC at any prompt to go back. Press ESC twice in a row to abort.')
|
|
104
111
|
|
|
112
|
+
// Docker preflight runs BEFORE the wizard so an OAuth login (which the
|
|
113
|
+
// wizard fires the moment the user picks "OAuth (browser login)") doesn't
|
|
114
|
+
// burn a real browser flow on an agent folder we can't actually start.
|
|
115
|
+
// `runInit` re-runs the preflight as a defense-in-depth gate, but
|
|
116
|
+
// surfacing the failure here lets the user fix Docker without re-doing
|
|
117
|
+
// every wizard step.
|
|
118
|
+
const preflightSpinner = spinner()
|
|
119
|
+
preflightSpinner.start('Checking Docker...')
|
|
120
|
+
const preflight = await checkDockerAvailable()
|
|
121
|
+
if (!preflight.ok) {
|
|
122
|
+
preflightSpinner.error(preflightFailureSummary(preflight))
|
|
123
|
+
note(preflightFailureGuidance(preflight).join('\n'), 'Docker check failed')
|
|
124
|
+
process.exit(1)
|
|
125
|
+
}
|
|
126
|
+
preflightSpinner.stop('Docker is reachable.')
|
|
127
|
+
|
|
105
128
|
let collected: CollectedInputs
|
|
106
129
|
try {
|
|
107
130
|
collected = await collectWizardInputs(cwd, defaultWizardPrompts)
|
|
108
131
|
} catch (error) {
|
|
109
132
|
if (error instanceof WizardAbortedError) {
|
|
133
|
+
if (error.oauthCredentialsSaved) {
|
|
134
|
+
note(
|
|
135
|
+
[
|
|
136
|
+
'OAuth credentials were saved to `secrets.json` before you aborted.',
|
|
137
|
+
'Re-run `typeclaw init` here to pick up where you left off (the credentials',
|
|
138
|
+
'will be reused), or delete `secrets.json` if you want a clean restart.',
|
|
139
|
+
].join('\n'),
|
|
140
|
+
'Saved OAuth credentials',
|
|
141
|
+
)
|
|
142
|
+
}
|
|
110
143
|
cancel('Aborted.')
|
|
111
144
|
process.exit(0)
|
|
112
145
|
}
|
|
@@ -309,9 +342,24 @@ export interface WizardPrompts {
|
|
|
309
342
|
hasExistingChannelSecrets: (cwd: string, channel: Exclude<ChannelChoice, 'none'>) => Promise<boolean>
|
|
310
343
|
askReuseExistingChannel: (channel: Exclude<ChannelChoice, 'none'>) => Promise<StepResult<'reuse' | 'prompt'>>
|
|
311
344
|
runChannelFlow: (choice: ChannelChoice) => Promise<StepResult<CollectedInputs['channelSecrets']>>
|
|
312
|
-
|
|
345
|
+
runOAuthLogin: (
|
|
346
|
+
provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
|
|
347
|
+
cwd: string,
|
|
348
|
+
model: KnownModelRef,
|
|
349
|
+
) => Promise<OAuthLoginResult>
|
|
350
|
+
// Asked after a failed OAuth login. `apiKeyAvailable` is true when the
|
|
351
|
+
// provider also supports api-key auth (so the wizard can offer a fallback
|
|
352
|
+
// path); false for OAuth-only providers like openai-codex, where the only
|
|
353
|
+
// options are retry or abort.
|
|
354
|
+
askOAuthFailureRecovery: (
|
|
355
|
+
provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
|
|
356
|
+
reason: string,
|
|
357
|
+
apiKeyAvailable: boolean,
|
|
358
|
+
) => Promise<OAuthFailureRecovery>
|
|
313
359
|
}
|
|
314
360
|
|
|
361
|
+
export type OAuthFailureRecovery = 'retry' | 'api-key' | 'abort'
|
|
362
|
+
|
|
315
363
|
export const defaultWizardPrompts: WizardPrompts = {
|
|
316
364
|
loadCatalog,
|
|
317
365
|
readExistingApiKey: readExistingProviderApiKey,
|
|
@@ -326,10 +374,8 @@ export const defaultWizardPrompts: WizardPrompts = {
|
|
|
326
374
|
hasExistingChannelSecrets,
|
|
327
375
|
askReuseExistingChannel,
|
|
328
376
|
runChannelFlow,
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
runLogin: makeOAuthLoginRunner(buildOAuthCallbacks(provider.name)),
|
|
332
|
-
}),
|
|
377
|
+
runOAuthLogin: (provider, cwd, model) => makeOAuthLoginRunner(buildOAuthCallbacks(provider.name))({ cwd, model }),
|
|
378
|
+
askOAuthFailureRecovery,
|
|
333
379
|
}
|
|
334
380
|
|
|
335
381
|
export async function collectWizardInputs(cwd: string, prompts: WizardPrompts): Promise<CollectedInputs> {
|
|
@@ -337,10 +383,15 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
|
|
|
337
383
|
const state: WizardState = { catalog }
|
|
338
384
|
let step: StepId = 'pick-provider'
|
|
339
385
|
let pendingBackOrigin: StepId | null = null
|
|
386
|
+
let oauthCredentialsSaved = false
|
|
387
|
+
|
|
388
|
+
const abort = (): never => {
|
|
389
|
+
throw new WizardAbortedError({ oauthCredentialsSaved })
|
|
390
|
+
}
|
|
340
391
|
|
|
341
392
|
const onResult = <T>(currentStep: StepId, result: StepResult<T>): StepResult<T> => {
|
|
342
393
|
if (result.kind === 'back') {
|
|
343
|
-
if (pendingBackOrigin === currentStep)
|
|
394
|
+
if (pendingBackOrigin === currentStep) abort()
|
|
344
395
|
pendingBackOrigin = currentStep
|
|
345
396
|
} else if (!result.auto) {
|
|
346
397
|
pendingBackOrigin = null
|
|
@@ -410,7 +461,32 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
|
|
|
410
461
|
}
|
|
411
462
|
state.authMethod = result.value
|
|
412
463
|
if (result.value === 'oauth') {
|
|
413
|
-
|
|
464
|
+
// Run the browser login eagerly so the user sees the OAuth URL the
|
|
465
|
+
// moment they pick "OAuth (browser login)" — not at the end of the
|
|
466
|
+
// wizard. On failure we ask the user how to recover (retry / fall
|
|
467
|
+
// back to API key / abort) instead of dumping them back into the
|
|
468
|
+
// auth method picker with no guidance.
|
|
469
|
+
const login = await runOAuthLoginSafely(prompts, provider, cwd, state.model!.ref)
|
|
470
|
+
if (!login.ok) {
|
|
471
|
+
const recovery = await prompts.askOAuthFailureRecovery(
|
|
472
|
+
provider,
|
|
473
|
+
login.reason,
|
|
474
|
+
providerSupportsApiKey(provider),
|
|
475
|
+
)
|
|
476
|
+
// The recovery prompt is a fresh user decision, so it must clear
|
|
477
|
+
// any back-token left over from an earlier step. Without this, a
|
|
478
|
+
// sequence like `enter-api-key → back → autoValue('oauth') →
|
|
479
|
+
// OAuth fails → recovery=api-key → enter-api-key` would treat the
|
|
480
|
+
// user's NEXT back press as a double-back and abort the wizard.
|
|
481
|
+
pendingBackOrigin = null
|
|
482
|
+
if (recovery === 'abort') abort()
|
|
483
|
+
state.authMethod = recovery === 'api-key' ? 'api-key' : undefined
|
|
484
|
+
state.llmAuth = undefined
|
|
485
|
+
step = recovery === 'api-key' ? 'enter-api-key' : 'pick-auth-method'
|
|
486
|
+
break
|
|
487
|
+
}
|
|
488
|
+
oauthCredentialsSaved = true
|
|
489
|
+
state.llmAuth = { kind: 'oauth-completed' }
|
|
414
490
|
step = stepAfterDefaultAuth(state)
|
|
415
491
|
} else {
|
|
416
492
|
step = 'enter-api-key'
|
|
@@ -498,7 +574,25 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
|
|
|
498
574
|
}
|
|
499
575
|
state.visionAuthMethod = result.value
|
|
500
576
|
if (result.value === 'oauth') {
|
|
501
|
-
|
|
577
|
+
// Same eager-login + recovery-prompt rationale as the default-provider branch above.
|
|
578
|
+
const login = await runOAuthLoginSafely(prompts, provider, cwd, state.visionModel!.ref)
|
|
579
|
+
if (!login.ok) {
|
|
580
|
+
const recovery = await prompts.askOAuthFailureRecovery(
|
|
581
|
+
provider,
|
|
582
|
+
login.reason,
|
|
583
|
+
providerSupportsApiKey(provider),
|
|
584
|
+
)
|
|
585
|
+
// See the matching pendingBackOrigin reset in the default-provider
|
|
586
|
+
// branch above — same reasoning applies to vision auth recovery.
|
|
587
|
+
pendingBackOrigin = null
|
|
588
|
+
if (recovery === 'abort') abort()
|
|
589
|
+
state.visionAuthMethod = recovery === 'api-key' ? 'api-key' : undefined
|
|
590
|
+
state.visionLlmAuth = undefined
|
|
591
|
+
step = recovery === 'api-key' ? 'enter-vision-api-key' : 'pick-vision-auth-method'
|
|
592
|
+
break
|
|
593
|
+
}
|
|
594
|
+
oauthCredentialsSaved = true
|
|
595
|
+
state.visionLlmAuth = { kind: 'oauth-completed' }
|
|
502
596
|
step = 'pick-channel'
|
|
503
597
|
} else {
|
|
504
598
|
step = 'enter-vision-api-key'
|
|
@@ -570,6 +664,25 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
|
|
|
570
664
|
}
|
|
571
665
|
}
|
|
572
666
|
|
|
667
|
+
// Belt-and-suspenders wrapper: `makeOAuthLoginRunner` already catches the
|
|
668
|
+
// upstream pi-ai login flow and returns `{ ok: false, reason }`, but the
|
|
669
|
+
// wizard cannot afford ANY uncaught throw from a custom runner (test seam,
|
|
670
|
+
// future plugin-contributed runner) — it would bubble out of
|
|
671
|
+
// `collectWizardInputs` and exit the whole init. Coerce unexpected throws to
|
|
672
|
+
// the normal failure path so the recovery prompt always fires.
|
|
673
|
+
async function runOAuthLoginSafely(
|
|
674
|
+
prompts: WizardPrompts,
|
|
675
|
+
provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
|
|
676
|
+
cwd: string,
|
|
677
|
+
model: KnownModelRef,
|
|
678
|
+
): Promise<OAuthLoginResult> {
|
|
679
|
+
try {
|
|
680
|
+
return await prompts.runOAuthLogin(provider, cwd, model)
|
|
681
|
+
} catch (error) {
|
|
682
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
573
686
|
function finalize(state: WizardState, channelSecrets: CollectedInputs['channelSecrets']): CollectedInputs {
|
|
574
687
|
return {
|
|
575
688
|
model: state.model!,
|
|
@@ -768,6 +881,28 @@ async function askApiKey(provider: (typeof KNOWN_PROVIDERS)[KnownProviderId]): P
|
|
|
768
881
|
return value(apiKey)
|
|
769
882
|
}
|
|
770
883
|
|
|
884
|
+
async function askOAuthFailureRecovery(
|
|
885
|
+
provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
|
|
886
|
+
reason: string,
|
|
887
|
+
apiKeyAvailable: boolean,
|
|
888
|
+
): Promise<OAuthFailureRecovery> {
|
|
889
|
+
note(reason, `${provider.name} OAuth login failed`)
|
|
890
|
+
const options: Array<{ value: OAuthFailureRecovery; label: string; hint?: string }> = [
|
|
891
|
+
{ value: 'retry', label: 'Retry OAuth login' },
|
|
892
|
+
]
|
|
893
|
+
if (apiKeyAvailable) {
|
|
894
|
+
options.push({ value: 'api-key', label: `Use a ${provider.name} API key instead` })
|
|
895
|
+
}
|
|
896
|
+
options.push({ value: 'abort', label: 'Abort init', hint: 'you can re-run `typeclaw init` later' })
|
|
897
|
+
const choice = await select<OAuthFailureRecovery>({
|
|
898
|
+
message: 'What next?',
|
|
899
|
+
options,
|
|
900
|
+
initialValue: 'retry',
|
|
901
|
+
})
|
|
902
|
+
if (isCancel(choice)) return 'abort'
|
|
903
|
+
return choice
|
|
904
|
+
}
|
|
905
|
+
|
|
771
906
|
async function pickChannel(initial: ChannelChoice | undefined): Promise<StepResult<ChannelChoice>> {
|
|
772
907
|
const choice = await select<ChannelChoice>({
|
|
773
908
|
message: 'Pick a channel to wire (you can add more later by editing typeclaw.json + secrets.json)',
|
|
@@ -1261,37 +1396,6 @@ export async function decideExistingApiKeyReuse(
|
|
|
1261
1396
|
return reuse === true ? 'reuse' : 'prompt'
|
|
1262
1397
|
}
|
|
1263
1398
|
|
|
1264
|
-
// Wraps the OAuth lifecycle into the same clack idiom the rest of the wizard
|
|
1265
|
-
// uses: a spinner over the "waiting for login" period, with onAuth printing
|
|
1266
|
-
// the URL the user needs to open and onPrompt falling back to a `text`
|
|
1267
|
-
// prompt for the manual code path. The spinner is started by onAuth and
|
|
1268
|
-
// stopped by the caller (runInit) — we don't try to manage it here because
|
|
1269
|
-
// the spinner lifecycle has to span emit('start') -> emit('done').
|
|
1270
|
-
function buildOAuthCallbacks(providerName: string) {
|
|
1271
|
-
return {
|
|
1272
|
-
onAuth: (url: string, instructions?: string) => {
|
|
1273
|
-
// Don't put the URL inside note(): clack wraps long lines with the box
|
|
1274
|
-
// border `│` on each wrapped segment, which corrupts the URL when the
|
|
1275
|
-
// user copy-pastes it. Keep instructional text in the box, but print
|
|
1276
|
-
// the URL itself as a bare console.log line that any terminal will
|
|
1277
|
-
// hyperlink intact.
|
|
1278
|
-
const preamble = [`Open this URL in your browser to authorize ${providerName}.`]
|
|
1279
|
-
if (instructions) preamble.push('', instructions)
|
|
1280
|
-
note(preamble.join('\n'), 'Browser login')
|
|
1281
|
-
console.log(url)
|
|
1282
|
-
console.log('')
|
|
1283
|
-
},
|
|
1284
|
-
onProgress: (message: string) => {
|
|
1285
|
-
log.info(message)
|
|
1286
|
-
},
|
|
1287
|
-
onPrompt: async (message: string, placeholder?: string): Promise<string | null> => {
|
|
1288
|
-
const value = await text({ message, ...(placeholder !== undefined ? { placeholder } : {}) })
|
|
1289
|
-
if (isCancel(value)) return null
|
|
1290
|
-
return value
|
|
1291
|
-
},
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
1399
|
function uniqueProviders(options: ModelOption[]): KnownProviderId[] {
|
|
1296
1400
|
const seen = new Set<KnownProviderId>()
|
|
1297
1401
|
const out: KnownProviderId[] = []
|
package/src/cli/model.ts
CHANGED
|
@@ -157,15 +157,23 @@ const listSub = defineCommand({
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
const profileWidth = Math.max(7, ...entries.map((e) => e.profile.length))
|
|
160
|
-
const
|
|
160
|
+
const refDisplay = (e: (typeof entries)[number]): string =>
|
|
161
|
+
e.refs.length > 1 ? `${e.ref} ${c.dim(`(+${e.refs.length - 1} fallback)`)}` : e.ref
|
|
162
|
+
const refWidth = Math.max(3, ...entries.map((e) => e.ref.length + (e.refs.length > 1 ? 14 : 0)))
|
|
161
163
|
|
|
162
164
|
const header = `${'PROFILE'.padEnd(profileWidth)} ${'REF'.padEnd(refWidth)} PROVIDER STATUS`
|
|
163
165
|
console.log(c.dim(header))
|
|
164
166
|
for (const e of entries) {
|
|
165
167
|
const star = e.isDefault ? c.cyan('*') : ' '
|
|
166
168
|
const status = e.credentialStatus === 'available' ? c.green('ok') : c.yellow('missing-credentials')
|
|
167
|
-
const line = `${star}${e.profile.padEnd(profileWidth - 1)} ${e.
|
|
169
|
+
const line = `${star}${e.profile.padEnd(profileWidth - 1)} ${refDisplay(e).padEnd(refWidth)} ${e.providerId.padEnd(12)} ${status}`
|
|
168
170
|
console.log(line)
|
|
171
|
+
if (e.refs.length > 1) {
|
|
172
|
+
for (let i = 1; i < e.refs.length; i++) {
|
|
173
|
+
const fb = e.refs[i]!
|
|
174
|
+
console.log(`${' '.padEnd(profileWidth + 2)}↳ ${c.dim(fb)}`)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
169
177
|
}
|
|
170
178
|
},
|
|
171
179
|
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { isCancel, log, note, text } from '@clack/prompts'
|
|
2
|
+
|
|
3
|
+
import type { OAuthCallbacks } from '@/init/oauth-login'
|
|
4
|
+
|
|
5
|
+
// Shared between `typeclaw init` (src/cli/init.ts) and `typeclaw provider
|
|
6
|
+
// add/set` (src/cli/provider.ts). Both call into the same OAuth runner, so
|
|
7
|
+
// they need to render the same UX: a note() box with the URL + cross-device
|
|
8
|
+
// guidance, a `text()` prompt for the post-callback manual fallback, and a
|
|
9
|
+
// concurrent `onManualCodeInput` prompt for users whose browser is on a
|
|
10
|
+
// different host than the CLI. See src/init/oauth-login.ts for the contract
|
|
11
|
+
// on each callback and why onManualCodeInput is required for cross-device.
|
|
12
|
+
export function buildOAuthCallbacks(providerName: string): OAuthCallbacks {
|
|
13
|
+
return {
|
|
14
|
+
onAuth: (url, instructions) => {
|
|
15
|
+
// Don't put the URL inside note(): clack wraps long lines with the box
|
|
16
|
+
// border `│` on each wrapped segment, which corrupts the URL when the
|
|
17
|
+
// user copy-pastes it. Keep instructional text in the box, but print
|
|
18
|
+
// the URL itself as a bare console.log line that any terminal will
|
|
19
|
+
// hyperlink intact.
|
|
20
|
+
const preamble = [
|
|
21
|
+
`Open this URL in your browser to sign in to ${providerName}.`,
|
|
22
|
+
'',
|
|
23
|
+
'If your browser shows "this site can\'t be reached" after you sign in,',
|
|
24
|
+
'copy the full address from the top of the browser and paste it below.',
|
|
25
|
+
]
|
|
26
|
+
if (instructions) preamble.push('', instructions)
|
|
27
|
+
note(preamble.join('\n'), 'Browser login')
|
|
28
|
+
console.log(url)
|
|
29
|
+
console.log('')
|
|
30
|
+
},
|
|
31
|
+
onProgress: (message) => {
|
|
32
|
+
log.info(message)
|
|
33
|
+
},
|
|
34
|
+
onPrompt: async (message, placeholder) => {
|
|
35
|
+
const value = await text({ message, ...(placeholder !== undefined ? { placeholder } : {}) })
|
|
36
|
+
if (isCancel(value)) return null
|
|
37
|
+
return value
|
|
38
|
+
},
|
|
39
|
+
onManualCodeInput: async () => {
|
|
40
|
+
const value = await text({
|
|
41
|
+
message:
|
|
42
|
+
'If your browser shows "this site can\'t be reached" after you sign in, copy the full address from the top of the browser and paste it here:',
|
|
43
|
+
placeholder: 'http://localhost:1455/auth/callback?code=...&state=...',
|
|
44
|
+
})
|
|
45
|
+
if (isCancel(value)) throw new Error('Login cancelled by user')
|
|
46
|
+
return value
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/cli/provider.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cancel, intro, isCancel, log,
|
|
1
|
+
import { cancel, intro, isCancel, log, password, select } from '@clack/prompts'
|
|
2
2
|
import { defineCommand } from 'citty'
|
|
3
3
|
|
|
4
4
|
import {
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import { findAgentDir, isInitialized } from '@/init'
|
|
18
18
|
import { makeOAuthLoginRunner } from '@/init/oauth-login'
|
|
19
19
|
|
|
20
|
+
import { buildOAuthCallbacks } from './oauth-callbacks'
|
|
20
21
|
import { c, done, errorLine } from './ui'
|
|
21
22
|
|
|
22
23
|
const addSub = defineCommand({
|
|
@@ -366,25 +367,7 @@ async function runOAuthLogin(cwd: string, providerId: KnownProviderId): Promise<
|
|
|
366
367
|
}
|
|
367
368
|
const modelRef = `${providerId}/${ref}` as const
|
|
368
369
|
|
|
369
|
-
const
|
|
370
|
-
onAuth: (url: string, instructions?: string) => {
|
|
371
|
-
const preamble = [`Open this URL in your browser to authorize ${provider.name}.`]
|
|
372
|
-
if (instructions) preamble.push('', instructions)
|
|
373
|
-
note(preamble.join('\n'), 'Browser login')
|
|
374
|
-
console.log(url)
|
|
375
|
-
console.log('')
|
|
376
|
-
},
|
|
377
|
-
onProgress: (message: string) => {
|
|
378
|
-
log.info(message)
|
|
379
|
-
},
|
|
380
|
-
onPrompt: async (message: string, placeholder?: string): Promise<string | null> => {
|
|
381
|
-
const value = await text({ message, ...(placeholder !== undefined ? { placeholder } : {}) })
|
|
382
|
-
if (isCancel(value)) return null
|
|
383
|
-
return value
|
|
384
|
-
},
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
const runner = makeOAuthLoginRunner(callbacks)
|
|
370
|
+
const runner = makeOAuthLoginRunner(buildOAuthCallbacks(provider.name))
|
|
388
371
|
const result = await runner({ cwd, model: modelRef as Parameters<typeof runner>[0]['model'] })
|
|
389
372
|
if (!result.ok) return { ok: false, reason: result.reason }
|
|
390
373
|
return { ok: true }
|
package/src/config/config.ts
CHANGED
|
@@ -103,6 +103,19 @@ const dockerfileObjectSchema = z.object({
|
|
|
103
103
|
// edit. Opt-out with `cloudflared: false` to skip the ~35MB binary on
|
|
104
104
|
// agents that don't use tunnels.
|
|
105
105
|
cloudflared: z.boolean().default(true),
|
|
106
|
+
// Install xvfb so the entrypoint shim can spawn an Xvfb virtual X
|
|
107
|
+
// server and export DISPLAY, giving headed Chrome (agent-browser
|
|
108
|
+
// --headed, Playwright headful) a real X11 display to connect to.
|
|
109
|
+
// Default `true` because modern bot detection (Akamai/Cloudflare Bot
|
|
110
|
+
// Manager) fingerprints `--headless` and `--headless=new` regardless
|
|
111
|
+
// of UA spoof, and headed-via-Xvfb is the cheapest path to a passing
|
|
112
|
+
// fingerprint from a container. Opt-out with `xvfb: false` to save
|
|
113
|
+
// ~5MB image + ~10MB RAM/idle on agents that never touch a browser.
|
|
114
|
+
// The shim self-heals — when Xvfb isn't on PATH it execs the agent
|
|
115
|
+
// directly, no other Dockerfile or shim change needed. Boolean-only
|
|
116
|
+
// because the package has no API-stable versioning that matters
|
|
117
|
+
// here; xvfb tracks the upstream X server release.
|
|
118
|
+
xvfb: z.boolean().default(true),
|
|
106
119
|
append: z.array(dockerfileLineSchema).default([]),
|
|
107
120
|
})
|
|
108
121
|
|
|
@@ -278,32 +291,50 @@ const tunnelsArraySchema = z
|
|
|
278
291
|
}
|
|
279
292
|
})
|
|
280
293
|
|
|
281
|
-
// `models`
|
|
294
|
+
// `models` maps a profile name to one or more curated model refs. The
|
|
282
295
|
// `default` profile is mandatory; every other profile is optional and falls
|
|
283
296
|
// back to `default` at resolution time (see `resolveProfile`).
|
|
284
297
|
//
|
|
298
|
+
// Each value is either a single `KnownModelRef` or a non-empty array of refs
|
|
299
|
+
// forming a fallback chain: when a turn against the first ref fails (hard
|
|
300
|
+
// throw or a soft provider error), the runtime disposes the failed session
|
|
301
|
+
// and replays the same prompt against the next ref. Schema accepts both
|
|
302
|
+
// shapes for ergonomics; the parsed value is always normalised to a
|
|
303
|
+
// non-empty array so downstream consumers read a uniform `KnownModelRef[]`.
|
|
304
|
+
//
|
|
285
305
|
// Profile names are open strings; the runtime recognizes a handful of
|
|
286
306
|
// well-known names by convention (`default`, `fast`, `deep`, `vision`) but
|
|
287
|
-
// any string is valid.
|
|
288
|
-
//
|
|
289
|
-
// with a one-time warning at session construction.
|
|
307
|
+
// any string is valid. Unknown profile names resolve to `default` with a
|
|
308
|
+
// one-time warning at session construction.
|
|
290
309
|
//
|
|
291
310
|
// The pre-multi-model schema had a single `model: KnownModelRef` at the top
|
|
292
311
|
// level. `migrateLegacyConfigShape` rewrites that to `models: { default: ... }`
|
|
293
312
|
// on first load (and writes the result back to disk + commits via
|
|
294
313
|
// `persistMigratedConfig`), so every downstream consumer sees the new shape.
|
|
314
|
+
const modelRefOrChainSchema = z
|
|
315
|
+
.union([
|
|
316
|
+
z.enum(knownModelRefs),
|
|
317
|
+
z
|
|
318
|
+
.array(z.enum(knownModelRefs))
|
|
319
|
+
.min(1)
|
|
320
|
+
// Reject exact duplicates in a chain — retrying the same ref after the
|
|
321
|
+
// same class of failure is almost certainly a config typo, and silently
|
|
322
|
+
// deduping would mask user intent. Different models from the same
|
|
323
|
+
// provider (e.g. `["openai/gpt-5.4-nano", "openai/gpt-5.4-mini"]`) are
|
|
324
|
+
// still valid because they hit distinct upstream endpoints.
|
|
325
|
+
.refine((arr) => new Set(arr).size === arr.length, {
|
|
326
|
+
message: 'models chain must not contain duplicate refs',
|
|
327
|
+
}),
|
|
328
|
+
])
|
|
329
|
+
.transform((value) => (Array.isArray(value) ? value : [value]))
|
|
295
330
|
export const modelsSchema = z
|
|
296
|
-
.record(z.string().min(1),
|
|
331
|
+
.record(z.string().min(1), modelRefOrChainSchema)
|
|
297
332
|
.refine((m) => 'default' in m, { message: 'models.default is required' })
|
|
298
333
|
|
|
299
|
-
// Zod's `z.record(..., refine)` doesn't refine the inferred type
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
// resolveProfile) reads `models.default` on the hot path; without this
|
|
304
|
-
// narrowing they all have to assert or `?? throw`, which is noise around an
|
|
305
|
-
// invariant the schema already enforces.
|
|
306
|
-
export type Models = Record<string, KnownModelRef> & { default: KnownModelRef }
|
|
334
|
+
// Zod's `z.record(..., refine)` doesn't refine the inferred type. The
|
|
335
|
+
// `default` key is schema-enforced, so we narrow it here to spare every
|
|
336
|
+
// consumer the `T | undefined` assertion noise.
|
|
337
|
+
export type Models = Record<string, KnownModelRef[]> & { default: KnownModelRef[] }
|
|
307
338
|
|
|
308
339
|
export const configSchema = z
|
|
309
340
|
.object({
|
|
@@ -311,8 +342,10 @@ export const configSchema = z
|
|
|
311
342
|
port: z.number().int().min(1).max(65535).default(DEFAULT_PORT),
|
|
312
343
|
// `default(() => ...)` ensures every parsed config has at least
|
|
313
344
|
// `models.default`. Direct `.default({ default: ... })` would short-circuit
|
|
314
|
-
// the refinement, so we lean on the lazy thunk form.
|
|
315
|
-
|
|
345
|
+
// the refinement, so we lean on the lazy thunk form. The default value is
|
|
346
|
+
// shaped to match the post-transform output (always `KnownModelRef[]`),
|
|
347
|
+
// not the user-facing input shape.
|
|
348
|
+
models: modelsSchema.default(() => ({ default: [DEFAULT_MODEL_REF] })) as unknown as z.ZodType<Models>,
|
|
316
349
|
// Defaults to `[]` so the field can be omitted from `typeclaw.json` (no
|
|
317
350
|
// host paths exposed) without failing the whole config load. `typeclaw
|
|
318
351
|
// init` omits this field so users don't see noise for the empty case.
|
|
@@ -345,26 +378,28 @@ export function resolveModel(ref: KnownModelRef): Model<'openai-completions'> |
|
|
|
345
378
|
return KNOWN_PROVIDERS[providerId].models[modelId as never]
|
|
346
379
|
}
|
|
347
380
|
|
|
348
|
-
// Resolves a profile name (e.g. `fast`, `deep`, `vision`) to
|
|
349
|
-
//
|
|
381
|
+
// Resolves a profile name (e.g. `fast`, `deep`, `vision`) to its fallback
|
|
382
|
+
// chain. Unknown profiles fall back to `default` so callers can pass through
|
|
350
383
|
// arbitrary subagent-declared or user-overridden strings without crashing.
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
384
|
+
// `refs` is non-empty (the schema guarantees `default` exists and every value
|
|
385
|
+
// is at least one ref). `ref` is the head of the chain — the model the
|
|
386
|
+
// session is created with first. Callers that don't implement fallback can
|
|
387
|
+
// keep reading `ref`; fallback-aware callers iterate `refs`.
|
|
354
388
|
export type ResolvedProfile = {
|
|
355
389
|
ref: KnownModelRef
|
|
390
|
+
refs: KnownModelRef[]
|
|
356
391
|
profile: string
|
|
357
392
|
fellBackToDefault: boolean
|
|
358
393
|
}
|
|
359
394
|
|
|
360
395
|
export function resolveProfile(models: Models, name: string | undefined): ResolvedProfile {
|
|
361
396
|
const requested = name ?? 'default'
|
|
362
|
-
const
|
|
363
|
-
if (
|
|
364
|
-
return { ref, profile: requested, fellBackToDefault: false }
|
|
397
|
+
const refs = models[requested]
|
|
398
|
+
if (refs !== undefined) {
|
|
399
|
+
return { ref: refs[0]!, refs, profile: requested, fellBackToDefault: false }
|
|
365
400
|
}
|
|
366
401
|
const fallback = models.default
|
|
367
|
-
return { ref: fallback, profile: 'default', fellBackToDefault: true }
|
|
402
|
+
return { ref: fallback[0]!, refs: fallback, profile: 'default', fellBackToDefault: true }
|
|
368
403
|
}
|
|
369
404
|
|
|
370
405
|
// Resolves a mount's `path` field to an absolute host path, mirroring shell
|
|
@@ -17,8 +17,16 @@ const CONFIG_FILE = 'typeclaw.json'
|
|
|
17
17
|
|
|
18
18
|
export type ModelProfileEntry = {
|
|
19
19
|
profile: string
|
|
20
|
+
// Head of the fallback chain. Kept under the legacy `ref` name so callers
|
|
21
|
+
// that only care about the active model (the common case) don't need to
|
|
22
|
+
// dereference `refs[0]`. The chain itself is exposed as `refs`.
|
|
20
23
|
ref: KnownModelRef
|
|
24
|
+
refs: KnownModelRef[]
|
|
21
25
|
providerId: KnownProviderId
|
|
26
|
+
// Credential status for every provider referenced by the chain. The chain's
|
|
27
|
+
// overall status is `available` only when every entry resolves; otherwise
|
|
28
|
+
// it is `missing-credentials`, and `missingProviders` names which.
|
|
29
|
+
missingProviders: KnownProviderId[]
|
|
22
30
|
isDefault: boolean
|
|
23
31
|
credentialStatus: 'available' | 'missing-credentials'
|
|
24
32
|
}
|
|
@@ -28,14 +36,18 @@ export type ModelMutationResult = { ok: true } | { ok: false; reason: string }
|
|
|
28
36
|
export function listModelProfiles(cwd: string, env: NodeJS.ProcessEnv = process.env): ModelProfileEntry[] {
|
|
29
37
|
const models = loadConfigSync(cwd).models
|
|
30
38
|
const out: ModelProfileEntry[] = []
|
|
31
|
-
for (const [profile,
|
|
32
|
-
const
|
|
39
|
+
for (const [profile, refs] of Object.entries(models)) {
|
|
40
|
+
const headRef = refs[0]!
|
|
41
|
+
const providerId = providerForModelRef(headRef)
|
|
42
|
+
const missingProviders = uniqueProviders(refs).filter((p) => !hasUsableCredential(cwd, p, env))
|
|
33
43
|
out.push({
|
|
34
44
|
profile,
|
|
35
|
-
ref,
|
|
45
|
+
ref: headRef,
|
|
46
|
+
refs,
|
|
36
47
|
providerId,
|
|
48
|
+
missingProviders,
|
|
37
49
|
isDefault: profile === 'default',
|
|
38
|
-
credentialStatus:
|
|
50
|
+
credentialStatus: missingProviders.length === 0 ? 'available' : 'missing-credentials',
|
|
39
51
|
})
|
|
40
52
|
}
|
|
41
53
|
// `default` always first; remaining profiles alphabetical so output is stable.
|
|
@@ -47,6 +59,19 @@ export function listModelProfiles(cwd: string, env: NodeJS.ProcessEnv = process.
|
|
|
47
59
|
return out
|
|
48
60
|
}
|
|
49
61
|
|
|
62
|
+
function uniqueProviders(refs: ReadonlyArray<KnownModelRef>): KnownProviderId[] {
|
|
63
|
+
const seen = new Set<KnownProviderId>()
|
|
64
|
+
const out: KnownProviderId[] = []
|
|
65
|
+
for (const r of refs) {
|
|
66
|
+
const p = providerForModelRef(r)
|
|
67
|
+
if (!seen.has(p)) {
|
|
68
|
+
seen.add(p)
|
|
69
|
+
out.push(p)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
74
|
+
|
|
50
75
|
export function listAvailableModelRefs(): KnownModelRef[] {
|
|
51
76
|
return listKnownModelRefs()
|
|
52
77
|
}
|
|
@@ -158,14 +183,18 @@ export function removeProfile(cwd: string, profile: string): ModelMutationResult
|
|
|
158
183
|
|
|
159
184
|
function writeProfile(cwd: string, profile: string, ref: KnownModelRef, message: string): ModelMutationResult {
|
|
160
185
|
const existing = readModelsRaw(cwd)
|
|
161
|
-
const next = existing === null ? { default: ref } : { ...existing, [profile]: ref }
|
|
186
|
+
const next: Record<string, string | string[]> = existing === null ? { default: ref } : { ...existing, [profile]: ref }
|
|
162
187
|
if (existing === null && profile !== 'default') {
|
|
163
188
|
next.default = ref
|
|
164
189
|
}
|
|
165
190
|
return writeModels(cwd, next, message)
|
|
166
191
|
}
|
|
167
192
|
|
|
168
|
-
function writeModels(
|
|
193
|
+
function writeModels(
|
|
194
|
+
cwd: string,
|
|
195
|
+
models: Record<string, string | string[]>,
|
|
196
|
+
commitMessage: string,
|
|
197
|
+
): ModelMutationResult {
|
|
169
198
|
const path = join(cwd, CONFIG_FILE)
|
|
170
199
|
let parsed: Record<string, unknown>
|
|
171
200
|
try {
|
|
@@ -207,10 +236,15 @@ function writeModels(cwd: string, models: Record<string, string>, commitMessage:
|
|
|
207
236
|
return { ok: true }
|
|
208
237
|
}
|
|
209
238
|
|
|
210
|
-
|
|
239
|
+
// Returns the raw `models` block from disk in its on-disk shape: each value
|
|
240
|
+
// is `string | string[]` (the user-facing schema). Writers preserve whichever
|
|
241
|
+
// shape was already present for profiles they don't touch — converting a
|
|
242
|
+
// hand-authored fallback chain back to a single string would silently drop
|
|
243
|
+
// the fallback.
|
|
244
|
+
function readModelsRaw(cwd: string): Record<string, string | string[]> | null {
|
|
211
245
|
try {
|
|
212
246
|
const raw = readFileSync(join(cwd, CONFIG_FILE), 'utf8')
|
|
213
|
-
const parsed = JSON.parse(raw) as { models?: Record<string, string> }
|
|
247
|
+
const parsed = JSON.parse(raw) as { models?: Record<string, string | string[]> }
|
|
214
248
|
return parsed.models ?? null
|
|
215
249
|
} catch (error) {
|
|
216
250
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|