typeclaw 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -15
- package/auth.schema.json +113 -0
- package/package.json +1 -1
- package/secrets.schema.json +113 -0
- package/src/agent/auth.ts +4 -2
- package/src/agent/index.ts +16 -28
- package/src/agent/model-fallback.ts +127 -0
- package/src/agent/session-meta.ts +1 -1
- package/src/agent/session-origin.ts +3 -2
- package/src/agent/tools/curl-impersonate.ts +300 -0
- package/src/agent/tools/ddg.ts +13 -88
- package/src/agent/tools/webfetch/fetch.ts +105 -2
- package/src/agent/tools/webfetch/tool.ts +4 -0
- package/src/bundled-plugins/agent-browser/shim.ts +47 -0
- package/src/bundled-plugins/backup/subagents.ts +2 -0
- package/src/bundled-plugins/memory/README.md +49 -12
- package/src/bundled-plugins/memory/citation-superset.ts +63 -0
- package/src/bundled-plugins/memory/dreaming.ts +105 -17
- package/src/bundled-plugins/memory/index.ts +2 -2
- package/src/bundled-plugins/memory/memory-logger.ts +45 -26
- package/src/bundled-plugins/memory/strength.ts +127 -0
- package/src/bundled-plugins/memory/topics.ts +75 -0
- package/src/bundled-plugins/security/index.ts +88 -43
- package/src/bundled-plugins/security/permissions.ts +36 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
- package/src/channels/adapters/github/auth-app.ts +120 -0
- package/src/channels/adapters/github/auth-pat.ts +50 -0
- package/src/channels/adapters/github/auth.ts +33 -0
- package/src/channels/adapters/github/channel-resolver.ts +30 -0
- package/src/channels/adapters/github/dedup.ts +26 -0
- package/src/channels/adapters/github/event-allowlist.ts +8 -0
- package/src/channels/adapters/github/fetch-attachment.ts +5 -0
- package/src/channels/adapters/github/history.ts +63 -0
- package/src/channels/adapters/github/inbound.ts +286 -0
- package/src/channels/adapters/github/index.ts +370 -0
- package/src/channels/adapters/github/managed-path.ts +54 -0
- package/src/channels/adapters/github/membership.ts +35 -0
- package/src/channels/adapters/github/outbound.ts +145 -0
- package/src/channels/adapters/github/webhook-register.ts +349 -0
- package/src/channels/manager.ts +94 -9
- package/src/channels/router.ts +194 -28
- package/src/channels/schema.ts +31 -1
- package/src/channels/tunnel-bridge.ts +51 -0
- package/src/channels/types.ts +3 -1
- package/src/cli/builtins.ts +28 -0
- package/src/cli/channel.ts +511 -25
- package/src/cli/container-command-client.ts +244 -0
- package/src/cli/cron.ts +173 -0
- package/src/cli/host-command-runner.ts +150 -0
- package/src/cli/index.ts +42 -1
- package/src/cli/init.ts +400 -67
- package/src/cli/model.ts +14 -4
- package/src/cli/oauth-callbacks.ts +49 -0
- package/src/cli/plugin-command-help.ts +49 -0
- package/src/cli/plugin-commands-dispatch.ts +112 -0
- package/src/cli/plugin-commands.ts +118 -0
- package/src/cli/provider.ts +3 -20
- package/src/cli/tui.ts +10 -2
- package/src/cli/tunnel.ts +533 -0
- package/src/cli/ui.ts +8 -3
- package/src/config/config.ts +134 -24
- package/src/config/models-mutation.ts +42 -8
- package/src/config/providers-mutation.ts +12 -8
- package/src/container/start.ts +48 -4
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +174 -48
- package/src/cron/index.ts +19 -2
- package/src/cron/list.ts +105 -0
- package/src/cron/scheduler.ts +12 -3
- package/src/cron/schema.ts +11 -3
- package/src/doctor/checks.ts +0 -50
- package/src/init/dockerfile.ts +165 -13
- package/src/init/ensure-deps.ts +15 -4
- package/src/init/github-webhook-install.ts +109 -0
- package/src/init/hatching.ts +2 -2
- package/src/init/index.ts +519 -12
- package/src/init/oauth-login.ts +17 -3
- package/src/init/run-bun-install.ts +17 -3
- package/src/init/run-owner-claim.ts +11 -2
- package/src/permissions/builtins.ts +29 -2
- package/src/permissions/match-rule.ts +24 -2
- package/src/permissions/permissions.ts +24 -7
- package/src/permissions/resolve.ts +1 -0
- package/src/plugin/define.ts +44 -1
- package/src/plugin/index.ts +18 -3
- package/src/plugin/manager.ts +16 -0
- package/src/plugin/registry.ts +85 -3
- package/src/plugin/types.ts +144 -1
- package/src/plugin/zod-introspect.ts +100 -0
- package/src/role-claim/match-rule.ts +2 -1
- package/src/run/index.ts +112 -4
- package/src/secrets/index.ts +1 -1
- package/src/secrets/schema.ts +21 -0
- package/src/server/command-runner.ts +476 -0
- package/src/server/index.ts +388 -5
- package/src/shared/index.ts +8 -0
- package/src/shared/protocol.ts +80 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
- package/src/skills/typeclaw-config/SKILL.md +27 -26
- package/src/skills/typeclaw-cron/SKILL.md +234 -3
- package/src/skills/typeclaw-memory/SKILL.md +25 -15
- package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
- package/src/skills/typeclaw-permissions/SKILL.md +35 -16
- package/src/skills/typeclaw-plugins/SKILL.md +251 -5
- package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
- package/src/test-helpers/wait-for.ts +50 -0
- package/src/tui/index.ts +70 -7
- package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
- package/src/tunnels/events.ts +14 -0
- package/src/tunnels/index.ts +12 -0
- package/src/tunnels/log-ring.ts +54 -0
- package/src/tunnels/manager.ts +139 -0
- package/src/tunnels/providers/cloudflare-quick.ts +189 -0
- package/src/tunnels/providers/external.ts +53 -0
- package/src/tunnels/quick-url-parser.ts +5 -0
- package/src/tunnels/types.ts +43 -0
- package/src/usage/report.ts +15 -12
- package/typeclaw.schema.json +311 -26
package/src/config/config.ts
CHANGED
|
@@ -86,6 +86,36 @@ const dockerfileObjectSchema = z.object({
|
|
|
86
86
|
gh: dockerfileFeatureSchema.default(true),
|
|
87
87
|
python: z.boolean().default(true),
|
|
88
88
|
tmux: dockerfileFeatureSchema.default(true),
|
|
89
|
+
// `fonts-noto-cjk` is a ~56MB metapackage that makes Chromium render
|
|
90
|
+
// Korean/Japanese/Chinese glyphs correctly in screenshots, `page.pdf()`,
|
|
91
|
+
// and any other raster output the agent-browser plugin produces. Default
|
|
92
|
+
// `true` because the alternative — silent tofu boxes (□□□) in CJK
|
|
93
|
+
// screenshots — is a confusing failure mode that an agent cannot self-
|
|
94
|
+
// diagnose from a screenshot it took itself. Opt-out with `cjkFonts:
|
|
95
|
+
// false` to save the ~56MB on agents that never touch CJK content.
|
|
96
|
+
// Boolean-only (no version pin) because the package is a metapackage
|
|
97
|
+
// tracking the upstream Noto release and version pinning offers no
|
|
98
|
+
// practical value.
|
|
99
|
+
cjkFonts: z.boolean().default(true),
|
|
100
|
+
// Opt into the cloudflared layer for `cloudflare-quick` tunnels. Default
|
|
101
|
+
// `true` so `tunnel add` / `channel add github` with the default Cloudflare
|
|
102
|
+
// Quick provider works on the next `start` without a separate Dockerfile
|
|
103
|
+
// edit. Opt-out with `cloudflared: false` to skip the ~35MB binary on
|
|
104
|
+
// agents that don't use tunnels.
|
|
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),
|
|
89
119
|
append: z.array(dockerfileLineSchema).default([]),
|
|
90
120
|
})
|
|
91
121
|
|
|
@@ -205,32 +235,106 @@ export const networkSchema = z
|
|
|
205
235
|
|
|
206
236
|
export type NetworkConfig = z.infer<typeof networkSchema>
|
|
207
237
|
|
|
208
|
-
//
|
|
238
|
+
// Reverse-proxy tunnels expose a container-private port to the public internet
|
|
239
|
+
// via a managed subprocess (cloudflared) or a user-supplied external URL.
|
|
240
|
+
// See AGENTS.md `## Tunnels`. PR 2 ships `cloudflare-quick`; `cloudflare-named`
|
|
241
|
+
// remains deferred to PR 3. Keeping the enum scoped to what's implemented means
|
|
242
|
+
// validateConfig() rejects unsupported providers at `typeclaw start` time,
|
|
243
|
+
// before the container is torn down and rebuilt. `restart-required` because
|
|
244
|
+
// the tunnel manager reads this list once at boot.
|
|
245
|
+
const tunnelForSchema = z.discriminatedUnion('kind', [
|
|
246
|
+
z.object({ kind: z.literal('channel'), name: z.string().trim().min(1) }),
|
|
247
|
+
z.object({ kind: z.literal('manual') }),
|
|
248
|
+
])
|
|
249
|
+
|
|
250
|
+
const tunnelEntrySchema = z
|
|
251
|
+
.object({
|
|
252
|
+
name: z
|
|
253
|
+
.string()
|
|
254
|
+
.min(1)
|
|
255
|
+
.regex(/^[a-z0-9][a-z0-9-_]*$/, {
|
|
256
|
+
message: 'tunnel name must match /^[a-z0-9][a-z0-9-_]*$/ (lowercase, digits, dashes, underscores)',
|
|
257
|
+
}),
|
|
258
|
+
provider: z.enum(['external', 'cloudflare-quick']),
|
|
259
|
+
for: tunnelForSchema,
|
|
260
|
+
externalUrl: z
|
|
261
|
+
.string()
|
|
262
|
+
.url()
|
|
263
|
+
.refine((u) => u.startsWith('https://'), { message: 'externalUrl must use https://' })
|
|
264
|
+
.optional(),
|
|
265
|
+
upstreamPort: z.number().int().min(1).max(65535).optional(),
|
|
266
|
+
})
|
|
267
|
+
.refine((v) => v.provider !== 'external' || (v.externalUrl !== undefined && v.externalUrl.trim() !== ''), {
|
|
268
|
+
message: "tunnels[].externalUrl is required when provider is 'external'",
|
|
269
|
+
})
|
|
270
|
+
.refine((v) => v.for.kind !== 'manual' || v.upstreamPort !== undefined, {
|
|
271
|
+
message: "tunnels[].upstreamPort is required when for.kind is 'manual'",
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
const tunnelsArraySchema = z
|
|
275
|
+
.array(tunnelEntrySchema)
|
|
276
|
+
.default([])
|
|
277
|
+
.superRefine((entries, ctx) => {
|
|
278
|
+
const seen = new Map<string, number>()
|
|
279
|
+
for (let i = 0; i < entries.length; i++) {
|
|
280
|
+
const name = entries[i]!.name
|
|
281
|
+
const prev = seen.get(name)
|
|
282
|
+
if (prev !== undefined) {
|
|
283
|
+
ctx.addIssue({
|
|
284
|
+
code: 'custom',
|
|
285
|
+
path: [i, 'name'],
|
|
286
|
+
message: `tunnels[${i}].name duplicates tunnels[${prev}].name ('${name}')`,
|
|
287
|
+
})
|
|
288
|
+
} else {
|
|
289
|
+
seen.set(name, i)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
// `models` maps a profile name to one or more curated model refs. The
|
|
209
295
|
// `default` profile is mandatory; every other profile is optional and falls
|
|
210
296
|
// back to `default` at resolution time (see `resolveProfile`).
|
|
211
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
|
+
//
|
|
212
305
|
// Profile names are open strings; the runtime recognizes a handful of
|
|
213
306
|
// well-known names by convention (`default`, `fast`, `deep`, `vision`) but
|
|
214
|
-
// any string is valid.
|
|
215
|
-
//
|
|
216
|
-
// 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.
|
|
217
309
|
//
|
|
218
310
|
// The pre-multi-model schema had a single `model: KnownModelRef` at the top
|
|
219
311
|
// level. `migrateLegacyConfigShape` rewrites that to `models: { default: ... }`
|
|
220
312
|
// on first load (and writes the result back to disk + commits via
|
|
221
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]))
|
|
222
330
|
export const modelsSchema = z
|
|
223
|
-
.record(z.string().min(1),
|
|
331
|
+
.record(z.string().min(1), modelRefOrChainSchema)
|
|
224
332
|
.refine((m) => 'default' in m, { message: 'models.default is required' })
|
|
225
333
|
|
|
226
|
-
// Zod's `z.record(..., refine)` doesn't refine the inferred type
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
// resolveProfile) reads `models.default` on the hot path; without this
|
|
231
|
-
// narrowing they all have to assert or `?? throw`, which is noise around an
|
|
232
|
-
// invariant the schema already enforces.
|
|
233
|
-
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[] }
|
|
234
338
|
|
|
235
339
|
export const configSchema = z
|
|
236
340
|
.object({
|
|
@@ -238,8 +342,10 @@ export const configSchema = z
|
|
|
238
342
|
port: z.number().int().min(1).max(65535).default(DEFAULT_PORT),
|
|
239
343
|
// `default(() => ...)` ensures every parsed config has at least
|
|
240
344
|
// `models.default`. Direct `.default({ default: ... })` would short-circuit
|
|
241
|
-
// the refinement, so we lean on the lazy thunk form.
|
|
242
|
-
|
|
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>,
|
|
243
349
|
// Defaults to `[]` so the field can be omitted from `typeclaw.json` (no
|
|
244
350
|
// host paths exposed) without failing the whole config load. `typeclaw
|
|
245
351
|
// init` omits this field so users don't see noise for the empty case.
|
|
@@ -258,6 +364,7 @@ export const configSchema = z
|
|
|
258
364
|
docker: dockerSchema,
|
|
259
365
|
git: gitSchema,
|
|
260
366
|
roles: rolesConfigSchema.optional(),
|
|
367
|
+
tunnels: tunnelsArraySchema,
|
|
261
368
|
})
|
|
262
369
|
.catchall(z.unknown())
|
|
263
370
|
|
|
@@ -271,26 +378,28 @@ export function resolveModel(ref: KnownModelRef): Model<'openai-completions'> |
|
|
|
271
378
|
return KNOWN_PROVIDERS[providerId].models[modelId as never]
|
|
272
379
|
}
|
|
273
380
|
|
|
274
|
-
// Resolves a profile name (e.g. `fast`, `deep`, `vision`) to
|
|
275
|
-
//
|
|
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
|
|
276
383
|
// arbitrary subagent-declared or user-overridden strings without crashing.
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
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`.
|
|
280
388
|
export type ResolvedProfile = {
|
|
281
389
|
ref: KnownModelRef
|
|
390
|
+
refs: KnownModelRef[]
|
|
282
391
|
profile: string
|
|
283
392
|
fellBackToDefault: boolean
|
|
284
393
|
}
|
|
285
394
|
|
|
286
395
|
export function resolveProfile(models: Models, name: string | undefined): ResolvedProfile {
|
|
287
396
|
const requested = name ?? 'default'
|
|
288
|
-
const
|
|
289
|
-
if (
|
|
290
|
-
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 }
|
|
291
400
|
}
|
|
292
401
|
const fallback = models.default
|
|
293
|
-
return { ref: fallback, profile: 'default', fellBackToDefault: true }
|
|
402
|
+
return { ref: fallback[0]!, refs: fallback, profile: 'default', fellBackToDefault: true }
|
|
294
403
|
}
|
|
295
404
|
|
|
296
405
|
// Resolves a mount's `path` field to an absolute host path, mirroring shell
|
|
@@ -369,6 +478,7 @@ export const FIELD_EFFECTS: Record<string, FieldEffect> = {
|
|
|
369
478
|
channels: 'applied',
|
|
370
479
|
portForward: 'restart-required',
|
|
371
480
|
network: 'restart-required',
|
|
481
|
+
tunnels: 'restart-required',
|
|
372
482
|
'docker.file': 'restart-required',
|
|
373
483
|
'git.ignore': 'restart-required',
|
|
374
484
|
// Split: `match` lists are reload-safe (typeclaw role claim, hand-edits
|
|
@@ -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
|
|
@@ -136,8 +136,8 @@ export function findModelsReferencingProvider(cwd: string, providerId: string):
|
|
|
136
136
|
const models = readModelsOrNull(cwd)
|
|
137
137
|
if (models === null) return []
|
|
138
138
|
const out: string[] = []
|
|
139
|
-
for (const [profile,
|
|
140
|
-
if (refTargetsProvider(
|
|
139
|
+
for (const [profile, refs] of Object.entries(models)) {
|
|
140
|
+
if (refs.some((r) => refTargetsProvider(r, providerId))) out.push(profile)
|
|
141
141
|
}
|
|
142
142
|
return out
|
|
143
143
|
}
|
|
@@ -212,12 +212,16 @@ function readEnvKey(env: NodeJS.ProcessEnv, key: string): string | undefined {
|
|
|
212
212
|
function buildProviderReferenceMap(models: Models | null): Map<string, string[]> {
|
|
213
213
|
const out = new Map<string, string[]>()
|
|
214
214
|
if (models === null) return out
|
|
215
|
-
for (const [profile,
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
215
|
+
for (const [profile, refs] of Object.entries(models)) {
|
|
216
|
+
for (const ref of refs) {
|
|
217
|
+
const providerId = safeProviderForRef(ref)
|
|
218
|
+
if (providerId === null) continue
|
|
219
|
+
const existing = out.get(providerId) ?? []
|
|
220
|
+
if (!existing.includes(profile)) {
|
|
221
|
+
existing.push(profile)
|
|
222
|
+
out.set(providerId, existing)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
221
225
|
}
|
|
222
226
|
return out
|
|
223
227
|
}
|
package/src/container/start.ts
CHANGED
|
@@ -87,7 +87,7 @@ export type StartOptions = {
|
|
|
87
87
|
// Hostd's supervisor restart callback already runs inside the daemon process.
|
|
88
88
|
// Reusing that daemon avoids a self-shutdown when disk source has drifted.
|
|
89
89
|
reuseCurrentHostDaemon?: boolean
|
|
90
|
-
ensureDeps?: (cwd: string) => Promise<EnsureDepsResult>
|
|
90
|
+
ensureDeps?: (cwd: string, opts?: { force?: boolean }) => Promise<EnsureDepsResult>
|
|
91
91
|
// Test seam for the typeclaw-version auto-upgrade. Production callers omit
|
|
92
92
|
// this and get the real autoUpgradeTypeclawDep (which reads the CLI's own
|
|
93
93
|
// package.json). Tests inject a stub to simulate `bun -g update typeclaw`
|
|
@@ -149,7 +149,7 @@ export async function start({
|
|
|
149
149
|
allocatePort = findFreePort,
|
|
150
150
|
cliEntry,
|
|
151
151
|
reuseCurrentHostDaemon = false,
|
|
152
|
-
ensureDeps = (dir) => ensureDepsInstalled({ cwd: dir }),
|
|
152
|
+
ensureDeps = (dir, opts) => ensureDepsInstalled({ cwd: dir, ...opts }),
|
|
153
153
|
autoUpgrade = (dir) => autoUpgradeTypeclawDep({ cwd: dir }),
|
|
154
154
|
forceBunUpdate = runBunUpdate,
|
|
155
155
|
readInstalledVersion = readInstalledTypeclawVersionFromAgent,
|
|
@@ -235,7 +235,14 @@ export async function start({
|
|
|
235
235
|
// node_modules/ partially populated. The container then crashes with
|
|
236
236
|
// `Cannot find package 'x'` because the agent folder is bind-mounted into
|
|
237
237
|
// /agent and the container has no node_modules of its own.
|
|
238
|
-
|
|
238
|
+
//
|
|
239
|
+
// Force-reinstall ONLY when --build is set AND typeclaw is declared via
|
|
240
|
+
// a local link. Bun's file-dep cache otherwise serves stale source on
|
|
241
|
+
// subsequent installs (PR #243 dogfooding wasted three rebuilds + a
|
|
242
|
+
// manual version bump before this gate existed). Registry-spec users
|
|
243
|
+
// skip the force path because their install is already cache-correct.
|
|
244
|
+
const forceDepsReinstall = forceBuild && (await hasLocallyLinkedTypeclawDep(cwd))
|
|
245
|
+
const deps = await ensureDeps(cwd, { force: forceDepsReinstall })
|
|
239
246
|
if (!deps.ok) {
|
|
240
247
|
return { ok: false, reason: `dependency install failed: ${deps.reason}` }
|
|
241
248
|
}
|
|
@@ -448,7 +455,24 @@ export async function planStart({
|
|
|
448
455
|
// the start() preflight force-removes any lingering corpse before the next
|
|
449
456
|
// launch — so the only state Docker ever sees in `docker ps -a` is either
|
|
450
457
|
// a running container or one the user has not started again yet.
|
|
451
|
-
|
|
458
|
+
//
|
|
459
|
+
// `--shm-size=2g` is mandatory for the bundled Chrome (agent-browser) to
|
|
460
|
+
// survive heavy pages. Docker's default /dev/shm is 64MB; Chrome uses
|
|
461
|
+
// shared memory for the renderer process and silently crashes mid-load
|
|
462
|
+
// on any site with a large DOM or non-trivial WebGL. The crash surfaces
|
|
463
|
+
// as a blank page or "target closed" with no clear cause — easy to
|
|
464
|
+
// misattribute to bot detection. 2g matches the Playwright/Puppeteer
|
|
465
|
+
// canonical recommendation and is a memory cap, not an allocation (only
|
|
466
|
+
// used pages count against the host).
|
|
467
|
+
const runArgs = [
|
|
468
|
+
'run',
|
|
469
|
+
'-d',
|
|
470
|
+
'--name',
|
|
471
|
+
containerName,
|
|
472
|
+
'--shm-size=2g',
|
|
473
|
+
'-p',
|
|
474
|
+
`${publishHost}:${hostPort}:${CONTAINER_PORT}`,
|
|
475
|
+
]
|
|
452
476
|
|
|
453
477
|
// Network egress filter: when `typeclaw.json#network.blockInternal` is true,
|
|
454
478
|
// grant the container CAP_NET_ADMIN at boot so the entrypoint shim can
|
|
@@ -710,6 +734,26 @@ async function detectDevSource(cwd: string): Promise<string | null> {
|
|
|
710
734
|
}
|
|
711
735
|
}
|
|
712
736
|
|
|
737
|
+
// True when the agent's package.json declares typeclaw via `file:` or
|
|
738
|
+
// `link:` — i.e. a developer is iterating on the typeclaw source via a
|
|
739
|
+
// locally-linked checkout. `bun install` keys its file-dep cache on
|
|
740
|
+
// name+version, so it treats a stale cached copy as a cache hit even
|
|
741
|
+
// after the source on disk has changed. `typeclaw start --build` uses
|
|
742
|
+
// this gate to force `bun install --force` only in dev: registry-spec
|
|
743
|
+
// users (`^X.Y.Z`, `~X.Y.Z`, exact pins) pay nothing because their
|
|
744
|
+
// install path is already cache-correct.
|
|
745
|
+
async function hasLocallyLinkedTypeclawDep(cwd: string): Promise<boolean> {
|
|
746
|
+
try {
|
|
747
|
+
const raw = await readFile(join(cwd, PACKAGE_FILE), 'utf8')
|
|
748
|
+
const pkg = JSON.parse(raw) as { dependencies?: Record<string, string> }
|
|
749
|
+
const spec = pkg.dependencies?.typeclaw
|
|
750
|
+
if (typeof spec !== 'string') return false
|
|
751
|
+
return spec.startsWith('file:') || spec.startsWith('link:')
|
|
752
|
+
} catch {
|
|
753
|
+
return false
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
713
757
|
// A missing typeclaw.json is tolerated (e.g. test fixtures, freshly-cloned
|
|
714
758
|
// folder mid-init). Anything else — malformed JSON, schema-invalid config,
|
|
715
759
|
// invalid mount entry — must surface so the user sees they configured a mount
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { resolveHostPort, resolveTuiToken } from '@/container'
|
|
2
|
+
import type { ClientMessage, CronListEntryPayload, ServerMessage } from '@/shared'
|
|
3
|
+
|
|
4
|
+
export type CronListBridgeOptions = {
|
|
5
|
+
cwd: string
|
|
6
|
+
url?: string
|
|
7
|
+
timeoutMs?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type CronListBridgeResult =
|
|
11
|
+
| { kind: 'ok'; jobs: CronListEntryPayload[]; nowMs: number }
|
|
12
|
+
| { kind: 'unreachable'; reason: string }
|
|
13
|
+
| { kind: 'timeout' }
|
|
14
|
+
| { kind: 'error'; reason: string }
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 15_000
|
|
17
|
+
|
|
18
|
+
export async function fetchCronList(opts: CronListBridgeOptions): Promise<CronListBridgeResult> {
|
|
19
|
+
const reach = await dial(opts)
|
|
20
|
+
if (reach.kind !== 'ok') return reach
|
|
21
|
+
const { ws, timeoutMs } = reach
|
|
22
|
+
const requestId = randomId()
|
|
23
|
+
try {
|
|
24
|
+
return await awaitReply(ws, timeoutMs, requestId)
|
|
25
|
+
} finally {
|
|
26
|
+
ws.close()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type DialResult = { kind: 'ok'; ws: WebSocket; timeoutMs: number } | { kind: 'unreachable'; reason: string }
|
|
31
|
+
|
|
32
|
+
async function dial(opts: CronListBridgeOptions): Promise<DialResult> {
|
|
33
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
34
|
+
let url = opts.url
|
|
35
|
+
if (url === undefined) {
|
|
36
|
+
try {
|
|
37
|
+
const port = await resolveHostPort({ cwd: opts.cwd })
|
|
38
|
+
const token = await resolveTuiToken({ cwd: opts.cwd })
|
|
39
|
+
url = buildBridgeUrl(port, token)
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return { kind: 'unreachable', reason: err instanceof Error ? err.message : String(err) }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const ws = new WebSocket(url)
|
|
45
|
+
const displayUrl = redactUrl(url)
|
|
46
|
+
try {
|
|
47
|
+
await new Promise<void>((resolve, reject) => {
|
|
48
|
+
// Mirrors the wedged-handshake timeout from src/doctor/plugin-bridge.ts.
|
|
49
|
+
// Bun's WebSocket has no built-in connect timeout; a stuck Upgrade
|
|
50
|
+
// never fires 'open' nor 'error', so `typeclaw cron list` would hang.
|
|
51
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
52
|
+
const cleanup = () => {
|
|
53
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
54
|
+
ws.removeEventListener('open', onOpen)
|
|
55
|
+
ws.removeEventListener('error', onError)
|
|
56
|
+
ws.removeEventListener('close', onClose)
|
|
57
|
+
}
|
|
58
|
+
const onOpen = () => {
|
|
59
|
+
cleanup()
|
|
60
|
+
resolve()
|
|
61
|
+
}
|
|
62
|
+
const onError = (err: unknown) => {
|
|
63
|
+
cleanup()
|
|
64
|
+
reject(new Error(`failed to connect to ${displayUrl}: ${err instanceof Error ? err.message : String(err)}`))
|
|
65
|
+
}
|
|
66
|
+
const onClose = () => {
|
|
67
|
+
cleanup()
|
|
68
|
+
reject(new Error(`connection to ${displayUrl} closed before opening`))
|
|
69
|
+
}
|
|
70
|
+
timer = setTimeout(() => {
|
|
71
|
+
cleanup()
|
|
72
|
+
try {
|
|
73
|
+
ws.close()
|
|
74
|
+
} catch {}
|
|
75
|
+
reject(new Error(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
|
|
76
|
+
}, timeoutMs)
|
|
77
|
+
ws.addEventListener('open', onOpen, { once: true })
|
|
78
|
+
ws.addEventListener('error', onError, { once: true })
|
|
79
|
+
ws.addEventListener('close', onClose, { once: true })
|
|
80
|
+
})
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return { kind: 'unreachable', reason: err instanceof Error ? err.message : String(err) }
|
|
83
|
+
}
|
|
84
|
+
return { kind: 'ok', ws, timeoutMs }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function awaitReply(ws: WebSocket, timeoutMs: number, requestId: string): Promise<CronListBridgeResult> {
|
|
88
|
+
const outgoing: ClientMessage = { type: 'cron_list', requestId }
|
|
89
|
+
ws.send(JSON.stringify(outgoing))
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
ws.removeEventListener('message', onMessage)
|
|
93
|
+
resolve({ kind: 'timeout' })
|
|
94
|
+
}, timeoutMs)
|
|
95
|
+
const onMessage = (event: MessageEvent) => {
|
|
96
|
+
let msg: ServerMessage
|
|
97
|
+
try {
|
|
98
|
+
msg = JSON.parse(String(event.data)) as ServerMessage
|
|
99
|
+
} catch (err) {
|
|
100
|
+
clearTimeout(timer)
|
|
101
|
+
ws.removeEventListener('message', onMessage)
|
|
102
|
+
resolve({ kind: 'error', reason: err instanceof Error ? err.message : String(err) })
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if (msg.type !== 'cron_list_result' || msg.requestId !== requestId) return
|
|
106
|
+
clearTimeout(timer)
|
|
107
|
+
ws.removeEventListener('message', onMessage)
|
|
108
|
+
if (msg.result.ok) {
|
|
109
|
+
resolve({ kind: 'ok', jobs: msg.result.jobs, nowMs: msg.result.nowMs })
|
|
110
|
+
} else {
|
|
111
|
+
resolve({ kind: 'error', reason: msg.result.reason })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
ws.addEventListener('message', onMessage)
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function buildBridgeUrl(port: number, token: string | null): string {
|
|
119
|
+
const url = new URL(`ws://127.0.0.1:${port}`)
|
|
120
|
+
if (token !== null) url.searchParams.set('token', token)
|
|
121
|
+
return url.toString()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function redactUrl(url: string): string {
|
|
125
|
+
try {
|
|
126
|
+
const parsed = new URL(url)
|
|
127
|
+
if (parsed.searchParams.has('token')) parsed.searchParams.set('token', '<redacted>')
|
|
128
|
+
return parsed.toString()
|
|
129
|
+
} catch {
|
|
130
|
+
return url
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function randomId(): string {
|
|
135
|
+
return `cron-${crypto.randomUUID()}`
|
|
136
|
+
}
|