typeclaw 0.9.2 → 0.11.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 +2 -2
- package/src/agent/index.ts +46 -11
- package/src/agent/restart-handoff/index.ts +91 -0
- package/src/agent/restart-handoff/paths.ts +11 -0
- package/src/agent/session-origin.ts +30 -10
- package/src/agent/subagent-completion-reminder.ts +4 -2
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tools/restart.ts +42 -1
- package/src/agent/tools/skip-response.ts +157 -0
- package/src/bundled-plugins/memory/README.md +18 -2
- package/src/bundled-plugins/memory/index.ts +108 -6
- package/src/bundled-plugins/memory/memory-logger.ts +33 -24
- package/src/bundled-plugins/security/index.ts +19 -17
- package/src/bundled-plugins/security/permissions.ts +9 -8
- package/src/bundled-plugins/security/policies/cron-promotion.ts +26 -9
- package/src/bundled-plugins/security/policies/git-exfil.ts +23 -15
- package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
- package/src/bundled-plugins/security/policies/role-promotion.ts +25 -18
- package/src/channels/adapters/github/auth-app.ts +53 -9
- package/src/channels/adapters/github/auth-pat.ts +4 -1
- package/src/channels/adapters/github/auth.ts +10 -0
- package/src/channels/adapters/github/event-permissions.ts +83 -0
- package/src/channels/adapters/github/inbound.ts +126 -1
- package/src/channels/adapters/github/index.ts +60 -66
- package/src/channels/adapters/github/outbound.ts +65 -17
- package/src/channels/adapters/github/permission-guidance.ts +169 -0
- package/src/channels/adapters/github/team-membership.ts +56 -0
- package/src/channels/router.ts +313 -10
- package/src/channels/schema.ts +22 -0
- package/src/channels/types.ts +1 -1
- package/src/cli/channel.ts +135 -38
- package/src/cli/cron.ts +1 -1
- package/src/cli/init.ts +133 -86
- package/src/cli/inspect-controller.ts +66 -0
- package/src/cli/inspect.ts +99 -14
- package/src/cli/role.ts +2 -2
- package/src/cli/run.ts +24 -5
- package/src/cli/tui.ts +34 -10
- package/src/cli/tunnel.ts +453 -14
- package/src/config/config.ts +35 -7
- package/src/config/providers.ts +82 -56
- package/src/cron/bridge.ts +25 -4
- package/src/hostd/daemon.ts +44 -24
- package/src/hostd/portbroker-manager.ts +19 -3
- package/src/init/dockerfile.ts +52 -0
- package/src/init/env-file.ts +66 -0
- package/src/init/gitignore.ts +8 -0
- package/src/init/hatching.ts +32 -5
- package/src/init/index.ts +131 -39
- package/src/init/validate-api-key.ts +31 -0
- package/src/inspect/index.ts +47 -6
- package/src/inspect/loop.ts +31 -0
- package/src/inspect/replay.ts +15 -1
- package/src/permissions/builtins.ts +29 -21
- package/src/permissions/permissions.ts +32 -5
- package/src/role-claim/code.ts +9 -9
- package/src/role-claim/controller.ts +3 -2
- package/src/role-claim/match-rule.ts +14 -19
- package/src/role-claim/pending.ts +2 -2
- package/src/run/codex-fetch-observer.ts +377 -0
- package/src/run/index.ts +12 -2
- package/src/server/index.ts +59 -1
- package/src/shared/protocol.ts +1 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +45 -1
- package/src/skills/typeclaw-codex-cli/SKILL.md +1 -1
- package/src/skills/typeclaw-codex-cli/references/auth-flow.md +14 -1
- package/src/skills/typeclaw-config/SKILL.md +7 -1
- package/src/skills/typeclaw-config/references/recommended-mounts.md +233 -0
- package/src/skills/typeclaw-permissions/SKILL.md +24 -18
- package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
- package/src/tui/index.ts +17 -5
- package/src/tunnels/index.ts +1 -0
- package/src/tunnels/manager.ts +18 -0
- package/src/tunnels/providers/cloudflare-named.ts +224 -0
- package/src/tunnels/types.ts +17 -1
- package/typeclaw.schema.json +120 -7
package/src/init/index.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { resolveBaseImageVersion, resolveScaffoldVersion } from './cli-version'
|
|
|
21
21
|
import { buildDockerfile, DOCKERFILE } from './dockerfile'
|
|
22
22
|
import { installGithubWebhooksEagerly, type EagerGithubWebhookInstallResult } from './github-webhook-install'
|
|
23
23
|
import { buildGitignore, GITIGNORE_FILE } from './gitignore'
|
|
24
|
-
import {
|
|
24
|
+
import { buildHatchingPrompt } from './hatching'
|
|
25
25
|
import type { OAuthLoginRunner, OAuthLoginResult } from './oauth-login'
|
|
26
26
|
import { GITKEEP_FILE, PACKAGES_DIR } from './paths'
|
|
27
27
|
import { type InstallResult, type InstallRunner, runBunInstall } from './run-bun-install'
|
|
@@ -33,6 +33,8 @@ export { formatEagerGithubWebhookInstallResult, installGithubWebhooksEagerly } f
|
|
|
33
33
|
|
|
34
34
|
export { GITKEEP_FILE, PACKAGES_DIR } from './paths'
|
|
35
35
|
|
|
36
|
+
export { appendOrReplaceEnvKey, hasEnvKey, readEnvFile } from './env-file'
|
|
37
|
+
|
|
36
38
|
const CONFIG_FILE = 'typeclaw.json'
|
|
37
39
|
const CRON_FILE = 'cron.json'
|
|
38
40
|
const PACKAGE_FILE = 'package.json'
|
|
@@ -78,13 +80,24 @@ export type KakaotalkAuthResult = { ok: true } | { ok: false; reason: string }
|
|
|
78
80
|
export type GithubInitCredentials = {
|
|
79
81
|
webhookSecret: string
|
|
80
82
|
tunnelProvider: GithubTunnelProvider
|
|
83
|
+
// Set when `tunnelProvider === 'external'`. The user-supplied https URL
|
|
84
|
+
// that GitHub POSTs to and that lands in `channels.github.webhookUrl`.
|
|
81
85
|
webhookUrl?: string
|
|
82
86
|
webhookPort?: number
|
|
87
|
+
// Set when `tunnelProvider === 'cloudflare-named'`. The Public Hostname
|
|
88
|
+
// configured in the Cloudflare dashboard; also used as the webhook URL for
|
|
89
|
+
// eager registration (GitHub POSTs through the named tunnel to the in-
|
|
90
|
+
// container webhook server). Kept distinct from `webhookUrl` so the
|
|
91
|
+
// wizard's branching stays readable and the resulting `tunnels[].hostname`
|
|
92
|
+
// ends up in the right field rather than being smuggled through
|
|
93
|
+
// `externalUrl`.
|
|
94
|
+
hostname?: string
|
|
95
|
+
tokenEnv?: string
|
|
83
96
|
repos: string[]
|
|
84
97
|
auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
|
|
85
98
|
}
|
|
86
99
|
|
|
87
|
-
export type GithubTunnelProvider = 'cloudflare-quick' | 'external' | 'none'
|
|
100
|
+
export type GithubTunnelProvider = 'cloudflare-quick' | 'cloudflare-named' | 'external' | 'none'
|
|
88
101
|
|
|
89
102
|
export type InitStepEvent =
|
|
90
103
|
| { step: 'preflight'; phase: 'start' }
|
|
@@ -414,9 +427,10 @@ export async function defaultRunHatching({
|
|
|
414
427
|
await runClaim({ url, configuredChannels })
|
|
415
428
|
}
|
|
416
429
|
|
|
430
|
+
const typeclawJsonContent = await readTypeclawJsonRaw(cwd)
|
|
417
431
|
const tui = tuiFactory({
|
|
418
432
|
url: buildTuiUrl(hostPort, launch.tuiToken),
|
|
419
|
-
initialPrompt:
|
|
433
|
+
initialPrompt: buildHatchingPrompt(typeclawJsonContent !== undefined ? { typeclawJsonContent } : undefined),
|
|
420
434
|
})
|
|
421
435
|
await tui.run()
|
|
422
436
|
return { ok: true }
|
|
@@ -425,6 +439,17 @@ export async function defaultRunHatching({
|
|
|
425
439
|
}
|
|
426
440
|
}
|
|
427
441
|
|
|
442
|
+
// Read the raw bytes of `typeclaw.json` to inline into the hatching prompt.
|
|
443
|
+
// Returns `undefined` on any failure so the agent falls back to reading the
|
|
444
|
+
// file itself — hatching must not abort just because we couldn't pre-fetch.
|
|
445
|
+
async function readTypeclawJsonRaw(cwd: string): Promise<string | undefined> {
|
|
446
|
+
try {
|
|
447
|
+
return await readFile(join(cwd, CONFIG_FILE), 'utf8')
|
|
448
|
+
} catch {
|
|
449
|
+
return undefined
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
428
453
|
export type ClaimRunner = (options: { url: string; configuredChannels: readonly ChannelKind[] }) => Promise<void>
|
|
429
454
|
|
|
430
455
|
const defaultRunClaim: ClaimRunner = async ({ url, configuredChannels }) => {
|
|
@@ -785,6 +810,37 @@ export async function readExistingProviderApiKey(root: string, providerId: Known
|
|
|
785
810
|
return new SecretsBackend(join(root, 'secrets.json')).tryReadProviderApiKeySync(providerId)
|
|
786
811
|
}
|
|
787
812
|
|
|
813
|
+
// Detects whether the requested provider has usable OAuth credentials already
|
|
814
|
+
// written to `secrets.json#providers.<oauthProviderId>`. Used by the init
|
|
815
|
+
// wizard's auto-resume path: when the user picks an OAuth-capable provider
|
|
816
|
+
// and credentials already exist on disk from a prior partial run, skip the
|
|
817
|
+
// browser login entirely instead of dragging them through a second OAuth
|
|
818
|
+
// flow.
|
|
819
|
+
//
|
|
820
|
+
// Mirrors `readExistingProviderApiKey`'s contract — returns `false` when:
|
|
821
|
+
// - The provider has no OAuth support (`oauthProviderId === null`)
|
|
822
|
+
// - The file doesn't exist
|
|
823
|
+
// - The slot exists but has the wrong shape (api-key instead of oauth, or
|
|
824
|
+
// missing access_token)
|
|
825
|
+
// - The token is empty / whitespace
|
|
826
|
+
//
|
|
827
|
+
// We do NOT validate the token's freshness here. A stale access_token still
|
|
828
|
+
// counts as "exists" — pi-ai's secrets store handles refresh on first use,
|
|
829
|
+
// and surfacing an "expired token" check at init-time would require a
|
|
830
|
+
// network call we'd rather not run during a wizard. The runtime will fall
|
|
831
|
+
// back to OAuth login on use if refresh fails; that's a separate UX path.
|
|
832
|
+
export async function hasExistingOAuthCredentials(root: string, providerId: KnownProviderId): Promise<boolean> {
|
|
833
|
+
const provider = KNOWN_PROVIDERS[providerId]
|
|
834
|
+
if (provider.oauthProviderId === null) return false
|
|
835
|
+
const backend = new SecretsBackend(join(root, 'secrets.json'))
|
|
836
|
+
const providers = backend.tryReadProvidersSync()
|
|
837
|
+
const credential = providers[provider.oauthProviderId]
|
|
838
|
+
if (credential === undefined) return false
|
|
839
|
+
if (credential.type !== 'oauth') return false
|
|
840
|
+
const accessToken = (credential as { access_token?: unknown }).access_token
|
|
841
|
+
return typeof accessToken === 'string' && accessToken.trim().length > 0
|
|
842
|
+
}
|
|
843
|
+
|
|
788
844
|
// Detects whether the requested channel already has usable credentials in
|
|
789
845
|
// `secrets.json#channels`, so the init wizard can offer to reuse them
|
|
790
846
|
// instead of re-prompting for tokens. Mirrors `readExistingProviderApiKey`:
|
|
@@ -937,6 +993,8 @@ export type AddChannelOptions = {
|
|
|
937
993
|
tunnelProvider: GithubTunnelProvider
|
|
938
994
|
webhookUrl?: string
|
|
939
995
|
webhookPort?: number
|
|
996
|
+
hostname?: string
|
|
997
|
+
tokenEnv?: string
|
|
940
998
|
repos: string[]
|
|
941
999
|
auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
|
|
942
1000
|
fetchImpl?: typeof fetch
|
|
@@ -1000,11 +1058,18 @@ async function maybeInstallGithubWebhooks(
|
|
|
1000
1058
|
options: Extract<AddChannelOptions, { channel: 'github' }>,
|
|
1001
1059
|
emit: (event: AddChannelStepEvent) => void,
|
|
1002
1060
|
): Promise<void> {
|
|
1003
|
-
|
|
1061
|
+
// For `external` and `cloudflare-named` we know the public URL up front
|
|
1062
|
+
// (user-supplied `webhookUrl` or dashboard-configured `hostname`), so we
|
|
1063
|
+
// can register the webhook on GitHub's side eagerly. For `cloudflare-quick`
|
|
1064
|
+
// the URL only exists once cloudflared has emitted it on stderr inside the
|
|
1065
|
+
// container, which hasn't happened yet at host-stage init/channel-add
|
|
1066
|
+
// time — registration is deferred to the adapter's first `start()`.
|
|
1067
|
+
const eagerUrl = resolveEagerWebhookUrl(options)
|
|
1068
|
+
if (eagerUrl === undefined) return
|
|
1004
1069
|
if (options.repos.length === 0) return
|
|
1005
1070
|
emit({ step: 'github-webhooks', phase: 'start' })
|
|
1006
1071
|
const result = await installGithubWebhooksEagerly({
|
|
1007
|
-
webhookUrl:
|
|
1072
|
+
webhookUrl: eagerUrl,
|
|
1008
1073
|
webhookSecret: options.webhookSecret,
|
|
1009
1074
|
repos: options.repos,
|
|
1010
1075
|
auth: options.auth,
|
|
@@ -1014,6 +1079,12 @@ async function maybeInstallGithubWebhooks(
|
|
|
1014
1079
|
emit({ step: 'github-webhooks', phase: 'done', result })
|
|
1015
1080
|
}
|
|
1016
1081
|
|
|
1082
|
+
function resolveEagerWebhookUrl(options: Extract<AddChannelOptions, { channel: 'github' }>): string | undefined {
|
|
1083
|
+
if (options.tunnelProvider === 'external') return options.webhookUrl
|
|
1084
|
+
if (options.tunnelProvider === 'cloudflare-named') return options.hostname
|
|
1085
|
+
return undefined
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1017
1088
|
function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
|
|
1018
1089
|
switch (options.channel) {
|
|
1019
1090
|
case 'discord-bot':
|
|
@@ -1112,24 +1183,20 @@ function mergeGithubTunnelConfig(
|
|
|
1112
1183
|
if (options.tunnelProvider === 'external' && options.webhookUrl === undefined) {
|
|
1113
1184
|
throw new Error('GitHub external tunnel requires webhookUrl')
|
|
1114
1185
|
}
|
|
1186
|
+
if (options.tunnelProvider === 'cloudflare-named') {
|
|
1187
|
+
if (options.hostname === undefined || options.hostname.trim() === '') {
|
|
1188
|
+
throw new Error('GitHub cloudflare-named tunnel requires hostname')
|
|
1189
|
+
}
|
|
1190
|
+
if (options.tokenEnv === undefined || options.tokenEnv.trim() === '') {
|
|
1191
|
+
throw new Error('GitHub cloudflare-named tunnel requires tokenEnv')
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1115
1194
|
|
|
1116
1195
|
const existingTunnels = Array.isArray(parsed.tunnels) ? parsed.tunnels : []
|
|
1117
|
-
const tunnel =
|
|
1118
|
-
options.tunnelProvider === 'external'
|
|
1119
|
-
? {
|
|
1120
|
-
name: 'github-webhook',
|
|
1121
|
-
provider: 'external',
|
|
1122
|
-
externalUrl: options.webhookUrl,
|
|
1123
|
-
for: { kind: 'channel', name: 'github' },
|
|
1124
|
-
}
|
|
1125
|
-
: {
|
|
1126
|
-
name: 'github-webhook',
|
|
1127
|
-
provider: 'cloudflare-quick',
|
|
1128
|
-
for: { kind: 'channel', name: 'github' },
|
|
1129
|
-
}
|
|
1196
|
+
const tunnel = buildGithubTunnelEntry(options)
|
|
1130
1197
|
parsed.tunnels = [...existingTunnels, tunnel]
|
|
1131
1198
|
|
|
1132
|
-
if (options.tunnelProvider === 'cloudflare-quick') {
|
|
1199
|
+
if (options.tunnelProvider === 'cloudflare-quick' || options.tunnelProvider === 'cloudflare-named') {
|
|
1133
1200
|
const docker = isObjectRecord(parsed.docker) ? { ...parsed.docker } : {}
|
|
1134
1201
|
const file = isObjectRecord(docker.file) ? { ...docker.file } : {}
|
|
1135
1202
|
file.cloudflared = true
|
|
@@ -1138,6 +1205,34 @@ function mergeGithubTunnelConfig(
|
|
|
1138
1205
|
}
|
|
1139
1206
|
}
|
|
1140
1207
|
|
|
1208
|
+
function buildGithubTunnelEntry(options: Extract<AddChannelOptions, { channel: 'github' }>): Record<string, unknown> {
|
|
1209
|
+
switch (options.tunnelProvider) {
|
|
1210
|
+
case 'external':
|
|
1211
|
+
return {
|
|
1212
|
+
name: 'github-webhook',
|
|
1213
|
+
provider: 'external',
|
|
1214
|
+
externalUrl: options.webhookUrl,
|
|
1215
|
+
for: { kind: 'channel', name: 'github' },
|
|
1216
|
+
}
|
|
1217
|
+
case 'cloudflare-quick':
|
|
1218
|
+
return {
|
|
1219
|
+
name: 'github-webhook',
|
|
1220
|
+
provider: 'cloudflare-quick',
|
|
1221
|
+
for: { kind: 'channel', name: 'github' },
|
|
1222
|
+
}
|
|
1223
|
+
case 'cloudflare-named':
|
|
1224
|
+
return {
|
|
1225
|
+
name: 'github-webhook',
|
|
1226
|
+
provider: 'cloudflare-named',
|
|
1227
|
+
for: { kind: 'channel', name: 'github' },
|
|
1228
|
+
hostname: options.hostname,
|
|
1229
|
+
tokenEnv: options.tokenEnv,
|
|
1230
|
+
}
|
|
1231
|
+
case 'none':
|
|
1232
|
+
throw new Error('buildGithubTunnelEntry called with tunnelProvider=none')
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1141
1236
|
// Init-side counterpart of runAddChannel's github branch. Same three writes
|
|
1142
1237
|
// (typeclaw.json#channels.github, secrets.json#channels.github, roles.member
|
|
1143
1238
|
// .match[]) but with overwrite semantics on the secrets/config side so a
|
|
@@ -1376,21 +1471,23 @@ export async function setChannelSecrets(
|
|
|
1376
1471
|
})
|
|
1377
1472
|
}
|
|
1378
1473
|
|
|
1379
|
-
// Discriminated union of what GitHub credentials the user wants to
|
|
1380
|
-
// The three secrets (PAT/private-key, webhook secret)
|
|
1474
|
+
// Discriminated union of what GitHub credentials the user wants to update.
|
|
1475
|
+
// The three secrets (PAT/private-key, webhook secret) update independently,
|
|
1381
1476
|
// so the CLI lets the user pick which one(s) to touch in a single call.
|
|
1382
|
-
// `auth.type`
|
|
1383
|
-
//
|
|
1384
|
-
//
|
|
1477
|
+
// `auth.type` may differ from the on-disk auth type — switching between PAT
|
|
1478
|
+
// and App auth replaces the entire auth block (no field carryover from the
|
|
1479
|
+
// previous auth type, since the two shapes share no fields beyond `type`).
|
|
1385
1480
|
export type GithubCredentialPatch = {
|
|
1386
1481
|
webhookSecret?: string
|
|
1387
1482
|
auth?: { type: 'pat'; pat: string } | { type: 'app'; privateKey: string; appId?: number; installationId?: number }
|
|
1388
1483
|
}
|
|
1389
1484
|
|
|
1390
|
-
//
|
|
1485
|
+
// Update one or more credential fields on an already-configured GitHub
|
|
1391
1486
|
// channel. Like setChannelSecrets, refuses when secrets.json has no
|
|
1392
|
-
// existing github entry.
|
|
1393
|
-
//
|
|
1487
|
+
// existing github entry. Supports both same-type rotation (preserves env
|
|
1488
|
+
// bindings, carries appId/installationId forward when not supplied) and
|
|
1489
|
+
// auth-type switching (replaces the entire auth block — see
|
|
1490
|
+
// `GithubCredentialPatch` above).
|
|
1394
1491
|
export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch): Promise<SetChannelTokensResult> {
|
|
1395
1492
|
if (!existsSync(join(cwd, CONFIG_FILE))) {
|
|
1396
1493
|
return {
|
|
@@ -1416,27 +1513,22 @@ export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch
|
|
|
1416
1513
|
if (patch.auth !== undefined) {
|
|
1417
1514
|
const existingAuth = block.auth
|
|
1418
1515
|
const existingAuthType = readGithubAuthTypeFromObject(existingAuth)
|
|
1419
|
-
|
|
1420
|
-
return {
|
|
1421
|
-
result: {
|
|
1422
|
-
ok: false,
|
|
1423
|
-
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.`,
|
|
1424
|
-
},
|
|
1425
|
-
}
|
|
1426
|
-
}
|
|
1516
|
+
const isSameType = existingAuthType === patch.auth.type
|
|
1427
1517
|
if (patch.auth.type === 'pat') {
|
|
1428
|
-
const previousToken =
|
|
1518
|
+
const previousToken =
|
|
1519
|
+
isSameType && isObjectRecord(existingAuth) ? (existingAuth as { token?: unknown }).token : undefined
|
|
1429
1520
|
block.auth = { type: 'pat', token: rotatedSecret(previousToken, patch.auth.pat) }
|
|
1430
1521
|
} else {
|
|
1431
|
-
const existingApp = isObjectRecord(existingAuth) ? (existingAuth as Record<string, unknown>) : {}
|
|
1522
|
+
const existingApp = isSameType && isObjectRecord(existingAuth) ? (existingAuth as Record<string, unknown>) : {}
|
|
1432
1523
|
const appId = patch.auth.appId ?? (existingApp.appId as number | undefined)
|
|
1433
1524
|
const installationId = patch.auth.installationId ?? (existingApp.installationId as number | undefined)
|
|
1434
1525
|
if (typeof appId !== 'number') {
|
|
1435
1526
|
return {
|
|
1436
1527
|
result: {
|
|
1437
1528
|
ok: false,
|
|
1438
|
-
reason:
|
|
1439
|
-
'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.'
|
|
1529
|
+
reason: isSameType
|
|
1530
|
+
? '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.'
|
|
1531
|
+
: 'github App auth requires appId when switching from PAT to App auth.',
|
|
1440
1532
|
},
|
|
1441
1533
|
}
|
|
1442
1534
|
}
|
|
@@ -60,6 +60,23 @@ export async function validateApiKey(
|
|
|
60
60
|
return { kind: 'skipped', reason: 'network-error', detail: 'unexpected response shape' }
|
|
61
61
|
}
|
|
62
62
|
if (res.status === 401 || res.status === 403) {
|
|
63
|
+
// Fireworks issues two key classes that probe the same /v1/models
|
|
64
|
+
// endpoint differently:
|
|
65
|
+
// * Standard keys (fw_...) → 200 with the models list
|
|
66
|
+
// * Fire Pass keys (fpk_...) → 403 with {"error":{"code":"FORBIDDEN",
|
|
67
|
+
// "message":"Fire Pass API keys are not authorized for this route."}}
|
|
68
|
+
// The 403 *proves* authentication succeeded — the route is just out of
|
|
69
|
+
// scope for the key. Fire Pass keys do work at chat-completions, which
|
|
70
|
+
// is exactly the surface typeclaw needs (the only Fireworks model wired
|
|
71
|
+
// here is the Fire Pass router `kimi-k2p6-turbo`). Treating that 403
|
|
72
|
+
// as `rejected` is the bug; recognize the marker and accept the key.
|
|
73
|
+
// Genuinely bad keys still come back as 401 UNAUTHORIZED, untouched.
|
|
74
|
+
if (providerId === 'fireworks' && res.status === 403) {
|
|
75
|
+
const body = await readCapped(res, MAX_BODY_BYTES)
|
|
76
|
+
if (body !== null && isFireworksFirePassForbidden(body)) {
|
|
77
|
+
return { kind: 'ok' }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
63
80
|
return { kind: 'rejected', status: res.status }
|
|
64
81
|
}
|
|
65
82
|
return { kind: 'skipped', reason: 'network-error', detail: `HTTP ${res.status}` }
|
|
@@ -74,6 +91,20 @@ export async function validateApiKey(
|
|
|
74
91
|
|
|
75
92
|
const MAX_BODY_BYTES = 4096
|
|
76
93
|
|
|
94
|
+
function isFireworksFirePassForbidden(body: string): boolean {
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(body) as { error?: { code?: unknown; message?: unknown } }
|
|
97
|
+
const err = parsed.error
|
|
98
|
+
if (!err || typeof err !== 'object') return false
|
|
99
|
+
if (err.code === 'FORBIDDEN' && typeof err.message === 'string' && err.message.includes('Fire Pass')) {
|
|
100
|
+
return true
|
|
101
|
+
}
|
|
102
|
+
return false
|
|
103
|
+
} catch {
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
77
108
|
async function isModelsListShape(res: Response): Promise<boolean> {
|
|
78
109
|
const text = await readCapped(res, MAX_BODY_BYTES)
|
|
79
110
|
if (text === null) return false
|
package/src/inspect/index.ts
CHANGED
|
@@ -16,6 +16,8 @@ export { replayJsonl } from './replay'
|
|
|
16
16
|
export { streamLive } from './live'
|
|
17
17
|
export { parseDuration, parseFilter } from './types'
|
|
18
18
|
export type { InspectCategory, InspectEvent, InspectFilter } from './types'
|
|
19
|
+
export { runInspectLoop } from './loop'
|
|
20
|
+
export type { RunInspectLoopOptions } from './loop'
|
|
19
21
|
|
|
20
22
|
export type RunInspectOptions = {
|
|
21
23
|
agentDir: string
|
|
@@ -29,9 +31,17 @@ export type RunInspectOptions = {
|
|
|
29
31
|
stderr: (line: string) => void
|
|
30
32
|
liveSource?: LiveSourceFactory
|
|
31
33
|
signal?: AbortSignal
|
|
34
|
+
// Aborting escSignal (and only escSignal) returns escToPicker=true so a
|
|
35
|
+
// caller-side loop can re-open the picker; signal still means process exit.
|
|
36
|
+
escSignal?: AbortSignal
|
|
37
|
+
liveHint?: string
|
|
32
38
|
}
|
|
33
39
|
|
|
34
|
-
export type
|
|
40
|
+
export type SelectSessionOptions = {
|
|
41
|
+
initialSessionId?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type SelectSession = (sessions: SessionSummary[], opts?: SelectSessionOptions) => Promise<SessionSummary | null>
|
|
35
45
|
|
|
36
46
|
export type LiveSourceFactory = (opts: {
|
|
37
47
|
sessionId: string
|
|
@@ -40,7 +50,9 @@ export type LiveSourceFactory = (opts: {
|
|
|
40
50
|
onSubscribed?: (sessionLive: boolean) => void
|
|
41
51
|
}) => AsyncIterable<InspectEvent>
|
|
42
52
|
|
|
43
|
-
export type RunInspectResult =
|
|
53
|
+
export type RunInspectResult =
|
|
54
|
+
| { ok: true; exitCode: 0; escToPicker?: boolean }
|
|
55
|
+
| { ok: false; exitCode: number; reason: string }
|
|
44
56
|
|
|
45
57
|
export async function runInspect(opts: RunInspectOptions): Promise<RunInspectResult> {
|
|
46
58
|
const filterResult = parseFilter(opts.filter)
|
|
@@ -59,7 +71,7 @@ export async function runInspect(opts: RunInspectOptions): Promise<RunInspectRes
|
|
|
59
71
|
const summary = await chooseSession(opts, sessionsDir, sinceMs)
|
|
60
72
|
if (!summary.ok) return summary
|
|
61
73
|
|
|
62
|
-
await streamSession({
|
|
74
|
+
const streamResult = await streamSession({
|
|
63
75
|
summary: summary.summary,
|
|
64
76
|
filter,
|
|
65
77
|
sinceMs,
|
|
@@ -69,7 +81,10 @@ export async function runInspect(opts: RunInspectOptions): Promise<RunInspectRes
|
|
|
69
81
|
stderr: opts.stderr,
|
|
70
82
|
...(opts.liveSource !== undefined ? { liveSource: opts.liveSource } : {}),
|
|
71
83
|
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
84
|
+
...(opts.escSignal !== undefined ? { escSignal: opts.escSignal } : {}),
|
|
85
|
+
...(opts.liveHint !== undefined ? { liveHint: opts.liveHint } : {}),
|
|
72
86
|
})
|
|
87
|
+
if (streamResult.escToPicker) return { ok: true, exitCode: 0, escToPicker: true }
|
|
73
88
|
return { ok: true, exitCode: 0 }
|
|
74
89
|
}
|
|
75
90
|
|
|
@@ -132,7 +147,9 @@ async function streamSession(opts: {
|
|
|
132
147
|
stderr: (line: string) => void
|
|
133
148
|
liveSource?: LiveSourceFactory
|
|
134
149
|
signal?: AbortSignal
|
|
135
|
-
|
|
150
|
+
escSignal?: AbortSignal
|
|
151
|
+
liveHint?: string
|
|
152
|
+
}): Promise<{ escToPicker: boolean }> {
|
|
136
153
|
if (!opts.json) writeHeader(opts.summary, opts.color, opts.stdout)
|
|
137
154
|
const emit = (event: InspectEvent): void => {
|
|
138
155
|
if (opts.sinceMs !== undefined && event.ts > 0 && event.ts < opts.sinceMs) return
|
|
@@ -144,20 +161,26 @@ async function streamSession(opts: {
|
|
|
144
161
|
}
|
|
145
162
|
}
|
|
146
163
|
|
|
164
|
+
const escAborted = (): boolean => opts.escSignal?.aborted === true
|
|
165
|
+
|
|
147
166
|
for await (const event of replayJsonl(opts.summary.sessionFile, { onWarn: opts.stderr })) {
|
|
167
|
+
if (escAborted()) return { escToPicker: true }
|
|
148
168
|
emit(event)
|
|
149
169
|
}
|
|
150
170
|
|
|
151
171
|
if (opts.liveSource === undefined) {
|
|
152
172
|
if (!opts.json) opts.stdout('─── end of transcript ───')
|
|
153
|
-
return
|
|
173
|
+
return { escToPicker: escAborted() }
|
|
154
174
|
}
|
|
155
175
|
|
|
176
|
+
if (escAborted()) return { escToPicker: true }
|
|
177
|
+
|
|
178
|
+
const combinedSignal = combineSignals(opts.signal, opts.escSignal)
|
|
156
179
|
let sessionLive = false
|
|
157
180
|
const liveIter = opts.liveSource({
|
|
158
181
|
sessionId: opts.summary.sessionId,
|
|
159
182
|
...(opts.sinceMs !== undefined ? { sinceMs: opts.sinceMs } : {}),
|
|
160
|
-
...(
|
|
183
|
+
...(combinedSignal !== undefined ? { signal: combinedSignal } : {}),
|
|
161
184
|
onSubscribed: (live) => {
|
|
162
185
|
sessionLive = live
|
|
163
186
|
},
|
|
@@ -170,6 +193,9 @@ async function streamSession(opts: {
|
|
|
170
193
|
opts.stdout(
|
|
171
194
|
divider(opts.color, sessionLive ? '─── live ───' : '─── live (session not in registry; broadcasts only) ───'),
|
|
172
195
|
)
|
|
196
|
+
if (opts.liveHint !== undefined && opts.liveHint !== '') {
|
|
197
|
+
opts.stdout(divider(opts.color, opts.liveHint))
|
|
198
|
+
}
|
|
173
199
|
liveAnnounced = true
|
|
174
200
|
}
|
|
175
201
|
emit(event)
|
|
@@ -178,6 +204,21 @@ async function streamSession(opts: {
|
|
|
178
204
|
opts.stderr(`live tail ended: ${err instanceof Error ? err.message : String(err)}`)
|
|
179
205
|
}
|
|
180
206
|
if (!opts.json) opts.stdout('─── end of transcript ───')
|
|
207
|
+
return { escToPicker: escAborted() && opts.signal?.aborted !== true }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function combineSignals(a: AbortSignal | undefined, b: AbortSignal | undefined): AbortSignal | undefined {
|
|
211
|
+
if (a === undefined) return b
|
|
212
|
+
if (b === undefined) return a
|
|
213
|
+
if (a.aborted) return a
|
|
214
|
+
if (b.aborted) return b
|
|
215
|
+
const ctrl = new AbortController()
|
|
216
|
+
const onAbort = (): void => {
|
|
217
|
+
ctrl.abort()
|
|
218
|
+
}
|
|
219
|
+
a.addEventListener('abort', onAbort, { once: true })
|
|
220
|
+
b.addEventListener('abort', onAbort, { once: true })
|
|
221
|
+
return ctrl.signal
|
|
181
222
|
}
|
|
182
223
|
|
|
183
224
|
function divider(color: boolean, text: string): string {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { runInspect, type RunInspectOptions, type RunInspectResult } from './index'
|
|
2
|
+
|
|
3
|
+
export type RunInspectLoopOptions = Omit<RunInspectOptions, 'escSignal'> & {
|
|
4
|
+
newEscSignal: () => AbortSignal
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunInspectResult> {
|
|
8
|
+
let sessionArg = opts.sessionIdOrPrefix
|
|
9
|
+
// Remember the last session the user picked from the interactive picker so
|
|
10
|
+
// an ESC-back-to-picker re-opens with that row pre-selected. The picker
|
|
11
|
+
// receives this through the `initialSessionId` hint on its second arg.
|
|
12
|
+
let lastPickedId: string | undefined
|
|
13
|
+
const wrappedSelectSession: typeof opts.selectSession = async (sessions, selectOpts) => {
|
|
14
|
+
const hint = selectOpts?.initialSessionId ?? lastPickedId
|
|
15
|
+
const picked = await opts.selectSession(sessions, hint !== undefined ? { initialSessionId: hint } : {})
|
|
16
|
+
if (picked !== null) lastPickedId = picked.sessionId
|
|
17
|
+
return picked
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
while (true) {
|
|
21
|
+
const escSignal = opts.newEscSignal()
|
|
22
|
+
const callOpts: RunInspectOptions = { ...opts, escSignal, selectSession: wrappedSelectSession }
|
|
23
|
+
if (sessionArg !== undefined) callOpts.sessionIdOrPrefix = sessionArg
|
|
24
|
+
else delete (callOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
|
|
25
|
+
|
|
26
|
+
const result = await runInspect(callOpts)
|
|
27
|
+
if (!result.ok) return result
|
|
28
|
+
if (result.escToPicker !== true) return result
|
|
29
|
+
sessionArg = undefined
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/inspect/replay.ts
CHANGED
|
@@ -66,7 +66,7 @@ function* eventsFromEntry(
|
|
|
66
66
|
if (!isMessageEntry(entry)) return
|
|
67
67
|
const message = entry.message
|
|
68
68
|
const role = message.role
|
|
69
|
-
const ts =
|
|
69
|
+
const ts = entryTimestampMs(entry, message)
|
|
70
70
|
if (role === 'user') {
|
|
71
71
|
const text = readTextContent(message.content)
|
|
72
72
|
if (text !== null) yield { cat: 'user', ts, text }
|
|
@@ -219,6 +219,20 @@ function readUsage(value: unknown): {
|
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
function entryTimestampMs(
|
|
223
|
+
entry: { type: 'message'; message: { role: string; [k: string]: unknown } },
|
|
224
|
+
message: { role: string; [k: string]: unknown },
|
|
225
|
+
): number {
|
|
226
|
+
return timestampMs(readField(entry, 'timestamp')) ?? timestampMs(readField(message, 'timestamp')) ?? 0
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function timestampMs(value: unknown): number | null {
|
|
230
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
231
|
+
if (typeof value !== 'string' || value === '') return null
|
|
232
|
+
const parsed = Date.parse(value)
|
|
233
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
234
|
+
}
|
|
235
|
+
|
|
222
236
|
function* readThinkingEvents(content: unknown, ts: number): Iterable<InspectEvent> {
|
|
223
237
|
if (!Array.isArray(content)) return
|
|
224
238
|
for (const block of content) {
|
|
@@ -29,21 +29,27 @@ export type BuiltinRoleSpec = {
|
|
|
29
29
|
readonly permissions: readonly string[]
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// guards require per-call ack from owner too (the audience-leak rule —
|
|
38
|
-
// owner-in-public-channel must not silently post credentials).
|
|
32
|
+
// Role-to-tier defaults form a strict tower:
|
|
33
|
+
// owner → bypass.low + bypass.medium + bypass.high
|
|
34
|
+
// trusted → bypass.low + bypass.medium
|
|
35
|
+
// member → bypass.low
|
|
36
|
+
// guest → no bypass
|
|
39
37
|
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
38
|
+
// `canBypass` in the bundled security plugin checks the specific tier
|
|
39
|
+
// string for the guard's severity, so each role must carry every tier
|
|
40
|
+
// string at or below its cap (tiers do not cascade implicitly).
|
|
41
|
+
//
|
|
42
|
+
// Owner also carries the wildcard sentinel: the sentinel expands to every
|
|
43
|
+
// plugin-contributed `security.bypass.*` string minus
|
|
44
|
+
// `ownerWildcardExclusions`. The bundled security plugin no longer excludes
|
|
45
|
+
// high-tier strings (owner is meant to bypass them by default under this
|
|
46
|
+
// model), so the sentinel covers per-guard high-tier strings too.
|
|
47
|
+
//
|
|
48
|
+
// Tradeoff: this gives owner audience-leak bypass without per-call ack.
|
|
49
|
+
// The owner-in-public-channel risk is now load-bearing on the operator
|
|
50
|
+
// scoping `roles.owner.match[]` tightly. Default match is TUI-only, where
|
|
51
|
+
// a human is present; configs that widen owner to a channel author should
|
|
52
|
+
// understand they have re-opened audience-leak for that author.
|
|
47
53
|
export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> = {
|
|
48
54
|
owner: {
|
|
49
55
|
match: [{ kind: 'tui' }],
|
|
@@ -57,6 +63,7 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
|
|
|
57
63
|
CORE_PERMISSIONS.subagentSpawnOperator,
|
|
58
64
|
'security.bypass.low',
|
|
59
65
|
'security.bypass.medium',
|
|
66
|
+
'security.bypass.high',
|
|
60
67
|
OWNER_SECURITY_WILDCARD,
|
|
61
68
|
],
|
|
62
69
|
},
|
|
@@ -70,6 +77,7 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
|
|
|
70
77
|
CORE_PERMISSIONS.subagentOutput,
|
|
71
78
|
CORE_PERMISSIONS.subagentSpawnOperator,
|
|
72
79
|
'security.bypass.low',
|
|
80
|
+
'security.bypass.medium',
|
|
73
81
|
],
|
|
74
82
|
},
|
|
75
83
|
member: {
|
|
@@ -79,6 +87,7 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
|
|
|
79
87
|
CORE_PERMISSIONS.subagentSpawn,
|
|
80
88
|
CORE_PERMISSIONS.subagentCancel,
|
|
81
89
|
CORE_PERMISSIONS.subagentOutput,
|
|
90
|
+
'security.bypass.low',
|
|
82
91
|
],
|
|
83
92
|
},
|
|
84
93
|
guest: {
|
|
@@ -88,13 +97,12 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
|
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
// Expands the owner wildcard sentinel against plugin-contributed
|
|
91
|
-
// `security.bypass.*` strings. `wildcardExclusions`
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
// flow through the non-sentinel branch).
|
|
100
|
+
// `security.bypass.*` strings. `wildcardExclusions` lets plugins opt
|
|
101
|
+
// specific strings OUT of the wildcard expansion. The bundled security
|
|
102
|
+
// plugin no longer excludes any high-tier strings — owner bypasses every
|
|
103
|
+
// security tier by default under the current role-tower model. The
|
|
104
|
+
// parameter is preserved for third-party plugins that want a different
|
|
105
|
+
// shape (e.g. a future audit-only plugin that never auto-flows to owner).
|
|
98
106
|
export function expandOwnerWildcard(
|
|
99
107
|
ownerPermissions: readonly string[],
|
|
100
108
|
pluginContributed: readonly string[],
|