typeclaw 0.34.1 → 0.35.1
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 +3 -1
- package/src/agent/plugin-tools.ts +71 -13
- package/src/agent/provider-error.ts +10 -0
- package/src/agent/session-origin.ts +26 -0
- package/src/agent/tools/channel-disengage.ts +13 -9
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +124 -6
- package/src/bundled-plugins/github-cli-auth/git-command.ts +172 -26
- package/src/bundled-plugins/github-cli-auth/index.ts +46 -7
- package/src/bundled-plugins/github-cli-auth/token-class.ts +13 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +33 -2
- package/src/channels/adapters/github/inbound.ts +41 -3
- package/src/channels/adapters/slack-bot.ts +17 -9
- package/src/channels/continuation-willingness.ts +331 -0
- package/src/channels/github-review-claim.ts +105 -0
- package/src/channels/github-token-bridge.ts +7 -0
- package/src/channels/router.ts +103 -24
- package/src/cli/channel.ts +102 -11
- package/src/cli/qr.ts +130 -0
- package/src/config/config.ts +98 -2
- package/src/container/start.ts +12 -0
- package/src/init/dockerfile.ts +64 -0
- package/src/init/line-auth.ts +8 -3
- package/src/inspect/live.ts +128 -13
- package/src/plugin/context.ts +5 -1
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/types.ts +1 -0
- package/src/run/index.ts +1 -0
- package/src/sandbox/availability.ts +87 -19
- package/src/sandbox/build.ts +27 -0
- package/src/sandbox/index.ts +10 -0
- package/src/sandbox/package-install.ts +23 -0
- package/src/sandbox/policy.ts +31 -0
- package/src/sandbox/symlinks.ts +34 -0
- package/src/sandbox/writable-zones.ts +164 -4
- package/src/server/index.ts +5 -1
- package/src/shared/protocol.ts +22 -11
- package/src/skills/typeclaw-channel-github/SKILL.md +4 -2
- package/src/skills/typeclaw-github-contributing/SKILL.md +124 -0
- package/typeclaw.schema.json +32 -1
package/src/config/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { accessSync, constants as fsConstants, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import { homedir } from 'node:os'
|
|
3
|
-
import { isAbsolute, join, resolve } from 'node:path'
|
|
3
|
+
import { isAbsolute, join, posix, resolve } from 'node:path'
|
|
4
4
|
|
|
5
5
|
import type { Model } from '@mariozechner/pi-ai'
|
|
6
6
|
import { z } from 'zod'
|
|
@@ -361,11 +361,98 @@ export type NetworkConfig = z.infer<typeof networkSchema>
|
|
|
361
361
|
// mount actually works (bare-metal Linux, Docker Desktop — NOT OrbStack, which
|
|
362
362
|
// rejects the mount even with the cap; there the runtime falls back to
|
|
363
363
|
// 'proc-bind' regardless). The cost is the CAP_SYS_ADMIN grant on the container.
|
|
364
|
+
|
|
365
|
+
// `sandbox.writablePaths` re-exposes operator-chosen subtrees of the agent
|
|
366
|
+
// folder as WRITABLE inside the per-tool bwrap sandbox, on top of the built-in
|
|
367
|
+
// free-write zones (workspace, public, mounts, .git). It exists for tools that
|
|
368
|
+
// insist on writing a fixed config dir a low-trust role would otherwise hit
|
|
369
|
+
// EROFS on (e.g. a CLI that rewrites `<agentDir>/.foo-cli/config.json`).
|
|
370
|
+
//
|
|
371
|
+
// Each entry is AGENT-ROOT-RELATIVE — it resolves under /agent and may not
|
|
372
|
+
// escape it. Absolute container paths are rejected at parse time: a blanket RW
|
|
373
|
+
// bind outside /agent would punch a hole through the agent trust boundary that
|
|
374
|
+
// the rest of the sandbox model assumes can't happen. `..` segments and
|
|
375
|
+
// null bytes are rejected for the same reason. Targets that don't exist, aren't
|
|
376
|
+
// directories, are symlinks, or land on a security-sensitive path
|
|
377
|
+
// (.git, .env, secrets.json, sessions, memory, .typeclaw, node_modules, the
|
|
378
|
+
// agent root itself) are dropped at resolve time, NOT parse time — existence is
|
|
379
|
+
// a runtime property and the drop keeps a stale config from aborting the
|
|
380
|
+
// sandbox. See resolveWritableZones in src/sandbox/writable-zones.ts.
|
|
381
|
+
export const relativeAgentPathSchema = z
|
|
382
|
+
.string()
|
|
383
|
+
.min(1)
|
|
384
|
+
.refine((value) => !isAbsolute(value), 'must be relative to the agent root, not an absolute path')
|
|
385
|
+
.refine((value) => !value.includes('\0'), 'must not contain a null byte')
|
|
386
|
+
.refine((value) => !value.split(/[/\\]+/).includes('..'), "must not contain a '..' segment")
|
|
387
|
+
|
|
388
|
+
// `sandbox.symlinks` is the one-entry abstraction for the common case of a CLI
|
|
389
|
+
// that reads its config from a fixed path the sandbox can't write: it (1) creates
|
|
390
|
+
// the symlink `from -> /agent/<to>` and (2) makes `<to>` a writable zone (same
|
|
391
|
+
// machinery as `writablePaths` — every `to` is folded into the writable set).
|
|
392
|
+
//
|
|
393
|
+
// `from` is the symlink LOCATION and is fully configurable: an absolute container
|
|
394
|
+
// path (e.g. `/root/.metabase-cli`) or a `~/`-prefixed path expanded against the
|
|
395
|
+
// stage's HOME. Two stages create it: the entrypoint shim creates it at the real
|
|
396
|
+
// container HOME (/root) for trusted/owner roles whose bash runs UNSANDBOXED, and
|
|
397
|
+
// the per-tool bwrap sandbox emits a `--symlink` at the sandbox HOME (/tmp) for
|
|
398
|
+
// low-trust roles — because `$HOME` differs between the two stages, a `~/` from
|
|
399
|
+
// resolves to a different absolute path in each, which is exactly what each
|
|
400
|
+
// consumer needs. The entrypoint refuses to clobber an existing non-symlink.
|
|
401
|
+
//
|
|
402
|
+
// `from` SECURITY: it must not contain a null byte, must not be the root `/`, and
|
|
403
|
+
// must not point INTO /agent (a self-referential loop). Kernel/virtual paths
|
|
404
|
+
// (/proc, /sys, /dev, /run) are rejected — symlinking over them is never a real
|
|
405
|
+
// config need and risks masking the runtime's view of them. `/etc/...` is allowed
|
|
406
|
+
// (a legitimate use case) because the entrypoint's no-clobber guard already stops
|
|
407
|
+
// it from overwriting an existing system file. `to` reuses relativeAgentPathSchema.
|
|
408
|
+
//
|
|
409
|
+
// `..` is rejected OUTRIGHT, before the /agent and kernel-root bans run. Those
|
|
410
|
+
// bans previously inspected the RAW string, which both consumers later normalize
|
|
411
|
+
// against $HOME — so a `~/../agent/workspace/.foo` (→ /agent/...) or
|
|
412
|
+
// `~/../proc/x` (→ /proc/...) slipped past a startsWith('/agent') /
|
|
413
|
+
// startsWith('/proc') check on the un-normalized text. A traversal segment is the
|
|
414
|
+
// ONLY way the post-$HOME effective path can re-enter a banned root, so banning
|
|
415
|
+
// `..` makes the raw-string bans equivalent to checking the effective path —
|
|
416
|
+
// stage-independent, no need to expand $HOME at parse time. The bans then run on
|
|
417
|
+
// the POSIX-normalized form so an absolute `from` like `/var/../proc` is caught
|
|
418
|
+
// even though it has no leading `/proc` literal.
|
|
419
|
+
const FORBIDDEN_SYMLINK_FROM_ROOTS = ['/proc', '/sys', '/dev', '/run'] as const
|
|
420
|
+
function normalizedSymlinkFrom(value: string): string {
|
|
421
|
+
// The `~/` prefix is not a real path component; normalize only the remainder
|
|
422
|
+
// so a `~/a/b` stays `~/a/b` while `/var/../proc` collapses to `/proc`.
|
|
423
|
+
if (value.startsWith('~/')) return `~/${posix.normalize(value.slice(2))}`
|
|
424
|
+
return posix.normalize(value)
|
|
425
|
+
}
|
|
426
|
+
export const symlinkFromSchema = z
|
|
427
|
+
.string()
|
|
428
|
+
.min(1)
|
|
429
|
+
.refine((value) => !value.includes('\0'), 'must not contain a null byte')
|
|
430
|
+
.refine((value) => value.startsWith('~/') || isAbsolute(value), 'must be an absolute path or start with ~/')
|
|
431
|
+
.refine((value) => !value.split(/[/\\]+/).includes('..'), "must not contain a '..' segment")
|
|
432
|
+
.refine((value) => normalizedSymlinkFrom(value) !== '/', 'must not be the filesystem root')
|
|
433
|
+
.refine((value) => {
|
|
434
|
+
const normalized = normalizedSymlinkFrom(value)
|
|
435
|
+
return !normalized.startsWith('/agent/') && normalized !== '/agent'
|
|
436
|
+
}, 'must not point into /agent (the symlink would loop back into the agent folder)')
|
|
437
|
+
.refine((value) => {
|
|
438
|
+
const normalized = normalizedSymlinkFrom(value)
|
|
439
|
+
return !FORBIDDEN_SYMLINK_FROM_ROOTS.some((root) => normalized === root || normalized.startsWith(`${root}/`))
|
|
440
|
+
}, 'must not point at a kernel/virtual path (/proc, /sys, /dev, /run)')
|
|
441
|
+
|
|
442
|
+
export const symlinkSchema = z.object({
|
|
443
|
+
from: symlinkFromSchema,
|
|
444
|
+
to: relativeAgentPathSchema,
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
export type SandboxSymlink = z.infer<typeof symlinkSchema>
|
|
448
|
+
|
|
364
449
|
export const sandboxSchema = z
|
|
365
450
|
.object({
|
|
366
451
|
realProc: z.boolean().default(false),
|
|
452
|
+
writablePaths: z.array(relativeAgentPathSchema).default([]),
|
|
453
|
+
symlinks: z.array(symlinkSchema).default([]),
|
|
367
454
|
})
|
|
368
|
-
.default({ realProc: false })
|
|
455
|
+
.default({ realProc: false, writablePaths: [], symlinks: [] })
|
|
369
456
|
|
|
370
457
|
export type SandboxConfig = z.infer<typeof sandboxSchema>
|
|
371
458
|
|
|
@@ -592,6 +679,15 @@ export function expandMountPath(input: string, cwd: string): string {
|
|
|
592
679
|
return isAbsolute(input) ? input : resolve(cwd, input)
|
|
593
680
|
}
|
|
594
681
|
|
|
682
|
+
// The full set of agent-relative dirs the sandbox should make writable: the
|
|
683
|
+
// explicit `sandbox.writablePaths` plus every `sandbox.symlinks[].to` (so an
|
|
684
|
+
// operator declaring a symlink doesn't also have to list its target). Order is
|
|
685
|
+
// stable (writablePaths first) and duplicates are harmless — resolveWritableZones
|
|
686
|
+
// dedupes after resolving each to an absolute path.
|
|
687
|
+
export function getSandboxWritablePathSpecs(cfg: Pick<Config, 'sandbox'>): string[] {
|
|
688
|
+
return [...cfg.sandbox.writablePaths, ...cfg.sandbox.symlinks.map((link) => link.to)]
|
|
689
|
+
}
|
|
690
|
+
|
|
595
691
|
// Loaded eagerly from process.cwd()/typeclaw.json at module-import time so
|
|
596
692
|
// citty arg defaults (e.g. config.port in src/cli/*.ts) see real values, not
|
|
597
693
|
// hardcoded fallbacks. Missing file → schema defaults; malformed file → ALSO
|
package/src/container/start.ts
CHANGED
|
@@ -533,6 +533,18 @@ export async function planStart({
|
|
|
533
533
|
runArgs.push('--cap-add=SYS_ADMIN')
|
|
534
534
|
}
|
|
535
535
|
|
|
536
|
+
// sandbox.symlinks: the entrypoint shim creates `from -> /agent/<to>` at the
|
|
537
|
+
// real container HOME for the UNSANDBOXED (trusted/owner) bash path. The
|
|
538
|
+
// low-trust path is handled separately by the per-tool bwrap --symlink op
|
|
539
|
+
// (src/sandbox/build.ts). Passed as base64-encoded JSON because `from`/`to`
|
|
540
|
+
// are arbitrary operator strings — base64 sidesteps every shell-metachar and
|
|
541
|
+
// env-quoting hazard; the shim decodes + JSON-parses it with bun. Omitted when
|
|
542
|
+
// empty so the common case adds no env clutter and the shim's loop never runs.
|
|
543
|
+
if (cfg.sandbox.symlinks.length > 0) {
|
|
544
|
+
const encoded = Buffer.from(JSON.stringify(cfg.sandbox.symlinks), 'utf8').toString('base64')
|
|
545
|
+
runArgs.push('-e', `TYPECLAW_SANDBOX_SYMLINKS=${encoded}`)
|
|
546
|
+
}
|
|
547
|
+
|
|
536
548
|
if (hostdControl) {
|
|
537
549
|
runArgs.push('--add-host', HOST_GATEWAY_ALIAS)
|
|
538
550
|
}
|
package/src/init/dockerfile.ts
CHANGED
|
@@ -385,6 +385,68 @@ link_persistent_home_files() {
|
|
|
385
385
|
ln -sfn "$persist_root/${CLAUDE_CREDENTIALS_RELATIVE_PATH}" "$claude_config_dir/${CLAUDE_CREDENTIALS_FILE_NAME}"
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
+
# link_configured_symlinks creates the operator's \`sandbox.symlinks\` at the real
|
|
389
|
+
# container $HOME for the UNSANDBOXED (trusted/owner) bash path; low-trust bash
|
|
390
|
+
# gets an equivalent in-jail symlink from the per-tool bwrap builder instead
|
|
391
|
+
# (src/sandbox/build.ts). TYPECLAW_SANDBOX_SYMLINKS is base64-encoded JSON of
|
|
392
|
+
# [{from,to}] (set by start.ts only when non-empty). The whole job is done in
|
|
393
|
+
# \`bun -e\` rather than POSIX shell because \`from\`/\`to\` are arbitrary operator
|
|
394
|
+
# strings: a real JSON parser + Node fs API sidesteps every word-splitting,
|
|
395
|
+
# glob, and metachar hazard that shell string-handling of untrusted paths
|
|
396
|
+
# carries. Contract enforced here (belt to the config-parse validation in
|
|
397
|
+
# config.ts): \`from\` is expanded against $HOME for a leading \`~/\`, \`to\` resolves
|
|
398
|
+
# under /agent and may not escape it, and an existing NON-symlink at \`from\` is
|
|
399
|
+
# refused (never clobbered) — a dangling/symlink \`from\` is replaced idempotently
|
|
400
|
+
# with \`ln -sfn\` semantics (force + no-deref). Failures are logged and skipped
|
|
401
|
+
# per-entry so one bad symlink never blocks container boot.
|
|
402
|
+
#
|
|
403
|
+
# TYPECLAW_AGENT_DIR defaults to /agent (the bind-mount path) and is overridable
|
|
404
|
+
# only by the shim's behavioral tests, which point it at a tmpdir — same escape
|
|
405
|
+
# hatch as TYPECLAW_PERSIST_HOME_ROOT in link_persistent_home_files. Production
|
|
406
|
+
# never sets it.
|
|
407
|
+
link_configured_symlinks() {
|
|
408
|
+
[ -n "\${TYPECLAW_SANDBOX_SYMLINKS:-}" ] || return 0
|
|
409
|
+
TYPECLAW_SANDBOX_SYMLINKS="$TYPECLAW_SANDBOX_SYMLINKS" HOME="$HOME" \\
|
|
410
|
+
TYPECLAW_AGENT_DIR="\${TYPECLAW_AGENT_DIR:-/agent}" bun -e '
|
|
411
|
+
import { lstatSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
|
|
412
|
+
import { dirname, join, normalize, resolve } from "node:path";
|
|
413
|
+
const home = process.env.HOME || "/root";
|
|
414
|
+
const agentDir = process.env.TYPECLAW_AGENT_DIR || "/agent";
|
|
415
|
+
let specs;
|
|
416
|
+
try {
|
|
417
|
+
specs = JSON.parse(Buffer.from(process.env.TYPECLAW_SANDBOX_SYMLINKS, "base64").toString("utf8"));
|
|
418
|
+
} catch (e) {
|
|
419
|
+
console.error("typeclaw-entrypoint: could not parse TYPECLAW_SANDBOX_SYMLINKS:", String(e));
|
|
420
|
+
process.exit(0);
|
|
421
|
+
}
|
|
422
|
+
for (const spec of Array.isArray(specs) ? specs : []) {
|
|
423
|
+
const from = String(spec?.from ?? "");
|
|
424
|
+
const to = String(spec?.to ?? "");
|
|
425
|
+
if (from === "" || to === "") continue;
|
|
426
|
+
const fromAbs = from.startsWith("~/") ? join(home, from.slice(2)) : normalize(from);
|
|
427
|
+
const target = resolve(agentDir, to);
|
|
428
|
+
if (target !== agentDir && !target.startsWith(agentDir + "/")) {
|
|
429
|
+
console.error("typeclaw-entrypoint: skip symlink, target escapes /agent:", to);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
mkdirSync(target, { recursive: true });
|
|
434
|
+
mkdirSync(dirname(fromAbs), { recursive: true });
|
|
435
|
+
let existing;
|
|
436
|
+
try { existing = lstatSync(fromAbs); } catch { existing = undefined; }
|
|
437
|
+
if (existing && !existing.isSymbolicLink()) {
|
|
438
|
+
console.error("typeclaw-entrypoint: refusing to clobber existing non-symlink at", fromAbs);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (existing) rmSync(fromAbs);
|
|
442
|
+
symlinkSync(target, fromAbs);
|
|
443
|
+
} catch (e) {
|
|
444
|
+
console.error("typeclaw-entrypoint: failed to create symlink", fromAbs, "->", target, ":", String(e));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
' || true
|
|
448
|
+
}
|
|
449
|
+
|
|
388
450
|
start_xvfb() {
|
|
389
451
|
if ! command -v Xvfb >/dev/null 2>&1; then
|
|
390
452
|
return 0
|
|
@@ -419,6 +481,7 @@ start_xvfb() {
|
|
|
419
481
|
|
|
420
482
|
if [ "\${TYPECLAW_NETWORK_BLOCK_INTERNAL:-0}" != "1" ]; then
|
|
421
483
|
link_persistent_home_files
|
|
484
|
+
link_configured_symlinks
|
|
422
485
|
start_xvfb
|
|
423
486
|
exec bun run typeclaw "$@"
|
|
424
487
|
fi
|
|
@@ -465,6 +528,7 @@ ip6tables -A OUTPUT -o lo -j ACCEPT
|
|
|
465
528
|
${ipv6Rules.join('\n')}
|
|
466
529
|
|
|
467
530
|
link_persistent_home_files
|
|
531
|
+
link_configured_symlinks
|
|
468
532
|
start_xvfb
|
|
469
533
|
exec setpriv --bounding-set -net_admin --inh-caps -net_admin --ambient-caps -net_admin -- bun run typeclaw "$@"
|
|
470
534
|
`
|
package/src/init/line-auth.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { SecretsLineCredentialStore } from '@/secrets/line-store'
|
|
|
7
7
|
export type LineBootstrapStatus = { ok: true } | { ok: false; reason: string }
|
|
8
8
|
|
|
9
9
|
export type LineLoginCallbacks = {
|
|
10
|
-
onQRUrl?: (url: string) => void
|
|
10
|
+
onQRUrl?: (url: string) => void | Promise<void>
|
|
11
11
|
onPincode: (pin: string) => void
|
|
12
12
|
}
|
|
13
13
|
|
|
@@ -33,7 +33,10 @@ export type LineLoginInput =
|
|
|
33
33
|
// Structural subset of the upstream LineClient the bootstrap drives. Declared
|
|
34
34
|
// here so tests can inject a fake without standing up the real LOCO client.
|
|
35
35
|
export type LineLoginClient = {
|
|
36
|
-
loginWithQR(options: {
|
|
36
|
+
loginWithQR(options: {
|
|
37
|
+
onQRUrl: (url: string) => void | Promise<void>
|
|
38
|
+
onPincode: (pin: string) => void
|
|
39
|
+
}): Promise<LineLoginResult>
|
|
37
40
|
loginWithEmail(options: {
|
|
38
41
|
email: string
|
|
39
42
|
password: string
|
|
@@ -58,7 +61,9 @@ export async function runLineBootstrap(input: LineLoginInput): Promise<LineBoots
|
|
|
58
61
|
const result =
|
|
59
62
|
input.method === 'qr'
|
|
60
63
|
? await client.loginWithQR({
|
|
61
|
-
onQRUrl: (url) =>
|
|
64
|
+
onQRUrl: async (url) => {
|
|
65
|
+
await input.callbacks.onQRUrl?.(url)
|
|
66
|
+
},
|
|
62
67
|
onPincode: input.callbacks.onPincode,
|
|
63
68
|
})
|
|
64
69
|
: await client.loginWithEmail({
|
package/src/inspect/live.ts
CHANGED
|
@@ -11,9 +11,15 @@ export type StreamLiveOptions = {
|
|
|
11
11
|
onSubscribed?: (live: boolean) => void
|
|
12
12
|
onError?: (message: string) => void
|
|
13
13
|
connectTimeoutMs?: number
|
|
14
|
+
heartbeatIntervalMs?: number
|
|
15
|
+
pongTimeoutMs?: number
|
|
16
|
+
bufferedAmountCeiling?: number
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
const DEFAULT_CONNECT_TIMEOUT_MS = 5_000
|
|
20
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000
|
|
21
|
+
const DEFAULT_PONG_TIMEOUT_MS = 30_000
|
|
22
|
+
const DEFAULT_BUFFERED_AMOUNT_CEILING = 1_048_576
|
|
17
23
|
|
|
18
24
|
export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<InspectEvent> {
|
|
19
25
|
const WS = opts.WebSocketImpl ?? WebSocket
|
|
@@ -26,6 +32,17 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
|
|
|
26
32
|
const accumulators = new Map<string, string>()
|
|
27
33
|
const thinkingAccumulators = new Map<string, string>()
|
|
28
34
|
|
|
35
|
+
let heartbeat: ReturnType<typeof setInterval> | null = null
|
|
36
|
+
let awaitingPongSince: number | null = null
|
|
37
|
+
let supportsPing = false
|
|
38
|
+
|
|
39
|
+
const stopHeartbeat = (): void => {
|
|
40
|
+
if (heartbeat !== null) {
|
|
41
|
+
clearInterval(heartbeat)
|
|
42
|
+
heartbeat = null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
29
46
|
const wake = (): void => {
|
|
30
47
|
if (resolveNext !== null) {
|
|
31
48
|
const fn = resolveNext
|
|
@@ -43,13 +60,19 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
|
|
|
43
60
|
return
|
|
44
61
|
}
|
|
45
62
|
if (msg.type === 'subscribed') {
|
|
63
|
+
supportsPing = msg.supportsPing === true
|
|
46
64
|
opts.onSubscribed?.(msg.sessionLive)
|
|
47
65
|
return
|
|
48
66
|
}
|
|
67
|
+
if (msg.type === 'pong') {
|
|
68
|
+
awaitingPongSince = null
|
|
69
|
+
return
|
|
70
|
+
}
|
|
49
71
|
if (msg.type === 'error') {
|
|
50
72
|
opts.onError?.(msg.message)
|
|
51
73
|
pendingError = msg.message
|
|
52
74
|
closed = true
|
|
75
|
+
stopHeartbeat()
|
|
53
76
|
try {
|
|
54
77
|
ws.close()
|
|
55
78
|
} catch {
|
|
@@ -84,6 +107,7 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
|
|
|
84
107
|
})
|
|
85
108
|
ws.addEventListener('close', () => {
|
|
86
109
|
closed = true
|
|
110
|
+
stopHeartbeat()
|
|
87
111
|
wake()
|
|
88
112
|
})
|
|
89
113
|
|
|
@@ -99,6 +123,7 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
|
|
|
99
123
|
'abort',
|
|
100
124
|
() => {
|
|
101
125
|
closed = true
|
|
126
|
+
stopHeartbeat()
|
|
102
127
|
try {
|
|
103
128
|
ws.close()
|
|
104
129
|
} catch {
|
|
@@ -134,25 +159,115 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
|
|
|
134
159
|
}
|
|
135
160
|
ws.send(JSON.stringify(subscribe))
|
|
136
161
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
162
|
+
startHeartbeat({
|
|
163
|
+
ws,
|
|
164
|
+
intervalMs: opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS,
|
|
165
|
+
pongTimeoutMs: opts.pongTimeoutMs ?? DEFAULT_PONG_TIMEOUT_MS,
|
|
166
|
+
bufferedAmountCeiling: opts.bufferedAmountCeiling ?? DEFAULT_BUFFERED_AMOUNT_CEILING,
|
|
167
|
+
supportsPing: () => supportsPing,
|
|
168
|
+
isAwaitingPongSince: () => awaitingPongSince,
|
|
169
|
+
setAwaitingPongSince: (at) => {
|
|
170
|
+
awaitingPongSince = at
|
|
171
|
+
},
|
|
172
|
+
setTimer: (timer) => {
|
|
173
|
+
heartbeat = timer
|
|
174
|
+
},
|
|
175
|
+
onDead: () => {
|
|
176
|
+
closed = true
|
|
177
|
+
stopHeartbeat()
|
|
178
|
+
try {
|
|
179
|
+
ws.close()
|
|
180
|
+
} catch {
|
|
181
|
+
/* ignore */
|
|
182
|
+
}
|
|
183
|
+
wake()
|
|
184
|
+
},
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
while (true) {
|
|
189
|
+
if (buffer.length > 0) {
|
|
190
|
+
const next = buffer.shift()!
|
|
191
|
+
yield next
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
if (closed) {
|
|
195
|
+
if (pendingError !== null) throw new Error(pendingError)
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
const { event, done } = await new Promise<{ event: InspectEvent | null; done: boolean }>((resolve) => {
|
|
199
|
+
resolveNext = resolve
|
|
200
|
+
})
|
|
201
|
+
if (event !== null) yield event
|
|
202
|
+
if (done) {
|
|
203
|
+
if (pendingError !== null) throw new Error(pendingError)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
} finally {
|
|
208
|
+
// Also fired when the consumer abandons the generator (break from a
|
|
209
|
+
// `for await` calls .return()): close the socket so it can't outlive the
|
|
210
|
+
// viewer, not just the heartbeat timer.
|
|
211
|
+
stopHeartbeat()
|
|
212
|
+
closed = true
|
|
213
|
+
try {
|
|
214
|
+
ws.close()
|
|
215
|
+
} catch {
|
|
216
|
+
/* ignore */
|
|
142
217
|
}
|
|
143
|
-
|
|
144
|
-
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
type HeartbeatOptions = {
|
|
222
|
+
ws: WebSocket
|
|
223
|
+
intervalMs: number
|
|
224
|
+
pongTimeoutMs: number
|
|
225
|
+
bufferedAmountCeiling: number
|
|
226
|
+
// Read live: the `subscribed` reply that sets it arrives after the timer is
|
|
227
|
+
// armed, so a snapshot taken at startHeartbeat time would always be false.
|
|
228
|
+
supportsPing: () => boolean
|
|
229
|
+
isAwaitingPongSince: () => number | null
|
|
230
|
+
setAwaitingPongSince: (at: number | null) => void
|
|
231
|
+
setTimer: (timer: ReturnType<typeof setInterval>) => void
|
|
232
|
+
onDead: () => void
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Steady-state liveness watchdog. The connect gate only bounds the OPENING
|
|
236
|
+
// phase; once subscribed, a wedged socket (send queue not draining, no
|
|
237
|
+
// 'close'/'error') would park the read loop forever. The interval fires on the
|
|
238
|
+
// event-loop timer queue independent of the dead socket, so it always runs.
|
|
239
|
+
// Two death signals, both treated as a clean close (return, never throw) so the
|
|
240
|
+
// viewer recovers to the picker:
|
|
241
|
+
// 1. bufferedAmount past a ceiling — our writes are not draining. Always on:
|
|
242
|
+
// it needs no server cooperation, so it works against any server version.
|
|
243
|
+
// 2. a ping with no pong within the deadline — round-trip liveness lost,
|
|
244
|
+
// which also covers idle tails (a quiet-but-healthy tail still pongs). Only
|
|
245
|
+
// armed when the server advertised supportsPing; a pre-heartbeat server
|
|
246
|
+
// answers an unknown ping with error+close, so probing it would kill the
|
|
247
|
+
// tail. Such a server degrades to bufferedAmount-only detection.
|
|
248
|
+
function startHeartbeat(opts: HeartbeatOptions): void {
|
|
249
|
+
let pingId = 0
|
|
250
|
+
const tick = (): void => {
|
|
251
|
+
if (opts.ws.bufferedAmount >= opts.bufferedAmountCeiling) {
|
|
252
|
+
opts.onDead()
|
|
145
253
|
return
|
|
146
254
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (done) {
|
|
152
|
-
if (pendingError !== null) throw new Error(pendingError)
|
|
255
|
+
if (!opts.supportsPing()) return
|
|
256
|
+
const awaiting = opts.isAwaitingPongSince()
|
|
257
|
+
if (awaiting !== null) {
|
|
258
|
+
if (Date.now() - awaiting >= opts.pongTimeoutMs) opts.onDead()
|
|
153
259
|
return
|
|
154
260
|
}
|
|
261
|
+
pingId += 1
|
|
262
|
+
const ping: InspectClientMessage = { type: 'ping', id: pingId }
|
|
263
|
+
try {
|
|
264
|
+
opts.ws.send(JSON.stringify(ping))
|
|
265
|
+
opts.setAwaitingPongSince(Date.now())
|
|
266
|
+
} catch {
|
|
267
|
+
opts.onDead()
|
|
268
|
+
}
|
|
155
269
|
}
|
|
270
|
+
opts.setTimer(setInterval(tick, opts.intervalMs))
|
|
156
271
|
}
|
|
157
272
|
|
|
158
273
|
function frameToEvent(
|
package/src/plugin/context.ts
CHANGED
|
@@ -13,6 +13,7 @@ export type CreatePluginContextOptions<TConfig> = {
|
|
|
13
13
|
logger: PluginLogger
|
|
14
14
|
permissions: PermissionService
|
|
15
15
|
resolveGithubTokenForRepo?: ResolveGithubTokenForRepo
|
|
16
|
+
hasGithubAppTokenResolver?: () => boolean
|
|
16
17
|
spawnSubagent: SpawnSubagentFn
|
|
17
18
|
isBooted: () => boolean
|
|
18
19
|
}
|
|
@@ -30,7 +31,10 @@ export function createPluginContext<TConfig>(opts: CreatePluginContextOptions<TC
|
|
|
30
31
|
config: opts.config,
|
|
31
32
|
logger: opts.logger,
|
|
32
33
|
permissions: opts.permissions,
|
|
33
|
-
github: {
|
|
34
|
+
github: {
|
|
35
|
+
resolveTokenForRepo: opts.resolveGithubTokenForRepo ?? githubTokenUnavailable,
|
|
36
|
+
hasAppTokenResolver: opts.hasGithubAppTokenResolver ?? (() => false),
|
|
37
|
+
},
|
|
34
38
|
spawnSubagent: async (name: string, payload?: unknown, options?: SpawnSubagentOptions) => {
|
|
35
39
|
if (!opts.isBooted()) {
|
|
36
40
|
throw new Error(
|
package/src/plugin/manager.ts
CHANGED
|
@@ -22,6 +22,7 @@ export type LoadPluginsOptions = {
|
|
|
22
22
|
loadEntry?: LoadPluginEntryFn
|
|
23
23
|
roles?: RolesConfig
|
|
24
24
|
resolveGithubTokenForRepo?: ResolveGithubTokenForRepo
|
|
25
|
+
hasGithubAppTokenResolver?: () => boolean
|
|
25
26
|
// Bundled plugins resolved by the runtime (not from typeclaw.json). Loaded
|
|
26
27
|
// before user-declared `entries` so a config block named after a bundled
|
|
27
28
|
// plugin (e.g. "memory") is consumed by the bundled plugin, and so plugin-
|
|
@@ -104,6 +105,7 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
|
|
|
104
105
|
logger,
|
|
105
106
|
permissions,
|
|
106
107
|
resolveGithubTokenForRepo: opts.resolveGithubTokenForRepo,
|
|
108
|
+
hasGithubAppTokenResolver: opts.hasGithubAppTokenResolver,
|
|
107
109
|
spawnSubagent: (name, payload, options) => spawnSubagentImpl(name, payload, options),
|
|
108
110
|
isBooted: () => booted,
|
|
109
111
|
})
|
package/src/plugin/types.ts
CHANGED
package/src/run/index.ts
CHANGED
|
@@ -167,6 +167,7 @@ export async function startAgent({
|
|
|
167
167
|
configsByName: pluginConfigsByName,
|
|
168
168
|
bundled: BUNDLED_PLUGINS,
|
|
169
169
|
resolveGithubTokenForRepo: githubTokenBridge.resolveTokenForRepo,
|
|
170
|
+
hasGithubAppTokenResolver: githubTokenBridge.hasAppTokenResolver,
|
|
170
171
|
...(cwdConfig.roles !== undefined ? { roles: cwdConfig.roles } : {}),
|
|
171
172
|
})
|
|
172
173
|
|