typeclaw 0.37.3 → 0.37.5
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 +69 -46
- package/package.json +1 -1
- package/src/agent/compaction.ts +24 -15
- package/src/agent/doctor.ts +6 -1
- package/src/agent/session-origin.ts +101 -173
- package/src/agent/subagents.ts +146 -14
- package/src/agent/system-prompt.ts +46 -48
- package/src/agent/todo/scope.ts +4 -2
- package/src/agent/tools/channel-reply.ts +7 -9
- package/src/bundled-plugins/memory/index.ts +33 -33
- package/src/bundled-plugins/memory/load-memory.ts +92 -35
- package/src/bundled-plugins/memory/slug.ts +19 -0
- package/src/bundled-plugins/memory/turn-dedup.ts +32 -29
- package/src/bundled-plugins/security/policies/private-surface-read.ts +4 -1
- package/src/bundled-plugins/tool-result-cap/README.md +7 -7
- package/src/bundled-plugins/tool-result-cap/index.ts +1 -1
- package/src/channels/adapters/discord-bot.ts +11 -4
- package/src/channels/adapters/github/inbound.ts +68 -43
- package/src/channels/adapters/github/index.ts +57 -9
- package/src/channels/adapters/github/recover-failed-deliveries.ts +270 -0
- package/src/channels/adapters/kakaotalk.ts +5 -1
- package/src/channels/adapters/mention-hints.ts +75 -0
- package/src/channels/adapters/slack-bot.ts +8 -2
- package/src/channels/continuation-willingness.ts +216 -68
- package/src/channels/router.ts +149 -15
- package/src/cli/dreams.ts +2 -2
- package/src/cli/init.ts +41 -7
- package/src/cli/inspect.ts +2 -2
- package/src/cli/logs.ts +2 -2
- package/src/cli/qr.ts +4 -3
- package/src/cli/require-agent-dir.ts +31 -0
- package/src/cli/shell.ts +2 -2
- package/src/cli/stop.ts +2 -2
- package/src/cli/tui.ts +20 -6
- package/src/cli/ui.ts +8 -4
- package/src/container/shared.ts +18 -0
- package/src/container/start.ts +1 -1
- package/src/doctor/checks.ts +145 -2
- package/src/hostd/client.ts +48 -52
- package/src/hostd/daemon.ts +82 -39
- package/src/hostd/paths.ts +22 -2
- package/src/hostd/spawn.ts +7 -0
- package/src/hostd/tailscale.ts +12 -1
- package/src/init/index.ts +35 -8
- package/src/init/kakaotalk-auth.ts +2 -2
- package/src/init/packagejson.ts +2 -2
- package/src/init/run-bun-install.ts +71 -37
- package/src/inspect/transcript-view.ts +15 -2
- package/src/plugin/loader.ts +7 -4
- package/src/portbroker/hostd-client.ts +32 -6
- package/src/sandbox/session-tmp.ts +6 -1
- package/src/secrets/export-claude-credentials-file.ts +2 -2
- package/src/shared/index.ts +4 -0
- package/src/shared/platform.ts +11 -0
- package/src/shared/wsl.ts +139 -0
- package/src/tui/index.ts +26 -8
- package/src/tui/terminal-guard.ts +139 -0
- package/typeclaw.schema.json +2 -2
package/src/init/index.ts
CHANGED
|
@@ -20,7 +20,14 @@ import {
|
|
|
20
20
|
type KnownProviderId,
|
|
21
21
|
type ModelRef,
|
|
22
22
|
} from '@/config/providers'
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
checkDockerAvailable,
|
|
25
|
+
type DockerAvailability,
|
|
26
|
+
type DockerExec,
|
|
27
|
+
start,
|
|
28
|
+
type StartResult,
|
|
29
|
+
stop,
|
|
30
|
+
} from '@/container'
|
|
24
31
|
import { commitSystemFile } from '@/git/system-commit'
|
|
25
32
|
import { createSecretsStoreForAgent, type Channels, type Secret, SecretsBackend } from '@/secrets'
|
|
26
33
|
import { hostLocaleIsCjk } from '@/shared/host-locale'
|
|
@@ -376,10 +383,12 @@ export async function runInit({
|
|
|
376
383
|
emit({ step: 'install', phase: 'start' })
|
|
377
384
|
const install = await installRunner(cwd)
|
|
378
385
|
emit({ step: 'install', phase: 'done', result: install })
|
|
386
|
+
if (!install.ok) throw new Error(`Dependency install failed: ${install.reason}`)
|
|
379
387
|
|
|
380
388
|
emit({ step: 'dockerfile', phase: 'start' })
|
|
381
389
|
const docker = await writeDockerAssets(cwd)
|
|
382
390
|
emit({ step: 'dockerfile', phase: 'done', result: docker })
|
|
391
|
+
if (!docker.ok) throw new Error(`Dockerfile generation failed: ${docker.reason}`)
|
|
383
392
|
|
|
384
393
|
emit({ step: 'git', phase: 'start' })
|
|
385
394
|
const git = await initGitRepo(cwd)
|
|
@@ -417,6 +426,7 @@ export async function defaultRunHatching({
|
|
|
417
426
|
tui: tuiFactory = createTui,
|
|
418
427
|
waitForAgent: waitForAgentFn = waitForAgent,
|
|
419
428
|
runClaim = defaultRunClaim,
|
|
429
|
+
stopContainer = stop,
|
|
420
430
|
}: {
|
|
421
431
|
cwd: string
|
|
422
432
|
port: number
|
|
@@ -426,14 +436,17 @@ export async function defaultRunHatching({
|
|
|
426
436
|
tui?: typeof createTui
|
|
427
437
|
waitForAgent?: typeof waitForAgent
|
|
428
438
|
runClaim?: ClaimRunner
|
|
439
|
+
stopContainer?: typeof stop
|
|
429
440
|
}): Promise<HatchingResult> {
|
|
441
|
+
let launch: Extract<StartResult, { ok: true }> | null = null
|
|
430
442
|
try {
|
|
431
|
-
const
|
|
443
|
+
const startResult = await startContainer({
|
|
432
444
|
cwd,
|
|
433
445
|
preferredHostPort: port,
|
|
434
446
|
...(cliEntry !== undefined ? { cliEntry } : {}),
|
|
435
447
|
})
|
|
436
|
-
if (!
|
|
448
|
+
if (!startResult.ok) return { ok: false, reason: startResult.reason }
|
|
449
|
+
launch = startResult
|
|
437
450
|
|
|
438
451
|
// start() may have allocated a different host port (the preferred one was
|
|
439
452
|
// bound). Use the actually-published port for the TUI handshake instead of
|
|
@@ -455,6 +468,9 @@ export async function defaultRunHatching({
|
|
|
455
468
|
await tui.run()
|
|
456
469
|
return { ok: true }
|
|
457
470
|
} catch (error) {
|
|
471
|
+
if (launch !== null && !launch.alreadyRunning) {
|
|
472
|
+
await stopContainer({ cwd }).catch(() => {})
|
|
473
|
+
}
|
|
458
474
|
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
459
475
|
}
|
|
460
476
|
}
|
|
@@ -709,7 +725,7 @@ export async function initGitRepo(cwd: string): Promise<GitInitResult> {
|
|
|
709
725
|
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
710
726
|
if (!bun) return { ok: false, reason: 'bun runtime not available' }
|
|
711
727
|
|
|
712
|
-
|
|
728
|
+
const hasGit = existsSync(join(cwd, '.git'))
|
|
713
729
|
|
|
714
730
|
// Author the initial commit as TypeClaw itself. The agent is still unnamed
|
|
715
731
|
// (IDENTITY.md is empty and hatching hasn't run), so the agent identity will
|
|
@@ -724,10 +740,21 @@ export async function initGitRepo(cwd: string): Promise<GitInitResult> {
|
|
|
724
740
|
}
|
|
725
741
|
|
|
726
742
|
try {
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
743
|
+
if (hasGit) {
|
|
744
|
+
const head = bun.spawn({
|
|
745
|
+
cmd: ['git', 'rev-parse', '--verify', 'HEAD'],
|
|
746
|
+
cwd,
|
|
747
|
+
env,
|
|
748
|
+
stdout: 'pipe',
|
|
749
|
+
stderr: 'pipe',
|
|
750
|
+
})
|
|
751
|
+
if ((await head.exited) === 0) return { ok: true, skipped: true }
|
|
752
|
+
} else {
|
|
753
|
+
const init = bun.spawn({ cmd: ['git', 'init', '-b', 'main'], cwd, env, stdout: 'pipe', stderr: 'pipe' })
|
|
754
|
+
if ((await init.exited) !== 0) {
|
|
755
|
+
const stderr = await new Response(init.stderr).text()
|
|
756
|
+
return { ok: false, reason: `git init failed: ${stderr.trim() || 'no stderr'}` }
|
|
757
|
+
}
|
|
731
758
|
}
|
|
732
759
|
|
|
733
760
|
const add = bun.spawn({ cmd: ['git', 'add', '.'], cwd, env, stdout: 'pipe', stderr: 'pipe' })
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { join, resolve } from 'node:path'
|
|
1
|
+
import { join, posix, resolve } from 'node:path'
|
|
2
2
|
|
|
3
3
|
import { loginFlow as upstreamLoginFlow } from 'agent-messenger/kakaotalk'
|
|
4
4
|
|
|
@@ -34,7 +34,7 @@ export type LoginFlowOptions = Parameters<LoginFlowFn>[0]
|
|
|
34
34
|
export type LoginFlowResult = Awaited<ReturnType<LoginFlowFn>>
|
|
35
35
|
|
|
36
36
|
export function kakaotalkConfigDir(agentDir: string): string {
|
|
37
|
-
return join(agentDir, 'workspace', '.agent-messenger')
|
|
37
|
+
return posix.join(agentDir, 'workspace', '.agent-messenger')
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
export function kakaotalkSecretsPath(agentDir: string): string {
|
package/src/init/packagejson.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
|
-
import { join } from 'node:path'
|
|
3
|
+
import { join, posix } from 'node:path'
|
|
4
4
|
|
|
5
5
|
import { GITKEEP_FILE, PACKAGES_DIR } from './paths'
|
|
6
6
|
|
|
@@ -33,7 +33,7 @@ export async function refreshPackageJson(cwd: string): Promise<PackageJsonRefres
|
|
|
33
33
|
if (updated) changed.push(PACKAGE_FILE)
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
const gitkeepRel = join(PACKAGES_DIR, GITKEEP_FILE)
|
|
36
|
+
const gitkeepRel = posix.join(PACKAGES_DIR, GITKEEP_FILE)
|
|
37
37
|
const gitkeepPath = join(cwd, gitkeepRel)
|
|
38
38
|
if (!existsSync(gitkeepPath)) {
|
|
39
39
|
await mkdir(join(cwd, PACKAGES_DIR), { recursive: true })
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export type InstallResult = { ok: true } | { ok: false; reason: string }
|
|
2
2
|
|
|
3
|
+
const INSTALL_TIMEOUT_MS = 300_000
|
|
4
|
+
|
|
3
5
|
export type InstallRunnerOptions = {
|
|
4
6
|
// Append `--force` to the bun install argv to bypass the cache for
|
|
5
7
|
// `file:` / `link:` deps. Bun treats name+version of a `file:` dep as a
|
|
@@ -7,6 +9,8 @@ export type InstallRunnerOptions = {
|
|
|
7
9
|
// locally-linked typeclaw never propagate into <agent>/node_modules until
|
|
8
10
|
// either the typeclaw version is bumped or the install is forced.
|
|
9
11
|
force?: boolean
|
|
12
|
+
timeoutMs?: number
|
|
13
|
+
spawn?: typeof Bun.spawn
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
// Signature for the function `runInit` uses to materialize the agent folder's
|
|
@@ -19,31 +23,29 @@ export async function runBunInstall(cwd: string, opts?: InstallRunnerOptions): P
|
|
|
19
23
|
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
20
24
|
if (!bun) return { ok: false, reason: 'bun runtime not available' }
|
|
21
25
|
try {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
26
|
+
// `--linker=hoisted` sidesteps a deadlock in Bun 1.3.x's isolated linker
|
|
27
|
+
// (the default since 1.3.0). When any single package fetch fails — 401,
|
|
28
|
+
// SHA-512 mismatch, transient registry 5xx, the kind of flake that's
|
|
29
|
+
// routine on GitHub Actions shared-IP runners — the isolated linker
|
|
30
|
+
// hangs the process indefinitely instead of erroring out
|
|
31
|
+
// (oven-sh/bun#26341, oven-sh/bun#29646). `bun install` runs here over
|
|
32
|
+
// ~500 transitive packages with no lockfile, so the odds of triggering
|
|
33
|
+
// the bug are non-trivial. Hoisted is the fallback strategy bun shipped
|
|
34
|
+
// before 1.3 — slightly slower for huge monorepos, indistinguishable
|
|
35
|
+
// for an agent folder, and not affected by the bug.
|
|
36
|
+
//
|
|
37
|
+
// `--force` is conditional: it bypasses the package cache so file:/link:
|
|
38
|
+
// deps re-copy their current on-disk source into node_modules. Bun's
|
|
39
|
+
// file-dep cache is keyed on name+version, so without --force, edits to
|
|
40
|
+
// a `file:..` typeclaw never reach the container after the first install.
|
|
41
|
+
return await runTimedBunProcess({
|
|
38
42
|
cmd: opts?.force ? ['bun', 'install', '--linker=hoisted', '--force'] : ['bun', 'install', '--linker=hoisted'],
|
|
39
43
|
cwd,
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
timeoutMs: opts?.timeoutMs ?? INSTALL_TIMEOUT_MS,
|
|
45
|
+
spawn: opts?.spawn ?? bun.spawn,
|
|
46
|
+
timeoutReason: (seconds) => `bun install timed out after ${seconds}s`,
|
|
47
|
+
failureReason: (code, stderr) => `bun install exited with code ${code}: ${stderr.trim() || 'no stderr'}`,
|
|
42
48
|
})
|
|
43
|
-
const code = await proc.exited
|
|
44
|
-
if (code === 0) return { ok: true }
|
|
45
|
-
const stderr = await new Response(proc.stderr).text()
|
|
46
|
-
return { ok: false, reason: `bun install exited with code ${code}: ${stderr.trim() || 'no stderr'}` }
|
|
47
49
|
} catch (error) {
|
|
48
50
|
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
49
51
|
}
|
|
@@ -55,30 +57,62 @@ export async function runBunInstall(cwd: string, opts?: InstallRunnerOptions): P
|
|
|
55
57
|
// install` would no-op when the existing lockfile entry already satisfies
|
|
56
58
|
// an in-range spec — which is the exact regression auto-upgrade exists to
|
|
57
59
|
// prevent).
|
|
58
|
-
export type
|
|
60
|
+
export type UpdateRunnerOptions = Pick<InstallRunnerOptions, 'timeoutMs' | 'spawn'>
|
|
59
61
|
|
|
60
|
-
export
|
|
62
|
+
export type UpdateRunner = (cwd: string, pkg: string, opts?: UpdateRunnerOptions) => Promise<InstallResult>
|
|
63
|
+
|
|
64
|
+
export async function runBunUpdate(cwd: string, pkg: string, opts?: UpdateRunnerOptions): Promise<InstallResult> {
|
|
61
65
|
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
62
66
|
if (!bun) return { ok: false, reason: 'bun runtime not available' }
|
|
63
67
|
try {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
// `bun update <pkg> --latest` re-resolves <pkg> against the registry,
|
|
69
|
+
// capped by the spec in package.json. For a caret/tilde range this
|
|
70
|
+
// pulls the highest in-range version (the case `bun install` won't
|
|
71
|
+
// upgrade because the lockfile already satisfies the spec). For an
|
|
72
|
+
// exact pin it's effectively a force re-fetch of that exact version.
|
|
73
|
+
// `--linker=hoisted` for the same Bun 1.3.x deadlock reason as
|
|
74
|
+
// runBunInstall above.
|
|
75
|
+
return await runTimedBunProcess({
|
|
72
76
|
cmd: ['bun', 'update', pkg, '--latest', '--linker=hoisted'],
|
|
73
77
|
cwd,
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
timeoutMs: opts?.timeoutMs ?? INSTALL_TIMEOUT_MS,
|
|
79
|
+
spawn: opts?.spawn ?? bun.spawn,
|
|
80
|
+
timeoutReason: (seconds) => `bun update ${pkg} timed out after ${seconds}s`,
|
|
81
|
+
failureReason: (code, stderr) => `bun update ${pkg} exited with code ${code}: ${stderr.trim() || 'no stderr'}`,
|
|
76
82
|
})
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function runTimedBunProcess({
|
|
89
|
+
cmd,
|
|
90
|
+
cwd,
|
|
91
|
+
timeoutMs,
|
|
92
|
+
spawn,
|
|
93
|
+
timeoutReason,
|
|
94
|
+
failureReason,
|
|
95
|
+
}: {
|
|
96
|
+
cmd: string[]
|
|
97
|
+
cwd: string
|
|
98
|
+
timeoutMs: number
|
|
99
|
+
spawn: typeof Bun.spawn
|
|
100
|
+
timeoutReason: (seconds: number) => string
|
|
101
|
+
failureReason: (code: number, stderr: string) => string
|
|
102
|
+
}): Promise<InstallResult> {
|
|
103
|
+
const proc = spawn({ cmd, cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
104
|
+
let timedOut = false
|
|
105
|
+
const timeout = setTimeout(() => {
|
|
106
|
+
timedOut = true
|
|
107
|
+
proc.kill('SIGKILL')
|
|
108
|
+
}, timeoutMs)
|
|
109
|
+
try {
|
|
77
110
|
const code = await proc.exited
|
|
111
|
+
if (timedOut) return { ok: false, reason: timeoutReason(timeoutMs / 1000) }
|
|
78
112
|
if (code === 0) return { ok: true }
|
|
79
113
|
const stderr = await new Response(proc.stderr).text()
|
|
80
|
-
return { ok: false, reason:
|
|
81
|
-
}
|
|
82
|
-
|
|
114
|
+
return { ok: false, reason: failureReason(code, stderr) }
|
|
115
|
+
} finally {
|
|
116
|
+
clearTimeout(timeout)
|
|
83
117
|
}
|
|
84
118
|
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from '@mariozechner/pi-tui'
|
|
11
11
|
|
|
12
12
|
import { formatToolEnd, formatToolStart, formatUserPromptHistory } from '@/tui/format'
|
|
13
|
+
import { armTerminalGuard } from '@/tui/terminal-guard'
|
|
13
14
|
import { colors, markdownTheme } from '@/tui/theme'
|
|
14
15
|
|
|
15
16
|
import { streamSessionEvents, type LiveSourceFactory, type StreamPhase } from './index'
|
|
@@ -46,6 +47,9 @@ export function createTranscriptView(opts: TranscriptViewOptions) {
|
|
|
46
47
|
tui.addChild(new Text(header(opts.summary), 0, 0))
|
|
47
48
|
tui.addChild(status)
|
|
48
49
|
tui.start()
|
|
50
|
+
// Restores the terminal on an abnormal exit (SSH SIGHUP, kill, crash) that
|
|
51
|
+
// never reaches tui.stop(), so the shell isn't left in Kitty kbd mode.
|
|
52
|
+
const guard = armTerminalGuard()
|
|
49
53
|
tui.requestRender()
|
|
50
54
|
|
|
51
55
|
// The status line is pinned last (no editor to pin, unlike createTui). Each
|
|
@@ -71,12 +75,21 @@ export function createTranscriptView(opts: TranscriptViewOptions) {
|
|
|
71
75
|
const outcome = new Promise<TranscriptViewOutcome>((resolve) => {
|
|
72
76
|
settle = resolve
|
|
73
77
|
})
|
|
78
|
+
// drainInput() before stop() swallows late Kitty key-release bytes so they
|
|
79
|
+
// don't leak to the shell over a slow SSH link; disarm() drops the abnormal-
|
|
80
|
+
// exit guard now that a clean teardown ran.
|
|
74
81
|
const finish = (reason: TranscriptViewOutcome['reason']): void => {
|
|
75
82
|
if (settle === null) return
|
|
76
83
|
const fn = settle
|
|
77
84
|
settle = null
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
void terminal
|
|
86
|
+
.drainInput()
|
|
87
|
+
.catch(() => {})
|
|
88
|
+
.then(() => {
|
|
89
|
+
tui.stop()
|
|
90
|
+
guard.disarm()
|
|
91
|
+
fn({ reason })
|
|
92
|
+
})
|
|
80
93
|
}
|
|
81
94
|
|
|
82
95
|
tui.addInputListener((data) => {
|
package/src/plugin/loader.ts
CHANGED
|
@@ -61,8 +61,7 @@ async function loadLocal(entry: string, agentDir: string): Promise<ResolvedPlugi
|
|
|
61
61
|
if (!existsSync(resolved)) {
|
|
62
62
|
throw new PluginNotFoundError(entry, `plugin path does not exist: ${entry} (resolved to ${resolved})`)
|
|
63
63
|
}
|
|
64
|
-
const
|
|
65
|
-
const mod = (await import(url)) as { default?: unknown }
|
|
64
|
+
const mod = (await import(toModuleSpecifier(resolved))) as { default?: unknown }
|
|
66
65
|
const defined = expectDefined(mod, entry)
|
|
67
66
|
const name = basename(resolved).replace(/\.(ts|tsx|js|mjs|cjs)$/i, '')
|
|
68
67
|
return { name, version: undefined, source: entry, defined }
|
|
@@ -108,10 +107,10 @@ async function loadNpm(entry: string, agentDir: string): Promise<ResolvedPlugin>
|
|
|
108
107
|
// located on disk; the else branch lets Bun's resolver read `exports` maps.
|
|
109
108
|
let importTarget: string
|
|
110
109
|
if (entryPath !== null) {
|
|
111
|
-
importTarget =
|
|
110
|
+
importTarget = toModuleSpecifier(entryPath)
|
|
112
111
|
} else {
|
|
113
112
|
try {
|
|
114
|
-
importTarget = Bun.resolveSync(packageName, agentDir)
|
|
113
|
+
importTarget = toModuleSpecifier(Bun.resolveSync(packageName, agentDir))
|
|
115
114
|
} catch (err) {
|
|
116
115
|
throw new PluginNotFoundError(entry, `cannot resolve plugin "${entry}": ${describeError(err)}`, { cause: err })
|
|
117
116
|
}
|
|
@@ -151,6 +150,10 @@ function describeError(err: unknown): string {
|
|
|
151
150
|
return err instanceof Error ? err.message : String(err)
|
|
152
151
|
}
|
|
153
152
|
|
|
153
|
+
function toModuleSpecifier(target: string): string {
|
|
154
|
+
return isAbsolute(target) ? pathToFileURL(target).href : target
|
|
155
|
+
}
|
|
156
|
+
|
|
154
157
|
function findPackageJson(entry: string, agentDir: string): string | null {
|
|
155
158
|
const PACKAGE_JSON = 'package.json'
|
|
156
159
|
let cur = agentDir
|
|
@@ -30,7 +30,8 @@ export type BrokerOptions = {
|
|
|
30
30
|
// and reports the reason so hostd can GC the stale registration.
|
|
31
31
|
onFatalAuthFailure?: (reason: string) => void
|
|
32
32
|
onLog?: (msg: string) => void
|
|
33
|
-
connectWs?: (url: string) => Promise<WsClient>
|
|
33
|
+
connectWs?: (url: string, timeoutMs: number) => Promise<WsClient>
|
|
34
|
+
connectTimeoutMs?: number
|
|
34
35
|
listenHost?: ListenHostFn
|
|
35
36
|
reconnectDelaysMs?: number[]
|
|
36
37
|
hostBindAddr?: string
|
|
@@ -71,6 +72,7 @@ export type Broker = {
|
|
|
71
72
|
|
|
72
73
|
const DEFAULT_RECONNECT_DELAYS = [1_000, 2_000, 4_000, 10_000]
|
|
73
74
|
const DEFAULT_HOST_BIND = '127.0.0.1'
|
|
75
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000
|
|
74
76
|
|
|
75
77
|
// broker-hello-nack reasons emitted by container-server.ts when authentication
|
|
76
78
|
// fails. These are immutable for a broker instance's lifetime, so reconnecting
|
|
@@ -82,6 +84,7 @@ export function createBroker(opts: BrokerOptions): Broker {
|
|
|
82
84
|
const reconnectDelays = opts.reconnectDelaysMs ?? DEFAULT_RECONNECT_DELAYS
|
|
83
85
|
const hostBind = opts.hostBindAddr ?? DEFAULT_HOST_BIND
|
|
84
86
|
const connectWs = opts.connectWs ?? defaultConnectWs
|
|
87
|
+
const connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
|
|
85
88
|
const listenHost = opts.listenHost ?? defaultListenHost
|
|
86
89
|
|
|
87
90
|
type ForwarderState = {
|
|
@@ -437,7 +440,7 @@ export function createBroker(opts: BrokerOptions): Broker {
|
|
|
437
440
|
const url = `ws://127.0.0.1:${hostPort}/portbroker`
|
|
438
441
|
let client: WsClient
|
|
439
442
|
try {
|
|
440
|
-
client = await connectWs(url)
|
|
443
|
+
client = await connectWs(url, connectTimeoutMs)
|
|
441
444
|
} catch (err) {
|
|
442
445
|
log(`ws connect ${url}: ${err instanceof Error ? err.message : String(err)}`)
|
|
443
446
|
scheduleReconnect()
|
|
@@ -504,12 +507,31 @@ export function createBroker(opts: BrokerOptions): Broker {
|
|
|
504
507
|
}
|
|
505
508
|
}
|
|
506
509
|
|
|
507
|
-
async function defaultConnectWs(url: string): Promise<WsClient> {
|
|
510
|
+
async function defaultConnectWs(url: string, timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS): Promise<WsClient> {
|
|
508
511
|
return new Promise((resolve, reject) => {
|
|
509
512
|
const ws = new WebSocket(url)
|
|
510
|
-
let
|
|
513
|
+
let settled = false
|
|
511
514
|
const messageCbs: Array<(msg: ContainerToHostd) => void> = []
|
|
512
515
|
const closeCbs: Array<() => void> = []
|
|
516
|
+
let connectTimeout: ReturnType<typeof setTimeout> | null = null
|
|
517
|
+
const clearConnectTimeout = (): void => {
|
|
518
|
+
if (connectTimeout !== null) {
|
|
519
|
+
clearTimeout(connectTimeout)
|
|
520
|
+
connectTimeout = null
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const failConnect = (err: Error): void => {
|
|
524
|
+
if (settled) return
|
|
525
|
+
settled = true
|
|
526
|
+
clearConnectTimeout()
|
|
527
|
+
reject(err)
|
|
528
|
+
}
|
|
529
|
+
connectTimeout = setTimeout(() => {
|
|
530
|
+
failConnect(new Error(`ws connect timeout after ${timeoutMs}ms to ${url}`))
|
|
531
|
+
try {
|
|
532
|
+
ws.close()
|
|
533
|
+
} catch {}
|
|
534
|
+
}, timeoutMs)
|
|
513
535
|
const client: WsClient = {
|
|
514
536
|
send: (msg) => {
|
|
515
537
|
try {
|
|
@@ -529,7 +551,9 @@ async function defaultConnectWs(url: string): Promise<WsClient> {
|
|
|
529
551
|
},
|
|
530
552
|
}
|
|
531
553
|
ws.onopen = (): void => {
|
|
532
|
-
|
|
554
|
+
if (settled) return
|
|
555
|
+
settled = true
|
|
556
|
+
clearConnectTimeout()
|
|
533
557
|
resolve(client)
|
|
534
558
|
}
|
|
535
559
|
ws.onmessage = (ev: MessageEvent): void => {
|
|
@@ -542,9 +566,11 @@ async function defaultConnectWs(url: string): Promise<WsClient> {
|
|
|
542
566
|
for (const cb of messageCbs) cb(msg)
|
|
543
567
|
}
|
|
544
568
|
ws.onerror = (): void => {
|
|
545
|
-
|
|
569
|
+
failConnect(new Error(`ws error connecting to ${url}`))
|
|
546
570
|
}
|
|
547
571
|
ws.onclose = (): void => {
|
|
572
|
+
if (!settled) failConnect(new Error(`ws closed connecting to ${url}`))
|
|
573
|
+
else clearConnectTimeout()
|
|
548
574
|
for (const cb of closeCbs) cb()
|
|
549
575
|
}
|
|
550
576
|
})
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { mkdir } from 'node:fs/promises'
|
|
2
|
-
import {
|
|
2
|
+
import { posix } from 'node:path'
|
|
3
|
+
|
|
4
|
+
// Container-only code over the POSIX `/tmp`; pinned to `path.posix` so the test
|
|
5
|
+
// suite produces the same backing paths on a win32 runner (default `node:path`
|
|
6
|
+
// would yield `\tmp\…` and diverge from the Linux runtime).
|
|
7
|
+
const { isAbsolute, join, relative, resolve } = posix
|
|
3
8
|
|
|
4
9
|
// Per-session scratch lives on the REAL container /tmp, namespaced by session id.
|
|
5
10
|
// It sits OUTSIDE the agent folder on purpose: the agent folder's `sessions/` is
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
writeFileSync,
|
|
10
10
|
} from 'node:fs'
|
|
11
11
|
import { homedir } from 'node:os'
|
|
12
|
-
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
12
|
+
import { dirname, isAbsolute, join, posix, resolve } from 'node:path'
|
|
13
13
|
|
|
14
14
|
import { decodeClaudeAccessTokenExpiryMs, emitClaudeCredentialsJson } from './claude-credentials-json'
|
|
15
15
|
import type { ProviderCredential, Providers } from './schema'
|
|
@@ -19,7 +19,7 @@ const FILE_MODE = 0o600
|
|
|
19
19
|
const DIR_MODE = 0o700
|
|
20
20
|
export const CLAUDE_CREDENTIALS_FILE_NAME = '.credentials.json'
|
|
21
21
|
export const CLAUDE_DEFAULT_CONFIG_DIR_NAME = '.claude'
|
|
22
|
-
export const CLAUDE_CREDENTIALS_RELATIVE_PATH = join(CLAUDE_DEFAULT_CONFIG_DIR_NAME, CLAUDE_CREDENTIALS_FILE_NAME)
|
|
22
|
+
export const CLAUDE_CREDENTIALS_RELATIVE_PATH = posix.join(CLAUDE_DEFAULT_CONFIG_DIR_NAME, CLAUDE_CREDENTIALS_FILE_NAME)
|
|
23
23
|
|
|
24
24
|
export type ExportClaudeCredentialsFileResult =
|
|
25
25
|
| { action: 'skipped'; reason: SkipReason }
|
package/src/shared/index.ts
CHANGED
|
@@ -27,3 +27,7 @@ export {
|
|
|
27
27
|
} from './protocol'
|
|
28
28
|
|
|
29
29
|
export { formatLocalDate, formatLocalDateTime, formatLocalWeekday, resolveLocalTimezoneName } from './local-time'
|
|
30
|
+
|
|
31
|
+
export { detectWsl, isWindowsDriveMount, type WslInfo, type WslVersion } from './wsl'
|
|
32
|
+
|
|
33
|
+
export { isMacOS, isWindows } from './platform'
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Centralized seam so host-stage macOS/Linux/Windows branches stay greppable as
|
|
2
|
+
// native-Windows support lands; the default arg lets tests pass a platform
|
|
3
|
+
// without mutating the read-only `process.platform`.
|
|
4
|
+
|
|
5
|
+
export function isWindows(platform: NodeJS.Platform = process.platform): boolean {
|
|
6
|
+
return platform === 'win32'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isMacOS(platform: NodeJS.Platform = process.platform): boolean {
|
|
10
|
+
return platform === 'darwin'
|
|
11
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { release } from 'node:os'
|
|
3
|
+
|
|
4
|
+
// Detection of WSL (Windows Subsystem for Linux) and Windows-drive mounts.
|
|
5
|
+
//
|
|
6
|
+
// Why this matters for typeclaw: inside WSL the kernel is real Linux, so the
|
|
7
|
+
// host stage runs unchanged — EXCEPT when files live on a Windows-drive mount
|
|
8
|
+
// (DrvFs on WSL1, 9p on WSL2, e.g. `/mnt/c/...`). On those mounts POSIX
|
|
9
|
+
// permissions are NOT enforced: `chmod 0o600` silently succeeds but leaves the
|
|
10
|
+
// file world-readable. typeclaw stores encryption keys and API tokens with
|
|
11
|
+
// mode 0600 (src/secrets/*, src/hostd/paths.ts), so a secrets file on `/mnt/c`
|
|
12
|
+
// loses its confidentiality guarantee. The doctor surfaces this as a warning.
|
|
13
|
+
|
|
14
|
+
export type WslVersion = 1 | 2
|
|
15
|
+
|
|
16
|
+
export type WslInfo = {
|
|
17
|
+
isWsl: boolean
|
|
18
|
+
// null when not WSL, or WSL but the version can't be determined (e.g. a
|
|
19
|
+
// custom kernel detected only via the binfmt/`/run/WSL` artifacts).
|
|
20
|
+
version: WslVersion | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Injectable so the pure detection logic is unit-testable without a real
|
|
24
|
+
// `/proc` or `/etc/wsl.conf`; production uses the real-filesystem defaults.
|
|
25
|
+
export type WslProbes = {
|
|
26
|
+
platform: NodeJS.Platform
|
|
27
|
+
kernelRelease: () => string
|
|
28
|
+
readFile: (path: string) => string | null
|
|
29
|
+
fileExists: (path: string) => boolean
|
|
30
|
+
env: NodeJS.ProcessEnv
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function safeReadFile(path: string): string | null {
|
|
34
|
+
try {
|
|
35
|
+
return readFileSync(path, 'utf8')
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const defaultProbes: WslProbes = {
|
|
42
|
+
platform: process.platform,
|
|
43
|
+
kernelRelease: release,
|
|
44
|
+
readFile: safeReadFile,
|
|
45
|
+
fileExists: existsSync,
|
|
46
|
+
env: process.env,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Mirrors the is-wsl@3 cascade: os.release() → /proc/version → WSL runtime
|
|
50
|
+
// artifacts. Matching is case-insensitive because WSL1 stamps `Microsoft`
|
|
51
|
+
// (capital M) and WSL2 stamps `microsoft` (lowercase) into the kernel strings.
|
|
52
|
+
// A user-compiled WSL2 kernel can omit the string entirely, which is why the
|
|
53
|
+
// binfmt/`/run/WSL` artifacts are the final fallback.
|
|
54
|
+
export function detectWslWith(probes: WslProbes): WslInfo {
|
|
55
|
+
if (probes.platform !== 'linux') return { isWsl: false, version: null }
|
|
56
|
+
|
|
57
|
+
const kernelRelease = probes.kernelRelease().toLowerCase()
|
|
58
|
+
const procVersion = (probes.readFile('/proc/version') ?? '').toLowerCase()
|
|
59
|
+
|
|
60
|
+
const kernelSaysMicrosoft = kernelRelease.includes('microsoft')
|
|
61
|
+
const procSaysMicrosoft = procVersion.includes('microsoft')
|
|
62
|
+
const hasArtifacts = probes.fileExists('/proc/sys/fs/binfmt_misc/WSLInterop') || probes.fileExists('/run/WSL')
|
|
63
|
+
|
|
64
|
+
if (!kernelSaysMicrosoft && !procSaysMicrosoft && !hasArtifacts) {
|
|
65
|
+
return { isWsl: false, version: null }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { isWsl: true, version: resolveWslVersion({ kernelRelease, procVersion, env: probes.env }) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// WSL_INTEROP is present only under WSL2 (Microsoft's own recommendation in
|
|
72
|
+
// microsoft/WSL#4555). It can be stripped by `sudo`, so the kernel-string
|
|
73
|
+
// heuristics back it up: WSL2 kernels are `*-microsoft-standard*` / contain
|
|
74
|
+
// `wsl2`; WSL1 kernels are the older `*-Microsoft` form without "standard".
|
|
75
|
+
function resolveWslVersion(args: {
|
|
76
|
+
kernelRelease: string
|
|
77
|
+
procVersion: string
|
|
78
|
+
env: NodeJS.ProcessEnv
|
|
79
|
+
}): WslVersion | null {
|
|
80
|
+
if (args.env.WSL_INTEROP !== undefined && args.env.WSL_INTEROP !== '') return 2
|
|
81
|
+
|
|
82
|
+
const haystack = `${args.kernelRelease} ${args.procVersion}`
|
|
83
|
+
if (haystack.includes('wsl2') || haystack.includes('microsoft-standard')) return 2
|
|
84
|
+
|
|
85
|
+
// Older WSL1 kernels say `microsoft` but never `standard`/`wsl2`. If we only
|
|
86
|
+
// know it's WSL from the kernel/proc string (not the artifacts), call it v1.
|
|
87
|
+
if (args.kernelRelease.includes('microsoft') || args.procVersion.includes('microsoft')) return 1
|
|
88
|
+
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function detectWsl(): WslInfo {
|
|
93
|
+
return detectWslWith(defaultProbes)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The WSL automount root defaults to `/mnt/` but can be remapped via
|
|
97
|
+
// `/etc/wsl.conf` ([automount] root = ...). Returns the configured root with a
|
|
98
|
+
// guaranteed trailing slash, or `/mnt/` when unset/unreadable.
|
|
99
|
+
export function readAutomountRootWith(probes: Pick<WslProbes, 'readFile'>): string {
|
|
100
|
+
const conf = probes.readFile('/etc/wsl.conf')
|
|
101
|
+
const parsed = conf === null ? null : parseAutomountRoot(conf)
|
|
102
|
+
const root = parsed ?? '/mnt/'
|
|
103
|
+
return root.endsWith('/') ? root : `${root}/`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseAutomountRoot(content: string): string | null {
|
|
107
|
+
let inAutomountSection = false
|
|
108
|
+
for (const rawLine of content.split('\n')) {
|
|
109
|
+
const line = rawLine.trim()
|
|
110
|
+
if (line.length === 0 || line.startsWith('#') || line.startsWith(';')) continue
|
|
111
|
+
const section = /^\[(?<name>[^\]]+)\]$/.exec(line)
|
|
112
|
+
if (section) {
|
|
113
|
+
inAutomountSection = section.groups?.name?.trim().toLowerCase() === 'automount'
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
if (!inAutomountSection) continue
|
|
117
|
+
const match = /^root\s*=\s*(?<value>"[^"]*"|'[^']*'|[^#;]*)/.exec(line)
|
|
118
|
+
const raw = match?.groups?.value
|
|
119
|
+
if (raw === undefined) continue
|
|
120
|
+
const value = raw.trim().replace(/^["']|["']$/g, '')
|
|
121
|
+
if (value.length > 0) return value
|
|
122
|
+
}
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// True when `path` lives under the WSL Windows-drive automount (e.g.
|
|
127
|
+
// `/mnt/c/...`), where Unix permissions are not enforced. The drive component
|
|
128
|
+
// must be a single ASCII letter so `/mnt/wsl/...` (WSLg, not a Windows drive)
|
|
129
|
+
// is correctly excluded.
|
|
130
|
+
export function isWindowsDriveMountWith(path: string, probes: Pick<WslProbes, 'readFile'>): boolean {
|
|
131
|
+
const root = readAutomountRootWith(probes)
|
|
132
|
+
if (!path.startsWith(root)) return false
|
|
133
|
+
const rest = path.slice(root.length)
|
|
134
|
+
return /^[A-Za-z](?:\/|$)/.test(rest)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function isWindowsDriveMount(path: string): boolean {
|
|
138
|
+
return isWindowsDriveMountWith(path, defaultProbes)
|
|
139
|
+
}
|