typeclaw 0.36.7 → 0.37.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 +2 -2
- package/package.json +3 -2
- package/src/agent/index.ts +31 -11
- package/src/agent/live-sessions.ts +12 -0
- package/src/agent/model-fallback.ts +17 -15
- package/src/agent/model-overrides.ts +2 -2
- package/src/agent/session-meta.ts +10 -0
- package/src/agent/subagents.ts +11 -2
- package/src/agent/system-prompt.ts +9 -3
- package/src/agent/todo/continuation-policy.ts +6 -3
- package/src/agent/todo/continuation-wiring.ts +4 -2
- package/src/agent/todo/continuation.ts +3 -3
- package/src/agent/tools/todo/index.ts +27 -4
- package/src/bundled-plugins/agent-browser/index.ts +33 -108
- package/src/bundled-plugins/agent-browser/shim.ts +3 -94
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +8 -33
- package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +2 -2
- package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +7 -1
- package/src/bundled-plugins/memory/README.md +80 -23
- package/src/bundled-plugins/memory/append-tool.ts +74 -53
- package/src/bundled-plugins/memory/citation-superset.ts +4 -0
- package/src/bundled-plugins/memory/citations.ts +54 -0
- package/src/bundled-plugins/memory/dreaming-metrics.ts +30 -0
- package/src/bundled-plugins/memory/dreaming.ts +444 -21
- package/src/bundled-plugins/memory/index.ts +544 -400
- package/src/bundled-plugins/memory/load-memory.ts +87 -10
- package/src/bundled-plugins/memory/load-shards.ts +48 -22
- package/src/bundled-plugins/memory/memory-logger.ts +95 -106
- package/src/bundled-plugins/memory/memory-retrieval.ts +3 -3
- package/src/bundled-plugins/memory/parent-link.ts +33 -0
- package/src/bundled-plugins/memory/paths.ts +12 -0
- package/src/bundled-plugins/memory/references/frontmatter.ts +197 -0
- package/src/bundled-plugins/memory/references/load-references.ts +212 -0
- package/src/bundled-plugins/memory/references/store-reference-tool.ts +59 -0
- package/src/bundled-plugins/memory/search-tool.ts +282 -45
- package/src/bundled-plugins/memory/stream-events.ts +1 -0
- package/src/bundled-plugins/memory/stream-io.ts +28 -3
- package/src/bundled-plugins/memory/turn-dedup.ts +40 -0
- package/src/bundled-plugins/memory/vector/cache-write.ts +19 -0
- package/src/bundled-plugins/memory/vector/config.ts +28 -0
- package/src/bundled-plugins/memory/vector/doctor.ts +124 -0
- package/src/bundled-plugins/memory/vector/embedder.ts +246 -0
- package/src/bundled-plugins/memory/vector/hybrid.ts +439 -0
- package/src/bundled-plugins/memory/vector/index-on-write.ts +34 -0
- package/src/bundled-plugins/memory/vector/inspect.ts +111 -0
- package/src/bundled-plugins/memory/vector/passages.ts +125 -0
- package/src/bundled-plugins/memory/vector/reference-index-on-write.ts +50 -0
- package/src/bundled-plugins/memory/vector/relevance-gate.ts +93 -0
- package/src/bundled-plugins/memory/vector/startup.ts +71 -0
- package/src/bundled-plugins/memory/vector/store.ts +203 -0
- package/src/bundled-plugins/memory/vector/truncation.ts +124 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +2 -0
- package/src/channels/router.ts +239 -40
- package/src/cli/incomplete-init.ts +57 -0
- package/src/cli/init.ts +143 -12
- package/src/cli/inspect.ts +11 -5
- package/src/cli/model.ts +112 -34
- package/src/cli/restart.ts +24 -0
- package/src/cli/start.ts +24 -0
- package/src/cli/tunnel.ts +53 -8
- package/src/config/config.ts +110 -19
- package/src/config/index.ts +5 -1
- package/src/config/models-mutation.ts +29 -11
- package/src/config/providers-mutation.ts +2 -2
- package/src/config/providers.ts +146 -12
- package/src/container/shared.ts +9 -0
- package/src/container/start.ts +87 -4
- package/src/cron/consumer.ts +13 -7
- package/src/hostd/models.ts +64 -0
- package/src/hostd/paths.ts +6 -0
- package/src/hostd/portbroker-manager.ts +2 -2
- package/src/init/checkpoint.ts +201 -0
- package/src/init/dockerfile.ts +164 -51
- package/src/init/gitignore.ts +7 -7
- package/src/init/index.ts +41 -9
- package/src/init/line-auth.ts +50 -21
- package/src/init/models-dev.ts +96 -21
- package/src/init/oauth-login.ts +3 -3
- package/src/init/progress.ts +29 -0
- package/src/init/validate-api-key.ts +4 -0
- package/src/inspect/index.ts +13 -6
- package/src/inspect/item-list.ts +11 -2
- package/src/inspect/live-list.ts +65 -0
- package/src/inspect/open-item.ts +22 -1
- package/src/inspect/session-list.ts +29 -0
- package/src/models/embedding-model.ts +114 -0
- package/src/models/transformers-version.ts +55 -0
- package/src/plugin/types.ts +3 -0
- package/src/portbroker/container-server.ts +23 -0
- package/src/portbroker/forward-request-bus.ts +35 -0
- package/src/portbroker/forward-result-bus.ts +2 -3
- package/src/portbroker/hostd-client.ts +182 -36
- package/src/portbroker/index.ts +6 -1
- package/src/portbroker/protocol.ts +9 -2
- package/src/run/channel-session-factory.ts +11 -1
- package/src/run/index.ts +41 -7
- package/src/server/command-runner.ts +24 -1
- package/src/server/index.ts +42 -8
- package/src/shared/index.ts +2 -0
- package/src/shared/protocol.ts +31 -0
- package/src/skills/typeclaw-channels/SKILL.md +4 -4
- package/src/skills/typeclaw-config/SKILL.md +2 -2
- package/src/skills/typeclaw-memory/SKILL.md +3 -1
- package/src/skills/typeclaw-permissions/SKILL.md +3 -3
- package/src/skills/typeclaw-skills/SKILL.md +1 -1
- package/src/skills/typeclaw-tunnels/SKILL.md +22 -1
- package/src/tunnels/providers/cloudflare-quick.ts +65 -7
- package/src/tunnels/upstream-probe.ts +25 -0
- package/typeclaw.schema.json +156 -67
- package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +0 -170
- package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +0 -421
- package/src/portbroker/bind-with-forward.ts +0 -102
|
@@ -2,6 +2,7 @@ import { readdir, stat } from 'node:fs/promises'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import type { MinimalSessionOrigin } from '@/agent/session-meta'
|
|
5
|
+
import type { LiveSessionPayload } from '@/shared'
|
|
5
6
|
|
|
6
7
|
import { previewForHint } from './preview'
|
|
7
8
|
import { replayJsonl } from './replay'
|
|
@@ -13,6 +14,11 @@ export type SessionSummary = {
|
|
|
13
14
|
mtimeMs: number
|
|
14
15
|
origin: MinimalSessionOrigin | null
|
|
15
16
|
firstPrompt: string | null
|
|
17
|
+
// True only for a registry-derived session with no .jsonl on disk yet (a
|
|
18
|
+
// reply is in flight). Disk sessions leave this undefined. Selecting one tails
|
|
19
|
+
// live-only: streamSessionEvents replays an empty file, then the WS delivers
|
|
20
|
+
// events as they happen.
|
|
21
|
+
live?: boolean
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
export type ListSessionsOptions = {
|
|
@@ -65,6 +71,29 @@ export async function listSessions(opts: ListSessionsOptions): Promise<SessionSu
|
|
|
65
71
|
)
|
|
66
72
|
}
|
|
67
73
|
|
|
74
|
+
// Overlay container-registry sessions onto the disk listing. A live session
|
|
75
|
+
// already flushed to disk (post-reply) is dropped from the overlay — the disk
|
|
76
|
+
// summary wins, carrying its real mtime and prompt preview. Only sessions with
|
|
77
|
+
// no .jsonl yet become synthetic live rows, sorted to the top by registration
|
|
78
|
+
// time so an in-flight reply surfaces above settled history.
|
|
79
|
+
export function mergeLiveSessions(disk: SessionSummary[], live: LiveSessionPayload[]): SessionSummary[] {
|
|
80
|
+
const onDisk = new Set(disk.map((s) => s.sessionId))
|
|
81
|
+
const liveOnly = live
|
|
82
|
+
.filter((l) => !onDisk.has(l.sessionId))
|
|
83
|
+
.map(
|
|
84
|
+
(l): SessionSummary => ({
|
|
85
|
+
sessionId: l.sessionId,
|
|
86
|
+
sessionFile: '',
|
|
87
|
+
basename: '',
|
|
88
|
+
mtimeMs: l.registeredAtMs,
|
|
89
|
+
origin: l.origin,
|
|
90
|
+
firstPrompt: null,
|
|
91
|
+
live: true,
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
return [...liveOnly, ...disk].sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
95
|
+
}
|
|
96
|
+
|
|
68
97
|
export type ResolveResult =
|
|
69
98
|
| { ok: true; summary: SessionSummary }
|
|
70
99
|
| { ok: false; reason: 'not-found' | 'ambiguous'; matches: SessionSummary[] }
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { readFile, rename, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export const EMBEDDING_MODEL_NAME = 'Xenova/multilingual-e5-base'
|
|
5
|
+
export const EMBEDDING_MODEL_DTYPE = 'q8'
|
|
6
|
+
export const EMBEDDING_DIMS = 768
|
|
7
|
+
|
|
8
|
+
// The embedding recipe that makes two vectors comparable: E5 query/passage
|
|
9
|
+
// prefixing + mean pooling + L2 normalize. Stamped in the sentinel (not folded
|
|
10
|
+
// into EMBEDDING_MODEL_ID, which is a stored-row filter — changing the ID would
|
|
11
|
+
// invalidate every existing vector row). A future pooling/normalize change
|
|
12
|
+
// bumps this string so a stale cache fails the sentinel loudly.
|
|
13
|
+
export const EMBEDDING_RECIPE = 'e5-prefix:mean-pool:l2-normalize'
|
|
14
|
+
|
|
15
|
+
// Stored-row identity = name@dtype. Used by the vector store to filter rows
|
|
16
|
+
// from an incompatible model/dtype variant out of cosine scans.
|
|
17
|
+
export const EMBEDDING_MODEL_ID = `${EMBEDDING_MODEL_NAME}@${EMBEDDING_MODEL_DTYPE}`
|
|
18
|
+
|
|
19
|
+
const SENTINEL_FILE = '.typeclaw-model.json'
|
|
20
|
+
|
|
21
|
+
export type ModelSentinel = {
|
|
22
|
+
schemaVersion: 1
|
|
23
|
+
model: string
|
|
24
|
+
dtype: string
|
|
25
|
+
dims: number
|
|
26
|
+
recipe: string
|
|
27
|
+
transformers: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sentinelPath(dir: string): string {
|
|
31
|
+
return join(dir, SENTINEL_FILE)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function expectedSentinel(transformers: string): Omit<ModelSentinel, 'transformers'> & { transformers: string } {
|
|
35
|
+
return {
|
|
36
|
+
schemaVersion: 1,
|
|
37
|
+
model: EMBEDDING_MODEL_NAME,
|
|
38
|
+
dtype: EMBEDDING_MODEL_DTYPE,
|
|
39
|
+
dims: EMBEDDING_DIMS,
|
|
40
|
+
recipe: EMBEDDING_RECIPE,
|
|
41
|
+
transformers,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Atomic write-then-rename so a container reader can never observe a partial
|
|
46
|
+
// JSON file mid-write. Called host-side after a successful model download,
|
|
47
|
+
// inside the proper-lockfile critical section.
|
|
48
|
+
export async function writeModelSentinel(dir: string, input: { transformers: string }): Promise<void> {
|
|
49
|
+
const sentinel = expectedSentinel(input.transformers)
|
|
50
|
+
const tmp = `${sentinelPath(dir)}.${process.pid}.tmp`
|
|
51
|
+
await writeFile(tmp, `${JSON.stringify(sentinel, null, 2)}\n`, 'utf8')
|
|
52
|
+
await rename(tmp, sentinelPath(dir))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function readModelSentinel(dir: string): Promise<ModelSentinel | null> {
|
|
56
|
+
let raw: string
|
|
57
|
+
try {
|
|
58
|
+
raw = await readFile(sentinelPath(dir), 'utf8')
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(raw) as Partial<ModelSentinel>
|
|
64
|
+
if (
|
|
65
|
+
parsed.schemaVersion !== 1 ||
|
|
66
|
+
typeof parsed.model !== 'string' ||
|
|
67
|
+
typeof parsed.dtype !== 'string' ||
|
|
68
|
+
typeof parsed.dims !== 'number' ||
|
|
69
|
+
typeof parsed.recipe !== 'string' ||
|
|
70
|
+
typeof parsed.transformers !== 'string'
|
|
71
|
+
) {
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
return parsed as ModelSentinel
|
|
75
|
+
} catch {
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Throws a TypeClaw-authored error (naming observed vs expected identity, with
|
|
81
|
+
// the fix) BEFORE the container's `local_files_only` pipeline load — so a
|
|
82
|
+
// host/container drift surfaces as a clear "refresh the cache" message instead
|
|
83
|
+
// of a cryptic missing-file miss, OR worse, a stale file that loads against a
|
|
84
|
+
// different producer's layout and silently returns garbage vectors. Absent
|
|
85
|
+
// sentinel is a hard failure: host ensureModels() writes it before `docker
|
|
86
|
+
// run` in the same `typeclaw start`, so a missing one means the mount is wrong
|
|
87
|
+
// or the cache was hand-copied — exactly the case we must not paper over.
|
|
88
|
+
export async function assertModelCacheCompatible(dir: string, expected: { transformers: string }): Promise<void> {
|
|
89
|
+
const sentinel = await readModelSentinel(dir)
|
|
90
|
+
const want = expectedSentinel(expected.transformers)
|
|
91
|
+
if (sentinel === null) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`TypeClaw model cache at ${dir} is missing or has an unreadable ${SENTINEL_FILE}, so compatibility with ` +
|
|
94
|
+
`this container cannot be verified. Re-run \`typeclaw start\` to refresh the model cache; if it was copied ` +
|
|
95
|
+
`manually, delete it and start again.`,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
const mismatches = describeMismatches(sentinel, want)
|
|
99
|
+
if (mismatches.length > 0) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`TypeClaw model cache at ${dir} is incompatible with this container (${mismatches.join('; ')}). ` +
|
|
102
|
+
`Re-run \`typeclaw start\` to refresh the model cache.`,
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function describeMismatches(got: ModelSentinel, want: ModelSentinel): string[] {
|
|
108
|
+
const fields: Array<keyof ModelSentinel> = ['model', 'dtype', 'dims', 'recipe', 'transformers']
|
|
109
|
+
return fields
|
|
110
|
+
.filter((field) => got[field] !== want[field])
|
|
111
|
+
.map(
|
|
112
|
+
(field) => `${field}: cache has ${JSON.stringify(got[field])}, container expects ${JSON.stringify(want[field])}`,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { dirname, join, parse as parsePath } from 'node:path'
|
|
4
|
+
|
|
5
|
+
// The ACTUALLY-INSTALLED @huggingface/transformers version in the current
|
|
6
|
+
// runtime, read from the resolved package's own package.json — NOT from
|
|
7
|
+
// typeclaw's dependency spec (which is the intended version, not what is on
|
|
8
|
+
// disk). The model-cache sentinel compares this across stages: the host
|
|
9
|
+
// stamps the version that produced the download, the container checks the
|
|
10
|
+
// version that will consume it. Comparing two intended constants would miss
|
|
11
|
+
// exactly the drift this guards — "the installed runtime isn't what the build
|
|
12
|
+
// said it should be" (e.g. a lockfile-free `bun add` resolving a newer
|
|
13
|
+
// release). Resolution is isolated here so the package-internals access lives
|
|
14
|
+
// in one place.
|
|
15
|
+
//
|
|
16
|
+
// We resolve the package's EXPORTED entry and walk up to its package.json,
|
|
17
|
+
// rather than `require('@huggingface/transformers/package.json')`: that subpath
|
|
18
|
+
// is not in the package's `exports` map (only `node`/`default`), so a strict
|
|
19
|
+
// Node-exports resolver throws ERR_PACKAGE_PATH_NOT_EXPORTED. The main entry IS
|
|
20
|
+
// exported, and its package.json is the nearest one above the resolved file.
|
|
21
|
+
export function getResolvedTransformersVersion(): string {
|
|
22
|
+
const require = createRequire(import.meta.url)
|
|
23
|
+
const entry = require.resolve('@huggingface/transformers')
|
|
24
|
+
const version = readNearestPackageVersion(dirname(entry))
|
|
25
|
+
if (version === null) {
|
|
26
|
+
throw new Error('could not resolve @huggingface/transformers version from its package.json')
|
|
27
|
+
}
|
|
28
|
+
return version
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readNearestPackageVersion(startDir: string): string | null {
|
|
32
|
+
const root = parsePath(startDir).root
|
|
33
|
+
let dir = startDir
|
|
34
|
+
for (;;) {
|
|
35
|
+
const version = readPackageNameVersion(join(dir, 'package.json'))
|
|
36
|
+
if (version !== null) return version
|
|
37
|
+
if (dir === root) return null
|
|
38
|
+
dir = dirname(dir)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Only accept the @huggingface/transformers package.json, never a nested
|
|
43
|
+
// dependency's: the resolved entry can sit under dist/, and an intermediate
|
|
44
|
+
// dir could in theory carry an unrelated package.json. Match on name.
|
|
45
|
+
function readPackageNameVersion(pkgPath: string): string | null {
|
|
46
|
+
let parsed: { name?: unknown; version?: unknown }
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name?: unknown; version?: unknown }
|
|
49
|
+
} catch {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
if (parsed.name !== '@huggingface/transformers') return null
|
|
53
|
+
if (typeof parsed.version !== 'string' || parsed.version.length === 0) return null
|
|
54
|
+
return parsed.version
|
|
55
|
+
}
|
package/src/plugin/types.ts
CHANGED
|
@@ -182,6 +182,9 @@ export type SessionTurnStartEvent = {
|
|
|
182
182
|
agentDir: string
|
|
183
183
|
userPrompt: string
|
|
184
184
|
origin?: SessionOrigin
|
|
185
|
+
// Mutable ref: plugin writes retrieval results here; server/router reads after hook returns.
|
|
186
|
+
// Only populated when vector.enabled and injection plan is index mode.
|
|
187
|
+
retrievalContext?: { results: string }
|
|
185
188
|
}
|
|
186
189
|
|
|
187
190
|
export type SessionTurnEndEvent = {
|
|
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
2
2
|
|
|
3
3
|
import type { ServerWebSocket } from 'bun'
|
|
4
4
|
|
|
5
|
+
import type { ForwardRequestEvent } from './forward-request-bus'
|
|
5
6
|
import { parseProcNetTcp } from './proc-net-tcp'
|
|
6
7
|
import { decodeBytes, encodeBytes, type ContainerToHostd, type HostdToContainer, type StreamId } from './protocol'
|
|
7
8
|
|
|
@@ -23,6 +24,7 @@ export type ContainerBrokerOptions = {
|
|
|
23
24
|
// to the host side. Without it, code that picks an in-container port has no
|
|
24
25
|
// way to detect host-side EADDRINUSE collisions across containers.
|
|
25
26
|
onForwardResult?: (event: ForwardResultEvent) => void
|
|
27
|
+
onForwardRequestSubscribe?: (cb: (event: ForwardRequestEvent) => void) => () => void
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export type ForwardResultEvent =
|
|
@@ -72,6 +74,8 @@ export function createContainerBroker(opts: ContainerBrokerOptions): ContainerBr
|
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
const sessions = new WeakMap<BrokerSocket, SessionState>()
|
|
77
|
+
const sockets = new Set<BrokerSocket>()
|
|
78
|
+
const reserved = new Map<number, ForwardRequestEvent>()
|
|
75
79
|
|
|
76
80
|
const send = (ws: BrokerSocket, msg: ContainerToHostd): void => {
|
|
77
81
|
try {
|
|
@@ -98,6 +102,22 @@ export function createContainerBroker(opts: ContainerBrokerOptions): ContainerBr
|
|
|
98
102
|
}
|
|
99
103
|
}
|
|
100
104
|
|
|
105
|
+
const sendReservedRequest = (ws: BrokerSocket, request: ForwardRequestEvent): void => {
|
|
106
|
+
send(ws, {
|
|
107
|
+
type: 'port-forward-request',
|
|
108
|
+
targetPort: request.targetPort,
|
|
109
|
+
hostCandidates: request.hostCandidates,
|
|
110
|
+
...(request.reason !== undefined ? { reason: request.reason } : {}),
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
opts.onForwardRequestSubscribe?.((event) => {
|
|
115
|
+
reserved.set(event.targetPort, event)
|
|
116
|
+
for (const ws of sockets) {
|
|
117
|
+
if (ws.data.authed) sendReservedRequest(ws, event)
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
101
121
|
const tickWatcher = async (ws: BrokerSocket, state: SessionState): Promise<void> => {
|
|
102
122
|
const next = await snapshotPorts()
|
|
103
123
|
for (const [port, bindAddr] of next) {
|
|
@@ -158,6 +178,7 @@ export function createContainerBroker(opts: ContainerBrokerOptions): ContainerBr
|
|
|
158
178
|
return {
|
|
159
179
|
open(ws) {
|
|
160
180
|
sessions.set(ws, { pollTimer: null, lastSnapshot: new Map(), upstreams: new Map() })
|
|
181
|
+
sockets.add(ws)
|
|
161
182
|
},
|
|
162
183
|
|
|
163
184
|
async message(ws, raw) {
|
|
@@ -187,6 +208,7 @@ export function createContainerBroker(opts: ContainerBrokerOptions): ContainerBr
|
|
|
187
208
|
ws.data.authed = true
|
|
188
209
|
log({ kind: 'authed' })
|
|
189
210
|
send(ws, { type: 'broker-hello-ack' })
|
|
211
|
+
for (const request of reserved.values()) sendReservedRequest(ws, request)
|
|
190
212
|
return
|
|
191
213
|
}
|
|
192
214
|
|
|
@@ -252,6 +274,7 @@ export function createContainerBroker(opts: ContainerBrokerOptions): ContainerBr
|
|
|
252
274
|
} catch {}
|
|
253
275
|
}
|
|
254
276
|
state.upstreams.clear()
|
|
277
|
+
sockets.delete(ws)
|
|
255
278
|
sessions.delete(ws)
|
|
256
279
|
},
|
|
257
280
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// In-process event bus for explicit container→host forward requests. The
|
|
2
|
+
// agent-browser plugin runs in the same process as the container broker but
|
|
3
|
+
// does not hold a broker handle, so it publishes here and run/index wires the
|
|
4
|
+
// bus into createContainerBroker.
|
|
5
|
+
|
|
6
|
+
export type ForwardRequestEvent = {
|
|
7
|
+
targetPort: number
|
|
8
|
+
hostCandidates: number[]
|
|
9
|
+
reason?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type Subscriber = (event: ForwardRequestEvent) => void
|
|
13
|
+
|
|
14
|
+
const subscribers = new Set<Subscriber>()
|
|
15
|
+
|
|
16
|
+
export function publishForwardRequest(event: ForwardRequestEvent): void {
|
|
17
|
+
for (const sub of subscribers) {
|
|
18
|
+
try {
|
|
19
|
+
sub(event)
|
|
20
|
+
} catch {
|
|
21
|
+
// Subscriber failures must not block peer subscribers.
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function subscribeForwardRequest(cb: Subscriber): () => void {
|
|
27
|
+
subscribers.add(cb)
|
|
28
|
+
return () => {
|
|
29
|
+
subscribers.delete(cb)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function __resetForwardRequestBus(): void {
|
|
34
|
+
subscribers.clear()
|
|
35
|
+
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// In-process event bus for `port-forward-result` events emitted by the
|
|
2
2
|
// host-side broker over the WS to the container side. Lives as a module-level
|
|
3
3
|
// singleton so the run-loop wiring (src/run/index.ts) can publish events from
|
|
4
|
-
// the broker callback while consumers
|
|
5
|
-
//
|
|
6
|
-
// needing a reference to the ContainerBroker itself.
|
|
4
|
+
// the broker callback while consumers subscribe by importing this module
|
|
5
|
+
// without needing a reference to the ContainerBroker itself.
|
|
7
6
|
//
|
|
8
7
|
// Tests should call `__resetForwardResultBus()` in afterEach so subscriptions
|
|
9
8
|
// from a previous test don't leak.
|
|
@@ -85,13 +85,21 @@ export function createBroker(opts: BrokerOptions): Broker {
|
|
|
85
85
|
const listenHost = opts.listenHost ?? defaultListenHost
|
|
86
86
|
|
|
87
87
|
type ForwarderState = {
|
|
88
|
-
|
|
88
|
+
targetPort: number
|
|
89
|
+
hostPort: number
|
|
89
90
|
bindAddr: BindAddr
|
|
91
|
+
reserved: boolean
|
|
90
92
|
listener: HostListener
|
|
91
93
|
streams: Map<StreamId, { sock: HostSocket; opened: boolean; pending: Uint8Array[] }>
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
const forwarders = new Map<number, ForwarderState>()
|
|
97
|
+
const reservedTargets = new Map<number, number>()
|
|
98
|
+
// Targets claimed by a reserved forward whose host bind is still in flight.
|
|
99
|
+
// Marked synchronously BEFORE awaiting listenHost() so a concurrent
|
|
100
|
+
// port-listen snapshot/opened for the same target cannot slip past the
|
|
101
|
+
// reserved guard and install a competing auto-forward during the await.
|
|
102
|
+
const pendingReservedTargets = new Set<number>()
|
|
95
103
|
let ws: WsClient | null = null
|
|
96
104
|
let nextStreamId: StreamId = 1
|
|
97
105
|
let stopped = false
|
|
@@ -112,8 +120,8 @@ export function createBroker(opts: BrokerOptions): Broker {
|
|
|
112
120
|
}
|
|
113
121
|
}
|
|
114
122
|
|
|
115
|
-
const closeStream = (
|
|
116
|
-
const fwd = forwarders.get(
|
|
123
|
+
const closeStream = (hostPort: number, streamId: StreamId, sendClose: boolean): void => {
|
|
124
|
+
const fwd = forwarders.get(hostPort)
|
|
117
125
|
if (!fwd) return
|
|
118
126
|
const stream = fwd.streams.get(streamId)
|
|
119
127
|
if (!stream) return
|
|
@@ -124,7 +132,7 @@ export function createBroker(opts: BrokerOptions): Broker {
|
|
|
124
132
|
if (sendClose && ws) ws.send({ type: 'relay-close', streamId, side: 'downstream' })
|
|
125
133
|
}
|
|
126
134
|
|
|
127
|
-
const handleHostConnection = (
|
|
135
|
+
const handleHostConnection = (hostPort: number, sock: HostSocket): void => {
|
|
128
136
|
if (!ws) {
|
|
129
137
|
try {
|
|
130
138
|
sock.end()
|
|
@@ -132,7 +140,7 @@ export function createBroker(opts: BrokerOptions): Broker {
|
|
|
132
140
|
return
|
|
133
141
|
}
|
|
134
142
|
const streamId = allocStreamId()
|
|
135
|
-
const fwd = forwarders.get(
|
|
143
|
+
const fwd = forwarders.get(hostPort)
|
|
136
144
|
if (!fwd) {
|
|
137
145
|
try {
|
|
138
146
|
sock.end()
|
|
@@ -153,53 +161,174 @@ export function createBroker(opts: BrokerOptions): Broker {
|
|
|
153
161
|
}
|
|
154
162
|
})
|
|
155
163
|
sock.onClose(() => {
|
|
156
|
-
closeStream(
|
|
164
|
+
closeStream(hostPort, streamId, true)
|
|
157
165
|
})
|
|
158
166
|
|
|
159
|
-
if (ws) ws.send({ type: 'relay-open', streamId, port })
|
|
167
|
+
if (ws) ws.send({ type: 'relay-open', streamId, port: fwd.targetPort })
|
|
160
168
|
}
|
|
161
169
|
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
+
const isReservedTarget = (targetPort: number): boolean =>
|
|
171
|
+
reservedTargets.has(targetPort) || pendingReservedTargets.has(targetPort)
|
|
172
|
+
|
|
173
|
+
const hasAutoForwarderForTarget = (targetPort: number): boolean => {
|
|
174
|
+
for (const fwd of forwarders.values()) {
|
|
175
|
+
if (!fwd.reserved && fwd.targetPort === targetPort) return true
|
|
176
|
+
}
|
|
177
|
+
return false
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const installForwarder = async (targetPort: number, bindAddr: BindAddr): Promise<void> => {
|
|
181
|
+
if (isReservedTarget(targetPort)) return
|
|
182
|
+
// Dedup by targetPort, not the map key: the map is keyed by the bound host
|
|
183
|
+
// port, which diverges from targetPort on an ephemeral bind, so a
|
|
184
|
+
// `forwarders.has(targetPort)` guard would miss an existing forward and bind
|
|
185
|
+
// a second host listener for the same container port.
|
|
186
|
+
if (hasAutoForwarderForTarget(targetPort)) return
|
|
187
|
+
if (!shouldForward({ policy: opts.policy, port: targetPort })) {
|
|
188
|
+
// Policy excluded the port. Tell the container so consumers waiting on
|
|
189
|
+
// port-forward-result can surface a diagnostic instead of hanging.
|
|
190
|
+
if (ws) ws.send({ type: 'port-forward-result', port: targetPort, ok: false, reason: 'policy excluded' })
|
|
170
191
|
return
|
|
171
192
|
}
|
|
172
193
|
try {
|
|
173
|
-
|
|
174
|
-
|
|
194
|
+
// The host listen port — not targetPort — is the forwarders map key, so
|
|
195
|
+
// the connection callback must look up by it. They coincide for ordinary
|
|
196
|
+
// 1:1 auto-forwards but diverge whenever the OS reassigns the bind (e.g. a
|
|
197
|
+
// port-0 ephemeral bind), at which point routing by targetPort misses the
|
|
198
|
+
// map and silently drops the connection. Captured after the await; a
|
|
199
|
+
// connection can only arrive once the listener is bound.
|
|
200
|
+
let boundHostPort: number | undefined
|
|
201
|
+
const listener = await listenHost(hostBind, targetPort, {
|
|
202
|
+
onConnection: (sock) => {
|
|
203
|
+
if (boundHostPort === undefined) {
|
|
204
|
+
try {
|
|
205
|
+
sock.end()
|
|
206
|
+
} catch {}
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
handleHostConnection(boundHostPort, sock)
|
|
210
|
+
},
|
|
211
|
+
})
|
|
212
|
+
boundHostPort = listener.port
|
|
213
|
+
forwarders.set(boundHostPort, {
|
|
214
|
+
targetPort,
|
|
215
|
+
hostPort: boundHostPort,
|
|
216
|
+
bindAddr,
|
|
217
|
+
reserved: false,
|
|
218
|
+
listener,
|
|
219
|
+
streams: new Map(),
|
|
175
220
|
})
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
221
|
+
emit({
|
|
222
|
+
kind: 'port-forward-opened',
|
|
223
|
+
containerName: opts.containerName,
|
|
224
|
+
port: targetPort,
|
|
225
|
+
hostPort: boundHostPort,
|
|
226
|
+
bindAddr,
|
|
227
|
+
})
|
|
228
|
+
if (ws) ws.send({ type: 'port-forward-result', port: targetPort, ok: true, hostPort: boundHostPort })
|
|
179
229
|
} catch (err) {
|
|
180
230
|
const reason = err instanceof Error ? err.message : String(err)
|
|
181
|
-
log(`forward bind ${
|
|
182
|
-
emit({ kind: 'port-forward-failed', containerName: opts.containerName, port, reason })
|
|
183
|
-
if (ws) ws.send({ type: 'port-forward-result', port, ok: false, reason })
|
|
231
|
+
log(`forward bind ${targetPort}: ${reason}`)
|
|
232
|
+
emit({ kind: 'port-forward-failed', containerName: opts.containerName, port: targetPort, reason })
|
|
233
|
+
if (ws) ws.send({ type: 'port-forward-result', port: targetPort, ok: false, reason })
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const installReservedForwarder = async (targetPort: number, hostCandidates: number[]): Promise<void> => {
|
|
238
|
+
const existingHostPort = reservedTargets.get(targetPort)
|
|
239
|
+
if (existingHostPort !== undefined) {
|
|
240
|
+
if (ws) ws.send({ type: 'port-forward-result', port: targetPort, ok: true, hostPort: existingHostPort })
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
if (!shouldForward({ policy: opts.policy, port: targetPort })) {
|
|
244
|
+
if (ws) ws.send({ type: 'port-forward-result', port: targetPort, ok: false, reason: 'policy excluded' })
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Claim the target and evict any auto-forward that already won the race
|
|
249
|
+
// before the reserved bind below yields control to the event loop.
|
|
250
|
+
pendingReservedTargets.add(targetPort)
|
|
251
|
+
removeAutoForwarderForTarget(targetPort, 'container-released')
|
|
252
|
+
|
|
253
|
+
let lastReason = 'no host candidates'
|
|
254
|
+
for (const hostPort of hostCandidates) {
|
|
255
|
+
try {
|
|
256
|
+
// `port` stays the in-container target; the host listen port is the
|
|
257
|
+
// forwarders map key the connection callback must route by. Reserved
|
|
258
|
+
// forwards deliberately allow the two to differ, so route by the actual
|
|
259
|
+
// bound port, captured after the await.
|
|
260
|
+
let boundHostPort: number | undefined
|
|
261
|
+
const listener = await listenHost(hostBind, hostPort, {
|
|
262
|
+
onConnection: (sock) => {
|
|
263
|
+
if (boundHostPort === undefined) {
|
|
264
|
+
try {
|
|
265
|
+
sock.end()
|
|
266
|
+
} catch {}
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
handleHostConnection(boundHostPort, sock)
|
|
270
|
+
},
|
|
271
|
+
})
|
|
272
|
+
boundHostPort = listener.port
|
|
273
|
+
forwarders.set(boundHostPort, {
|
|
274
|
+
targetPort,
|
|
275
|
+
hostPort: boundHostPort,
|
|
276
|
+
bindAddr: '127.0.0.1',
|
|
277
|
+
reserved: true,
|
|
278
|
+
listener,
|
|
279
|
+
streams: new Map(),
|
|
280
|
+
})
|
|
281
|
+
reservedTargets.set(targetPort, boundHostPort)
|
|
282
|
+
pendingReservedTargets.delete(targetPort)
|
|
283
|
+
emit({
|
|
284
|
+
kind: 'port-forward-opened',
|
|
285
|
+
containerName: opts.containerName,
|
|
286
|
+
port: targetPort,
|
|
287
|
+
hostPort: boundHostPort,
|
|
288
|
+
bindAddr: '127.0.0.1',
|
|
289
|
+
})
|
|
290
|
+
if (ws) ws.send({ type: 'port-forward-result', port: targetPort, ok: true, hostPort: boundHostPort })
|
|
291
|
+
return
|
|
292
|
+
} catch (err) {
|
|
293
|
+
lastReason = err instanceof Error ? err.message : String(err)
|
|
294
|
+
log(`reserved forward bind ${hostPort} for ${targetPort}: ${lastReason}`)
|
|
295
|
+
}
|
|
184
296
|
}
|
|
297
|
+
pendingReservedTargets.delete(targetPort)
|
|
298
|
+
emit({ kind: 'port-forward-failed', containerName: opts.containerName, port: targetPort, reason: lastReason })
|
|
299
|
+
if (ws) ws.send({ type: 'port-forward-result', port: targetPort, ok: false, reason: lastReason })
|
|
185
300
|
}
|
|
186
301
|
|
|
187
|
-
const removeForwarder = (
|
|
188
|
-
const fwd = forwarders.get(
|
|
302
|
+
const removeForwarder = (hostPort: number, reason: 'container-released' | 'host-error'): void => {
|
|
303
|
+
const fwd = forwarders.get(hostPort)
|
|
189
304
|
if (!fwd) return
|
|
190
|
-
forwarders.delete(
|
|
305
|
+
forwarders.delete(hostPort)
|
|
306
|
+
if (fwd.reserved) reservedTargets.delete(fwd.targetPort)
|
|
191
307
|
try {
|
|
192
308
|
fwd.listener.stop()
|
|
193
309
|
} catch {}
|
|
194
|
-
for (const [streamId] of fwd.streams) closeStream(
|
|
195
|
-
emit({
|
|
310
|
+
for (const [streamId] of fwd.streams) closeStream(hostPort, streamId, false)
|
|
311
|
+
emit({
|
|
312
|
+
kind: 'port-forward-closed',
|
|
313
|
+
containerName: opts.containerName,
|
|
314
|
+
port: fwd.targetPort,
|
|
315
|
+
hostPort: fwd.hostPort,
|
|
316
|
+
reason,
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const removeAutoForwarderForTarget = (targetPort: number, reason: 'container-released' | 'host-error'): void => {
|
|
321
|
+
for (const [hostPort, fwd] of Array.from(forwarders)) {
|
|
322
|
+
if (!fwd.reserved && fwd.targetPort === targetPort) removeForwarder(hostPort, reason)
|
|
323
|
+
}
|
|
196
324
|
}
|
|
197
325
|
|
|
198
326
|
const teardownAllForwarders = (reason: 'broker-stopped' | 'deregistered' | 'host-error'): void => {
|
|
199
|
-
for (const
|
|
200
|
-
const fwd = forwarders.get(
|
|
327
|
+
for (const hostPort of Array.from(forwarders.keys())) {
|
|
328
|
+
const fwd = forwarders.get(hostPort)
|
|
201
329
|
if (!fwd) continue
|
|
202
|
-
forwarders.delete(
|
|
330
|
+
forwarders.delete(hostPort)
|
|
331
|
+
if (fwd.reserved) reservedTargets.delete(fwd.targetPort)
|
|
203
332
|
try {
|
|
204
333
|
fwd.listener.stop()
|
|
205
334
|
} catch {}
|
|
@@ -209,7 +338,19 @@ export function createBroker(opts: BrokerOptions): Broker {
|
|
|
209
338
|
stream?.sock.end()
|
|
210
339
|
} catch {}
|
|
211
340
|
}
|
|
212
|
-
emit({
|
|
341
|
+
emit({
|
|
342
|
+
kind: 'port-forward-closed',
|
|
343
|
+
containerName: opts.containerName,
|
|
344
|
+
port: fwd.targetPort,
|
|
345
|
+
hostPort: fwd.hostPort,
|
|
346
|
+
reason,
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const teardownAutoForwarders = (reason: 'host-error'): void => {
|
|
352
|
+
for (const [hostPort, fwd] of Array.from(forwarders)) {
|
|
353
|
+
if (!fwd.reserved) removeForwarder(hostPort, reason)
|
|
213
354
|
}
|
|
214
355
|
}
|
|
215
356
|
|
|
@@ -228,14 +369,19 @@ export function createBroker(opts: BrokerOptions): Broker {
|
|
|
228
369
|
return
|
|
229
370
|
case 'port-listen-snapshot':
|
|
230
371
|
for (const { port, bindAddr } of msg.ports) {
|
|
231
|
-
void installForwarder(port, bindAddr)
|
|
372
|
+
if (!isReservedTarget(port)) void installForwarder(port, bindAddr)
|
|
232
373
|
}
|
|
233
374
|
return
|
|
234
375
|
case 'port-listen-opened':
|
|
376
|
+
if (isReservedTarget(msg.port)) return
|
|
235
377
|
void installForwarder(msg.port, msg.bindAddr)
|
|
236
378
|
return
|
|
237
379
|
case 'port-listen-closed':
|
|
238
|
-
|
|
380
|
+
if (isReservedTarget(msg.port)) return
|
|
381
|
+
removeAutoForwarderForTarget(msg.port, 'container-released')
|
|
382
|
+
return
|
|
383
|
+
case 'port-forward-request':
|
|
384
|
+
void installReservedForwarder(msg.targetPort, msg.hostCandidates)
|
|
239
385
|
return
|
|
240
386
|
case 'relay-open-ack': {
|
|
241
387
|
const port = findStreamPort(msg.streamId)
|
|
@@ -303,7 +449,7 @@ export function createBroker(opts: BrokerOptions): Broker {
|
|
|
303
449
|
client.onMessage(handleContainerMessage)
|
|
304
450
|
client.onClose(() => {
|
|
305
451
|
ws = null
|
|
306
|
-
|
|
452
|
+
teardownAutoForwarders('host-error')
|
|
307
453
|
scheduleReconnect()
|
|
308
454
|
})
|
|
309
455
|
|
|
@@ -404,7 +550,7 @@ async function defaultConnectWs(url: string): Promise<WsClient> {
|
|
|
404
550
|
})
|
|
405
551
|
}
|
|
406
552
|
|
|
407
|
-
function defaultListenHost(
|
|
553
|
+
export function defaultListenHost(
|
|
408
554
|
host: string,
|
|
409
555
|
port: number,
|
|
410
556
|
handlers: { onConnection: (sock: HostSocket) => void },
|
package/src/portbroker/index.ts
CHANGED
|
@@ -20,8 +20,13 @@ export {
|
|
|
20
20
|
type UpstreamConnection,
|
|
21
21
|
type UpstreamHandlers,
|
|
22
22
|
} from './container-server'
|
|
23
|
+
export {
|
|
24
|
+
__resetForwardRequestBus,
|
|
25
|
+
publishForwardRequest,
|
|
26
|
+
subscribeForwardRequest,
|
|
27
|
+
type ForwardRequestEvent,
|
|
28
|
+
} from './forward-request-bus'
|
|
23
29
|
export { __resetForwardResultBus, publishForwardResult, subscribeForwardResult } from './forward-result-bus'
|
|
24
|
-
export { bindWithForward, type BindFactory, type BindResult, type BindWithForwardOptions } from './bind-with-forward'
|
|
25
30
|
export {
|
|
26
31
|
createBroker,
|
|
27
32
|
type Broker,
|