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/doctor/checks.ts
CHANGED
|
@@ -15,11 +15,12 @@ import {
|
|
|
15
15
|
resolveHostPort,
|
|
16
16
|
type DockerExec,
|
|
17
17
|
} from '@/container'
|
|
18
|
-
import { isDaemonReachable, send } from '@/hostd'
|
|
18
|
+
import { homeRoot, isDaemonReachable, send } from '@/hostd'
|
|
19
19
|
import { resolveBaseImageVersion } from '@/init/cli-version'
|
|
20
20
|
import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
|
|
21
21
|
import { detectMissingDeps } from '@/init/ensure-deps'
|
|
22
22
|
import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
|
|
23
|
+
import { detectWsl, isWindows, isWindowsDriveMount, type WslInfo } from '@/shared'
|
|
23
24
|
|
|
24
25
|
import { buildChannelChecks } from './channel-checks'
|
|
25
26
|
import type { DoctorCheck } from './types'
|
|
@@ -37,8 +38,11 @@ export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): Docto
|
|
|
37
38
|
agentFolderGitRepo(),
|
|
38
39
|
configValid(),
|
|
39
40
|
hostdHomeWritable(),
|
|
41
|
+
wslDriveMount(),
|
|
42
|
+
windowsSecretPerms(),
|
|
40
43
|
hostdReachable(),
|
|
41
44
|
hostdRegistration(),
|
|
45
|
+
windowsBindMount(),
|
|
42
46
|
containerState(dockerExec),
|
|
43
47
|
containerHostPort(),
|
|
44
48
|
...buildChannelChecks(),
|
|
@@ -237,6 +241,142 @@ function hostdHomeWritable(): DoctorCheck {
|
|
|
237
241
|
}
|
|
238
242
|
}
|
|
239
243
|
|
|
244
|
+
export type WslDriveMountDeps = {
|
|
245
|
+
detect: () => WslInfo
|
|
246
|
+
isWindowsDriveMount: (path: string) => boolean
|
|
247
|
+
typeclawHome: () => string
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Under WSL, files on a Windows-drive mount (/mnt/c/...) don't enforce Unix
|
|
251
|
+
// permissions, so the 0600 chmod that protects secrets.json and the encryption
|
|
252
|
+
// keys is silently ignored — they become readable by every local user. Warn
|
|
253
|
+
// when either the agent folder or ~/.typeclaw lives on such a mount.
|
|
254
|
+
export function wslDriveMount(deps: Partial<WslDriveMountDeps> = {}): DoctorCheck {
|
|
255
|
+
const detect = deps.detect ?? detectWsl
|
|
256
|
+
const onWindowsDrive = deps.isWindowsDriveMount ?? isWindowsDriveMount
|
|
257
|
+
const typeclawHome = deps.typeclawHome ?? homeRoot
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
name: 'hostd.wsl-drive-mount',
|
|
261
|
+
category: 'hostd',
|
|
262
|
+
description: 'agent state is not on a Windows-drive mount under WSL',
|
|
263
|
+
async run(ctx) {
|
|
264
|
+
if (!detect().isWsl) return { status: 'ok', message: 'not running under WSL' }
|
|
265
|
+
|
|
266
|
+
const offenders: string[] = []
|
|
267
|
+
if (ctx.hasAgentFolder && onWindowsDrive(ctx.cwd)) offenders.push(`agent folder: ${ctx.cwd}`)
|
|
268
|
+
const home = typeclawHome()
|
|
269
|
+
if (onWindowsDrive(home)) offenders.push(`hostd home: ${home}`)
|
|
270
|
+
|
|
271
|
+
if (offenders.length === 0) {
|
|
272
|
+
return { status: 'ok', message: 'agent state is on the Linux filesystem' }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
status: 'warning',
|
|
277
|
+
message: 'agent state is on a Windows-drive mount; file permissions are not enforced',
|
|
278
|
+
details: [
|
|
279
|
+
...offenders,
|
|
280
|
+
'chmod is a no-op on /mnt/<drive> (DrvFs/9p), so secrets.json and encryption keys become world-readable.',
|
|
281
|
+
],
|
|
282
|
+
fix: {
|
|
283
|
+
description:
|
|
284
|
+
'Move the agent folder to the WSL Linux filesystem (e.g. ~/my-agent) and, if needed, set TYPECLAW_HOME to a Linux path.',
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export type WindowsSecretPermsDeps = {
|
|
292
|
+
isWindows: () => boolean
|
|
293
|
+
typeclawHome: () => string
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// On native Windows the 0600/0700 modes typeclaw sets on secrets.json and the
|
|
297
|
+
// encryption keys are no-ops — NTFS uses ACLs, not Unix modes — so their
|
|
298
|
+
// confidentiality rests on the inherited %USERPROFILE% ACLs rather than the
|
|
299
|
+
// hardening typeclaw enforces on POSIX. Surface that as a warning.
|
|
300
|
+
export function windowsSecretPerms(deps: Partial<WindowsSecretPermsDeps> = {}): DoctorCheck {
|
|
301
|
+
const onWindows = deps.isWindows ?? isWindows
|
|
302
|
+
const typeclawHome = deps.typeclawHome ?? homeRoot
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
name: 'hostd.windows-secret-perms',
|
|
306
|
+
category: 'hostd',
|
|
307
|
+
description: 'secrets rely on enforced file permissions (native Windows)',
|
|
308
|
+
async run(ctx) {
|
|
309
|
+
if (!onWindows()) return { status: 'ok', message: 'not running on native Windows' }
|
|
310
|
+
|
|
311
|
+
const details = [`hostd home: ${typeclawHome()}`]
|
|
312
|
+
if (ctx.hasAgentFolder) details.push(`agent folder: ${ctx.cwd}`)
|
|
313
|
+
details.push(
|
|
314
|
+
'NTFS ignores the 0600/0700 chmod typeclaw applies to secrets.json and encryption keys; their confidentiality relies on the inherited %USERPROFILE% ACLs instead.',
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
status: 'warning',
|
|
319
|
+
message: 'native Windows does not enforce the file modes that protect agent secrets',
|
|
320
|
+
details,
|
|
321
|
+
fix: {
|
|
322
|
+
description:
|
|
323
|
+
'Keep the agent folder and ~/.typeclaw under your user profile, where default ACLs restrict access to your account; avoid a shared or everyone-readable location.',
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export type WindowsBindMountDeps = {
|
|
331
|
+
isWindows: () => boolean
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Docker Desktop bind-mounts the agent folder into its Linux VM, and a few host
|
|
335
|
+
// locations don't survive that translation: UNC/network paths (\\server\share)
|
|
336
|
+
// aren't shareable, OneDrive-virtualized folders fail on placeholder files, and
|
|
337
|
+
// paths near the legacy MAX_PATH (260) limit break mid-build. Flag them so
|
|
338
|
+
// `typeclaw start` fails loudly here instead of cryptically at mount time.
|
|
339
|
+
export function windowsBindMount(deps: Partial<WindowsBindMountDeps> = {}): DoctorCheck {
|
|
340
|
+
const onWindows = deps.isWindows ?? isWindows
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
name: 'container.windows-bind-mount',
|
|
344
|
+
category: 'container',
|
|
345
|
+
description: 'agent folder is bind-mountable by Docker Desktop (native Windows)',
|
|
346
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
347
|
+
async run(ctx) {
|
|
348
|
+
if (!onWindows()) return { status: 'ok', message: 'not running on native Windows' }
|
|
349
|
+
|
|
350
|
+
const issues = detectWindowsBindMountIssues(ctx.cwd)
|
|
351
|
+
if (issues.length === 0) return { status: 'ok', message: 'agent folder path is bind-mountable' }
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
status: 'warning',
|
|
355
|
+
message: 'agent folder may not bind-mount cleanly under Docker Desktop',
|
|
356
|
+
details: issues,
|
|
357
|
+
fix: {
|
|
358
|
+
description:
|
|
359
|
+
'Use a local, short, non-OneDrive path under your user profile (e.g. C:\\agents\\my-agent), then re-run typeclaw start.',
|
|
360
|
+
},
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function detectWindowsBindMountIssues(path: string): string[] {
|
|
367
|
+
const issues: string[] = []
|
|
368
|
+
if (path.startsWith('\\\\')) {
|
|
369
|
+
issues.push(`UNC/network path is not shareable with Docker Desktop: ${path}`)
|
|
370
|
+
}
|
|
371
|
+
if (path.split(/[\\/]/).some((seg) => /^onedrive(?: -.*)?$/i.test(seg))) {
|
|
372
|
+
issues.push(`path is under OneDrive, where virtualized files can break bind mounts: ${path}`)
|
|
373
|
+
}
|
|
374
|
+
if (path.length > 260) {
|
|
375
|
+
issues.push(`path length ${path.length} exceeds the legacy Windows MAX_PATH (260) limit`)
|
|
376
|
+
}
|
|
377
|
+
return issues
|
|
378
|
+
}
|
|
379
|
+
|
|
240
380
|
function hostdReachable(): DoctorCheck {
|
|
241
381
|
return {
|
|
242
382
|
name: 'hostd.reachable',
|
|
@@ -362,9 +502,12 @@ function loadConfigStrictForTemplate(
|
|
|
362
502
|
return { dockerfile: cfg.docker.file, gitignore: cfg.git.ignore }
|
|
363
503
|
}
|
|
364
504
|
|
|
505
|
+
// Normalizes CRLF to LF: the managed templates are emitted with `\n`, but a
|
|
506
|
+
// checkout under Git for Windows (core.autocrlf=true) rewrites them to `\r\n`,
|
|
507
|
+
// which would make the byte-exact template comparison report a false divergence.
|
|
365
508
|
async function safeRead(path: string): Promise<string | null> {
|
|
366
509
|
try {
|
|
367
|
-
return await readFile(path, 'utf8')
|
|
510
|
+
return (await readFile(path, 'utf8')).replace(/\r\n/g, '\n')
|
|
368
511
|
} catch {
|
|
369
512
|
return null
|
|
370
513
|
}
|
package/src/hostd/client.ts
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
+
import { connect, type Socket as NetSocket } from 'node:net'
|
|
2
3
|
|
|
3
|
-
import
|
|
4
|
+
import { isWindows } from '@/shared'
|
|
4
5
|
|
|
5
6
|
import { socketPath } from './paths'
|
|
6
7
|
import type { Request, Response } from './protocol'
|
|
7
8
|
|
|
8
9
|
const DEFAULT_TIMEOUT_MS = 3_000
|
|
9
10
|
|
|
10
|
-
export async function isDaemonReachable(
|
|
11
|
-
|
|
11
|
+
export async function isDaemonReachable(
|
|
12
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
13
|
+
opts: Pick<SendOptions, 'socket'> = {},
|
|
14
|
+
): Promise<boolean> {
|
|
15
|
+
const path = opts.socket ?? socketPath()
|
|
16
|
+
if (!isWindows() && !existsSync(path)) return false
|
|
12
17
|
try {
|
|
13
|
-
const reply = await send({ kind: 'list' }, { timeoutMs })
|
|
18
|
+
const reply = await send({ kind: 'list' }, { timeoutMs, socket: path })
|
|
14
19
|
return reply.ok
|
|
15
20
|
} catch {
|
|
16
21
|
return false
|
|
@@ -58,56 +63,47 @@ export async function send(req: Request, opts: SendOptions = {}): Promise<Respon
|
|
|
58
63
|
const path = opts.socket ?? socketPath()
|
|
59
64
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
60
65
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
return new Promise<Response>((resolve) => {
|
|
67
|
+
let buf = ''
|
|
68
|
+
let settled = false
|
|
69
|
+
let sock: NetSocket | null = null
|
|
70
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
66
71
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
const finish = (response: Response, destroy = false): void => {
|
|
73
|
+
if (settled) return
|
|
74
|
+
settled = true
|
|
75
|
+
if (timer) clearTimeout(timer)
|
|
76
|
+
if (sock) {
|
|
77
|
+
try {
|
|
78
|
+
if (destroy) sock.destroy()
|
|
79
|
+
else sock.end()
|
|
80
|
+
} catch {}
|
|
81
|
+
}
|
|
82
|
+
resolve(response)
|
|
83
|
+
}
|
|
70
84
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
sock
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
},
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
},
|
|
85
|
+
timer = setTimeout(() => finish({ ok: false, reason: `daemon ack timeout after ${timeoutMs}ms` }, true), timeoutMs)
|
|
86
|
+
sock = connect(path)
|
|
87
|
+
sock.on('connect', () => {
|
|
88
|
+
try {
|
|
89
|
+
sock?.write(`${JSON.stringify(req)}\n`)
|
|
90
|
+
} catch (error) {
|
|
91
|
+
finish({ ok: false, reason: error instanceof Error ? error.message : String(error) }, true)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
sock.on('data', (chunk: Buffer) => {
|
|
95
|
+
buf += chunk.toString('utf8')
|
|
96
|
+
const newline = buf.indexOf('\n')
|
|
97
|
+
if (newline < 0) return
|
|
98
|
+
const line = buf.slice(0, newline)
|
|
99
|
+
try {
|
|
100
|
+
finish(JSON.parse(line) as Response)
|
|
101
|
+
} catch {
|
|
102
|
+
finish({ ok: false, reason: 'invalid response from daemon' }, true)
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
sock.on('error', (error) => {
|
|
106
|
+
finish({ ok: false, reason: error instanceof Error ? error.message : String(error) }, true)
|
|
94
107
|
})
|
|
95
|
-
} catch (error) {
|
|
96
|
-
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
97
|
-
}
|
|
98
|
-
sock.data = state
|
|
99
|
-
sock.write(`${JSON.stringify(req)}\n`)
|
|
100
|
-
|
|
101
|
-
let timer: ReturnType<typeof setTimeout> | null = null
|
|
102
|
-
const timeoutPromise = new Promise<Response>((resolve) => {
|
|
103
|
-
timer = setTimeout(() => resolve({ ok: false, reason: `daemon ack timeout after ${timeoutMs}ms` }), timeoutMs)
|
|
104
108
|
})
|
|
105
|
-
try {
|
|
106
|
-
return await Promise.race([replyPromise, timeoutPromise])
|
|
107
|
-
} finally {
|
|
108
|
-
if (timer) clearTimeout(timer)
|
|
109
|
-
try {
|
|
110
|
-
sock.end()
|
|
111
|
-
} catch {}
|
|
112
|
-
}
|
|
113
109
|
}
|
package/src/hostd/daemon.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { chmod, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { createServer, type Server, type Socket as NetSocket } from 'node:net'
|
|
3
4
|
import { join } from 'node:path'
|
|
4
5
|
|
|
5
|
-
import type { Socket, UnixSocketListener } from 'bun'
|
|
6
|
-
|
|
7
6
|
import type { PortForward } from '@/config'
|
|
8
7
|
import { defaultDockerExec, type DockerExec } from '@/container'
|
|
9
8
|
import type { PortForwardEvent } from '@/portbroker'
|
|
10
9
|
import { kakaoChannelBlockSchema, lineChannelBlockSchema } from '@/secrets/schema'
|
|
11
10
|
import { SecretsBackend } from '@/secrets/storage'
|
|
11
|
+
import { isWindows } from '@/shared'
|
|
12
12
|
|
|
13
13
|
import { isDaemonReachable } from './client'
|
|
14
14
|
import type { KakaoRenewalCallbacks, KakaoRenewalLogEvent } from './kakao-renewal-manager'
|
|
@@ -121,8 +121,6 @@ const MAX_HTTP_REQUEST_BYTES = 64 * 1024
|
|
|
121
121
|
// it's already in use by some other local service.
|
|
122
122
|
const STABLE_HTTP_PORT = 8974
|
|
123
123
|
|
|
124
|
-
type ServerState = { buf: string }
|
|
125
|
-
|
|
126
124
|
function json(response: RpcResponse, status = 200): globalThis.Response {
|
|
127
125
|
return new Response(JSON.stringify(response), {
|
|
128
126
|
status,
|
|
@@ -208,12 +206,54 @@ function stringifyError(error: unknown): string {
|
|
|
208
206
|
return error instanceof Error ? error.message : String(error)
|
|
209
207
|
}
|
|
210
208
|
|
|
209
|
+
function errorCode(error: Error): unknown {
|
|
210
|
+
const direct = error as Error & { code?: unknown; cause?: unknown }
|
|
211
|
+
if (direct.code !== undefined) return direct.code
|
|
212
|
+
if (direct.cause instanceof Error) return errorCode(direct.cause)
|
|
213
|
+
return undefined
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function listenOnSocket(server: Server, path: string, onWindows: boolean): Promise<void> {
|
|
217
|
+
await new Promise<void>((resolve, reject) => {
|
|
218
|
+
const onError = (error: Error): void => {
|
|
219
|
+
server.off('error', onError)
|
|
220
|
+
const code = errorCode(error)
|
|
221
|
+
if (code === 'EADDRINUSE' || (onWindows && error.message.includes('Failed to listen at'))) {
|
|
222
|
+
reject(new Error(`another typeclaw host daemon is already listening at ${path}`))
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
reject(error)
|
|
226
|
+
}
|
|
227
|
+
server.once('error', onError)
|
|
228
|
+
server.listen(path, () => {
|
|
229
|
+
server.off('error', onError)
|
|
230
|
+
resolve()
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function closeSocketServer(server: Server, sockets: Set<NetSocket>): Promise<void> {
|
|
236
|
+
await new Promise<void>((resolve) => {
|
|
237
|
+
try {
|
|
238
|
+
server.close(() => resolve())
|
|
239
|
+
} catch {
|
|
240
|
+
resolve()
|
|
241
|
+
}
|
|
242
|
+
for (const socket of sockets) {
|
|
243
|
+
try {
|
|
244
|
+
socket.destroy()
|
|
245
|
+
} catch {}
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
211
250
|
export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
212
251
|
await ensureDirs()
|
|
213
252
|
const path = opts.socket ?? socketPath()
|
|
253
|
+
const onWindows = isWindows()
|
|
214
254
|
|
|
215
|
-
if (existsSync(path)) {
|
|
216
|
-
if (await isDaemonReachable(500)) {
|
|
255
|
+
if (!onWindows && existsSync(path)) {
|
|
256
|
+
if (await isDaemonReachable(500, { socket: path })) {
|
|
217
257
|
throw new Error(`another typeclaw host daemon is already listening at ${path}`)
|
|
218
258
|
}
|
|
219
259
|
try {
|
|
@@ -488,37 +528,37 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
488
528
|
}
|
|
489
529
|
}
|
|
490
530
|
|
|
491
|
-
const respond = (
|
|
531
|
+
const respond = (socket: NetSocket, response: RpcResponse): void => {
|
|
492
532
|
try {
|
|
493
|
-
|
|
533
|
+
socket.write(`${JSON.stringify(response)}\n`)
|
|
494
534
|
} catch {}
|
|
495
535
|
try {
|
|
496
|
-
|
|
536
|
+
socket.end()
|
|
497
537
|
} catch {}
|
|
498
538
|
}
|
|
499
539
|
|
|
500
|
-
const handleData = (
|
|
501
|
-
|
|
502
|
-
if (
|
|
503
|
-
respond(
|
|
540
|
+
const handleData = (socket: NetSocket, chunk: Buffer, state: { buf: string }): void => {
|
|
541
|
+
state.buf += chunk.toString('utf8')
|
|
542
|
+
if (state.buf.length > MAX_REQUEST_BUFFER_BYTES) {
|
|
543
|
+
respond(socket, { ok: false, reason: 'request exceeds buffer limit' })
|
|
504
544
|
return
|
|
505
545
|
}
|
|
506
|
-
let newline =
|
|
546
|
+
let newline = state.buf.indexOf('\n')
|
|
507
547
|
while (newline >= 0) {
|
|
508
|
-
const line =
|
|
509
|
-
|
|
548
|
+
const line = state.buf.slice(0, newline)
|
|
549
|
+
state.buf = state.buf.slice(newline + 1)
|
|
510
550
|
let req: Request
|
|
511
551
|
try {
|
|
512
552
|
req = JSON.parse(line) as Request
|
|
513
553
|
} catch {
|
|
514
|
-
respond(
|
|
554
|
+
respond(socket, { ok: false, reason: 'invalid request json' })
|
|
515
555
|
return
|
|
516
556
|
}
|
|
517
557
|
void dispatch(req).then(
|
|
518
|
-
(response) => respond(
|
|
519
|
-
(error) => respond(
|
|
558
|
+
(response) => respond(socket, response),
|
|
559
|
+
(error) => respond(socket, { ok: false, reason: error instanceof Error ? error.message : String(error) }),
|
|
520
560
|
)
|
|
521
|
-
newline =
|
|
561
|
+
newline = state.buf.indexOf('\n')
|
|
522
562
|
}
|
|
523
563
|
}
|
|
524
564
|
|
|
@@ -599,25 +639,28 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
599
639
|
}
|
|
600
640
|
|
|
601
641
|
// Boot-time restore: replay every persisted registration into the in-memory
|
|
602
|
-
// maps and revive portbroker for it. Runs before
|
|
642
|
+
// maps and revive portbroker for it. Runs before the IPC listener so the socket
|
|
603
643
|
// is never accepting RPCs against a half-restored registry. A bad file
|
|
604
644
|
// (parse error, schema mismatch) is logged-and-skipped — one corrupt
|
|
605
645
|
// registration must not gate every other container's recovery.
|
|
606
646
|
await restorePersistedRegistrations(applyRegistration, log, probeContainerAlive, removeRegistrationFile)
|
|
607
647
|
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
close: () => {},
|
|
616
|
-
error: () => {},
|
|
617
|
-
},
|
|
648
|
+
const sockets = new Set<NetSocket>()
|
|
649
|
+
const listener = createServer((socket) => {
|
|
650
|
+
const state = { buf: '' }
|
|
651
|
+
sockets.add(socket)
|
|
652
|
+
socket.on('data', (chunk: Buffer) => handleData(socket, chunk, state))
|
|
653
|
+
socket.on('close', () => sockets.delete(socket))
|
|
654
|
+
socket.on('error', () => {})
|
|
618
655
|
})
|
|
619
|
-
|
|
620
|
-
|
|
656
|
+
try {
|
|
657
|
+
await listenOnSocket(listener, path, onWindows)
|
|
658
|
+
} catch (error) {
|
|
659
|
+
httpServer.stop(true)
|
|
660
|
+
throw error
|
|
661
|
+
}
|
|
662
|
+
// Restrict POSIX sockets to the owning user; ~/.typeclaw/run is also 0700.
|
|
663
|
+
if (!onWindows) await chmod(path, 0o600).catch(() => {})
|
|
621
664
|
log({ kind: 'daemon-listening', socket: path })
|
|
622
665
|
|
|
623
666
|
const runGc = async (): Promise<void> => {
|
|
@@ -658,9 +701,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
658
701
|
stopped = true
|
|
659
702
|
log({ kind: 'daemon-stopping' })
|
|
660
703
|
clearInterval(gcTimer)
|
|
661
|
-
|
|
662
|
-
listener.stop(true)
|
|
663
|
-
} catch {}
|
|
704
|
+
await closeSocketServer(listener, sockets)
|
|
664
705
|
httpServer.stop(true)
|
|
665
706
|
if (opts.portbroker) {
|
|
666
707
|
const names = Array.from(cwds.keys())
|
|
@@ -672,9 +713,11 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
672
713
|
}
|
|
673
714
|
cwds.clear()
|
|
674
715
|
restartTokens.clear()
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
716
|
+
if (!onWindows) {
|
|
717
|
+
try {
|
|
718
|
+
if (existsSync(path)) await unlink(path)
|
|
719
|
+
} catch {}
|
|
720
|
+
}
|
|
678
721
|
},
|
|
679
722
|
}
|
|
680
723
|
return daemonHandle
|
package/src/hostd/paths.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
1
2
|
import { chmod, mkdir } from 'node:fs/promises'
|
|
2
|
-
import { homedir } from 'node:os'
|
|
3
|
-
import { join } from 'node:path'
|
|
3
|
+
import { homedir, userInfo } from 'node:os'
|
|
4
|
+
import { join, resolve } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { isWindows } from '@/shared'
|
|
4
7
|
|
|
5
8
|
// Fixed in-container path where the host daemon's run dir is bind-mounted.
|
|
6
9
|
// The agent uses this to reach the host daemon (e.g. for the `restart` tool).
|
|
@@ -33,9 +36,26 @@ export function logDir(): string {
|
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
export function socketPath(): string {
|
|
39
|
+
if (isWindows()) return windowsPipePath()
|
|
36
40
|
return join(runDir(), SOCKET_FILE)
|
|
37
41
|
}
|
|
38
42
|
|
|
43
|
+
function windowsPipePath(): string {
|
|
44
|
+
const uid =
|
|
45
|
+
typeof process.getuid === 'function'
|
|
46
|
+
? `uid:${process.getuid()}`
|
|
47
|
+
: `user:${process.env.USERDOMAIN ?? ''}\\${userInfo().username}`
|
|
48
|
+
// Locale-invariant lowercasing: toLocaleLowerCase under e.g. tr-TR would map
|
|
49
|
+
// 'I' to a dotless 'ı', hashing the same path differently per process locale.
|
|
50
|
+
const scopedHome = resolve(homeRoot()).toLowerCase()
|
|
51
|
+
const hash = createHash('sha256').update(`${uid}\0${scopedHome}`).digest('hex').slice(0, 32)
|
|
52
|
+
|
|
53
|
+
// Node's net named-pipe API has no portable ACL hook. TypeClaw accepts that
|
|
54
|
+
// under the single-tenant dev-box model; the per-user/per-home pipe name keeps
|
|
55
|
+
// the pipe scoped, while the separate HTTP leg remains restart/secrets-only.
|
|
56
|
+
return `\\\\.\\pipe\\typeclaw-hostd-${hash}`
|
|
57
|
+
}
|
|
58
|
+
|
|
39
59
|
export function pidfilePath(): string {
|
|
40
60
|
return join(runDir(), 'hostd.pid')
|
|
41
61
|
}
|
package/src/hostd/spawn.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { open, readFile, unlink, writeFile } from 'node:fs/promises'
|
|
3
3
|
|
|
4
|
+
import { isWindows } from '@/shared'
|
|
5
|
+
|
|
4
6
|
import { isDaemonReachable, send } from './client'
|
|
5
7
|
import { ensureDirs, lockfilePath, logfilePath, pidfilePath, socketPath } from './paths'
|
|
6
8
|
import type { HttpInfoResult, VersionResult } from './protocol'
|
|
@@ -75,6 +77,11 @@ async function requestShutdownAndWait(): Promise<boolean> {
|
|
|
75
77
|
if (!reply.ok) return false
|
|
76
78
|
const deadline = Date.now() + SHUTDOWN_TIMEOUT_MS
|
|
77
79
|
while (Date.now() < deadline) {
|
|
80
|
+
if (isWindows()) {
|
|
81
|
+
if (!(await isDaemonReachable(POLL_INTERVAL_MS))) return true
|
|
82
|
+
await sleep(POLL_INTERVAL_MS)
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
78
85
|
if (!existsSync(socketPath())) return true
|
|
79
86
|
await sleep(POLL_INTERVAL_MS)
|
|
80
87
|
}
|
package/src/hostd/tailscale.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getBun } from '@/container/shared'
|
|
2
|
+
import { isMacOS, isWindows } from '@/shared'
|
|
2
3
|
|
|
3
4
|
export type TailscaleExecResult = { exitCode: number; stdout: string; stderr: string }
|
|
4
5
|
export type TailscaleExec = (args: string[]) => Promise<TailscaleExecResult>
|
|
@@ -33,6 +34,7 @@ type TailscaleStatus = {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
const MACOS_APP_CLI = '/Applications/Tailscale.app/Contents/MacOS/Tailscale'
|
|
37
|
+
const WINDOWS_CLI = 'C:\\Program Files\\Tailscale\\tailscale.exe'
|
|
36
38
|
|
|
37
39
|
export function createTailscaleServeManager(opts: TailscaleServeManagerOptions): TailscaleServeManager {
|
|
38
40
|
const exec = opts.exec ?? defaultTailscaleExec
|
|
@@ -129,8 +131,17 @@ async function checkRunning(exec: TailscaleExec): Promise<{ ok: true } | { ok: f
|
|
|
129
131
|
return { ok: true }
|
|
130
132
|
}
|
|
131
133
|
|
|
134
|
+
// `tailscale` on PATH is tried first everywhere; the platform-specific absolute
|
|
135
|
+
// path is a fallback for GUI installs that leave the CLI off PATH (the macOS app
|
|
136
|
+
// bundle, the Windows default install dir).
|
|
137
|
+
export function tailscaleCandidates(platform: NodeJS.Platform = process.platform): string[] {
|
|
138
|
+
if (isMacOS(platform)) return ['tailscale', MACOS_APP_CLI]
|
|
139
|
+
if (isWindows(platform)) return ['tailscale', WINDOWS_CLI]
|
|
140
|
+
return ['tailscale']
|
|
141
|
+
}
|
|
142
|
+
|
|
132
143
|
export const defaultTailscaleExec: TailscaleExec = async (args) => {
|
|
133
|
-
const candidates =
|
|
144
|
+
const candidates = tailscaleCandidates()
|
|
134
145
|
let lastError = 'tailscale command not found'
|
|
135
146
|
|
|
136
147
|
for (const candidate of candidates) {
|