typeclaw 0.1.3 → 0.1.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 +1 -1
- package/package.json +1 -1
- package/src/bundled-plugins/memory/README.md +8 -8
- package/src/bundled-plugins/memory/dreaming.ts +117 -1
- package/src/cli/init.ts +35 -6
- package/src/cli/reload.ts +6 -3
- package/src/cli/tui.ts +6 -3
- package/src/config/config.ts +162 -17
- package/src/config/index.ts +8 -1
- package/src/container/index.ts +3 -1
- package/src/container/port.ts +10 -0
- package/src/container/start.ts +54 -10
- package/src/doctor/checks.ts +8 -28
- package/src/doctor/commit.ts +44 -3
- package/src/doctor/plugin-bridge.ts +46 -3
- package/src/init/dockerfile.ts +62 -4
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +31 -24
- package/src/reload/client.ts +25 -1
- package/src/run/index.ts +13 -1
- package/src/secrets/storage.ts +15 -0
- package/src/server/index.ts +80 -64
- package/src/skills/typeclaw-config/SKILL.md +70 -52
- package/src/skills/typeclaw-memory/SKILL.md +8 -8
- package/src/tui/client.ts +66 -5
- package/src/tui/index.ts +61 -9
- package/typeclaw.schema.json +91 -54
package/src/container/start.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs'
|
|
|
3
3
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
4
4
|
import { isAbsolute, join, resolve } from 'node:path'
|
|
5
5
|
|
|
6
|
-
import { configSchema, expandMountPath, type Config } from '@/config
|
|
6
|
+
import { configSchema, expandMountPath, migrateLegacyConfigShape, type Config } from '@/config'
|
|
7
7
|
import { send as sendToDaemon } from '@/hostd/client'
|
|
8
8
|
import type { HttpInfoResult } from '@/hostd/protocol'
|
|
9
9
|
import { ensureDaemon } from '@/hostd/spawn'
|
|
@@ -22,7 +22,7 @@ import { refreshPackageJson } from '@/init/packagejson'
|
|
|
22
22
|
import { runBunUpdate, type UpdateRunner } from '@/init/run-bun-install'
|
|
23
23
|
import { migrateKakaotalkCredentials } from '@/secrets'
|
|
24
24
|
|
|
25
|
-
import { CONTAINER_PORT, findFreePort, isPortAllocatedError } from './port'
|
|
25
|
+
import { CONTAINER_PORT, TUI_TOKEN_LABEL, findFreePort, isPortAllocatedError, resolveTuiToken } from './port'
|
|
26
26
|
import {
|
|
27
27
|
classifyRmStderr,
|
|
28
28
|
cleanupRunCorpse,
|
|
@@ -57,6 +57,7 @@ export type StartPlan = {
|
|
|
57
57
|
runArgs: string[]
|
|
58
58
|
needsBuild: boolean
|
|
59
59
|
hostPort: number
|
|
60
|
+
tuiToken: string | null
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
export type PlanStartOptions = {
|
|
@@ -65,6 +66,8 @@ export type PlanStartOptions = {
|
|
|
65
66
|
imageExists: boolean
|
|
66
67
|
forceBuild?: boolean
|
|
67
68
|
hostdControl?: HostDaemonControl
|
|
69
|
+
publishHost?: string
|
|
70
|
+
tuiToken?: string | null
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
export type HostDaemonControl = {
|
|
@@ -127,6 +130,7 @@ export type StartResult =
|
|
|
127
130
|
containerId: string
|
|
128
131
|
built: boolean
|
|
129
132
|
hostPort: number
|
|
133
|
+
tuiToken: string | null
|
|
130
134
|
hostd: HostDaemonStatus
|
|
131
135
|
// True when the container was already running and start() became a no-op.
|
|
132
136
|
// Callers that want to distinguish "I just launched it" from "it was up
|
|
@@ -298,7 +302,17 @@ export async function start({
|
|
|
298
302
|
: { state: 'disabled' as const }
|
|
299
303
|
let hostdControl = hostd.state === 'registered' ? hostd.control : undefined
|
|
300
304
|
|
|
301
|
-
|
|
305
|
+
const publishHost = await resolvePublishHost(exec)
|
|
306
|
+
const tuiToken = randomBytes(32).toString('base64url')
|
|
307
|
+
let plan = await planStart({
|
|
308
|
+
cwd,
|
|
309
|
+
hostPort,
|
|
310
|
+
imageExists: imageExisted,
|
|
311
|
+
forceBuild,
|
|
312
|
+
hostdControl,
|
|
313
|
+
publishHost,
|
|
314
|
+
tuiToken,
|
|
315
|
+
})
|
|
302
316
|
|
|
303
317
|
let built = false
|
|
304
318
|
if (plan.needsBuild) {
|
|
@@ -347,7 +361,15 @@ export async function start({
|
|
|
347
361
|
hostd = await registerWithDaemon({ cwd, containerName, cliEntry, hostPort, reuseCurrentHostDaemon })
|
|
348
362
|
hostdControl = hostd.state === 'registered' ? hostd.control : undefined
|
|
349
363
|
}
|
|
350
|
-
plan = await planStart({
|
|
364
|
+
plan = await planStart({
|
|
365
|
+
cwd,
|
|
366
|
+
hostPort,
|
|
367
|
+
imageExists: true,
|
|
368
|
+
forceBuild: false,
|
|
369
|
+
hostdControl,
|
|
370
|
+
publishHost,
|
|
371
|
+
tuiToken,
|
|
372
|
+
})
|
|
351
373
|
run = await execRunWithConflictRetry(exec, plan.runArgs, cwd, containerName)
|
|
352
374
|
}
|
|
353
375
|
|
|
@@ -370,6 +392,7 @@ export async function start({
|
|
|
370
392
|
containerId,
|
|
371
393
|
built,
|
|
372
394
|
hostPort,
|
|
395
|
+
tuiToken,
|
|
373
396
|
hostd: stripHostDaemonControl(hostd),
|
|
374
397
|
alreadyRunning: false,
|
|
375
398
|
autoUpgrade: upgrade,
|
|
@@ -403,6 +426,8 @@ export async function planStart({
|
|
|
403
426
|
imageExists,
|
|
404
427
|
forceBuild = false,
|
|
405
428
|
hostdControl,
|
|
429
|
+
publishHost = '127.0.0.1',
|
|
430
|
+
tuiToken = null,
|
|
406
431
|
}: PlanStartOptions): Promise<StartPlan> {
|
|
407
432
|
const containerName = containerNameFromCwd(cwd)
|
|
408
433
|
const imageTag = imageTagFromCwd(cwd)
|
|
@@ -416,7 +441,7 @@ export async function planStart({
|
|
|
416
441
|
// the start() preflight force-removes any lingering corpse before the next
|
|
417
442
|
// launch — so the only state Docker ever sees in `docker ps -a` is either
|
|
418
443
|
// a running container or one the user has not started again yet.
|
|
419
|
-
const runArgs = ['run', '-d', '--name', containerName, '-p',
|
|
444
|
+
const runArgs = ['run', '-d', '--name', containerName, '-p', `${publishHost}:${hostPort}:${CONTAINER_PORT}`]
|
|
420
445
|
|
|
421
446
|
// Network egress filter: when `typeclaw.json#network.blockInternal` is true,
|
|
422
447
|
// grant the container CAP_NET_ADMIN at boot so the entrypoint shim can
|
|
@@ -424,9 +449,16 @@ export async function planStart({
|
|
|
424
449
|
// bounding set via setpriv before exec'ing the agent — see the shim source
|
|
425
450
|
// in src/init/dockerfile.ts for the full handoff. The `-e` flag is what
|
|
426
451
|
// tells the shim to take the on-path; absent or set to anything other than
|
|
427
|
-
// "1", the shim is a no-op.
|
|
452
|
+
// "1", the shim is a no-op. `autoAllowResolvers` / `allow` envs are only
|
|
453
|
+
// emitted on the on-path because the shim's off-path doesn't read them;
|
|
454
|
+
// `TYPECLAW_NETWORK_ALLOW` is comma-joined to match the shim's `IFS=','`
|
|
455
|
+
// loop, and CIDR validation already happened at config parse time.
|
|
428
456
|
if (cfg.network.blockInternal) {
|
|
429
457
|
runArgs.push('--cap-add=NET_ADMIN', '-e', 'TYPECLAW_NETWORK_BLOCK_INTERNAL=1')
|
|
458
|
+
runArgs.push('-e', `TYPECLAW_NETWORK_AUTO_ALLOW_RESOLVERS=${cfg.network.autoAllowResolvers ? '1' : '0'}`)
|
|
459
|
+
if (cfg.network.allow.length > 0) {
|
|
460
|
+
runArgs.push('-e', `TYPECLAW_NETWORK_ALLOW=${cfg.network.allow.join(',')}`)
|
|
461
|
+
}
|
|
430
462
|
}
|
|
431
463
|
|
|
432
464
|
if (hostdControl) {
|
|
@@ -436,6 +468,9 @@ export async function planStart({
|
|
|
436
468
|
for (const [key, value] of Object.entries(composeLabels(cwd, containerName))) {
|
|
437
469
|
runArgs.push('--label', `${key}=${value}`)
|
|
438
470
|
}
|
|
471
|
+
if (tuiToken !== null) {
|
|
472
|
+
runArgs.push('--label', `${TUI_TOKEN_LABEL}=${tuiToken}`, '-e', `TYPECLAW_TUI_TOKEN=${tuiToken}`)
|
|
473
|
+
}
|
|
439
474
|
|
|
440
475
|
if (existsSync(join(cwd, ENV_FILE))) {
|
|
441
476
|
runArgs.push('--env-file', join(cwd, ENV_FILE))
|
|
@@ -486,20 +521,27 @@ export async function planStart({
|
|
|
486
521
|
runArgs,
|
|
487
522
|
needsBuild: forceBuild || !imageExists,
|
|
488
523
|
hostPort,
|
|
524
|
+
tuiToken,
|
|
489
525
|
}
|
|
490
526
|
}
|
|
491
527
|
|
|
528
|
+
async function resolvePublishHost(exec: DockerExec): Promise<string> {
|
|
529
|
+
const result = await exec(['version', '--format', '{{.Server.Platform.Name}}'])
|
|
530
|
+
if (result.exitCode === 0 && result.stdout.toLowerCase().includes('docker desktop')) return '0.0.0.0'
|
|
531
|
+
return '127.0.0.1'
|
|
532
|
+
}
|
|
533
|
+
|
|
492
534
|
export async function refreshDockerfile(cwd: string): Promise<void> {
|
|
493
535
|
const cfg = await loadTypeclawConfig(cwd)
|
|
494
536
|
await writeFile(
|
|
495
537
|
join(cwd, DOCKERFILE),
|
|
496
|
-
buildDockerfile(cfg.
|
|
538
|
+
buildDockerfile(cfg.docker.file, { baseImageVersion: resolveBaseImageVersion(cwd) }),
|
|
497
539
|
)
|
|
498
540
|
}
|
|
499
541
|
|
|
500
542
|
export async function refreshGitignore(cwd: string): Promise<void> {
|
|
501
543
|
const cfg = await loadTypeclawConfig(cwd)
|
|
502
|
-
await writeFile(join(cwd, GITIGNORE_FILE), buildGitignore(cfg.
|
|
544
|
+
await writeFile(join(cwd, GITIGNORE_FILE), buildGitignore(cfg.git.ignore))
|
|
503
545
|
}
|
|
504
546
|
|
|
505
547
|
// Commits TypeClaw-owned system file(s) if any are dirty in git. Skips silently
|
|
@@ -616,13 +658,15 @@ async function reportAlreadyRunning(exec: DockerExec, cwd: string, containerName
|
|
|
616
658
|
reason: `Container ${containerName} is running but its published host port could not be resolved.`,
|
|
617
659
|
}
|
|
618
660
|
}
|
|
619
|
-
const
|
|
661
|
+
const tuiToken = await resolveTuiToken({ cwd, exec })
|
|
662
|
+
const plan = await planStart({ cwd, hostPort, imageExists: true, forceBuild: false, tuiToken })
|
|
620
663
|
return {
|
|
621
664
|
ok: true,
|
|
622
665
|
plan,
|
|
623
666
|
containerId,
|
|
624
667
|
built: false,
|
|
625
668
|
hostPort,
|
|
669
|
+
tuiToken,
|
|
626
670
|
hostd: { state: 'disabled' },
|
|
627
671
|
alreadyRunning: true,
|
|
628
672
|
autoUpgrade: { kind: 'skipped-already-running' },
|
|
@@ -740,7 +784,7 @@ async function loadConfigJson(cwd: string): Promise<unknown> {
|
|
|
740
784
|
} catch {
|
|
741
785
|
return {}
|
|
742
786
|
}
|
|
743
|
-
return JSON.parse(raw)
|
|
787
|
+
return migrateLegacyConfigShape(JSON.parse(raw)).json
|
|
744
788
|
}
|
|
745
789
|
|
|
746
790
|
type PreparedHostDaemonStatus =
|
package/src/doctor/checks.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from 'node:fs'
|
|
2
|
-
import { readFile
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import { join, relative } from 'node:path'
|
|
5
5
|
|
|
@@ -10,10 +10,13 @@ import {
|
|
|
10
10
|
defaultDockerExec,
|
|
11
11
|
imageTagFromCwd,
|
|
12
12
|
inspectContainer,
|
|
13
|
+
refreshDockerfile,
|
|
14
|
+
refreshGitignore,
|
|
13
15
|
resolveHostPort,
|
|
14
16
|
type DockerExec,
|
|
15
17
|
} from '@/container'
|
|
16
18
|
import { isDaemonReachable, send } from '@/hostd'
|
|
19
|
+
import { resolveBaseImageVersion } from '@/init/cli-version'
|
|
17
20
|
import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
|
|
18
21
|
import { detectMissingDeps } from '@/init/ensure-deps'
|
|
19
22
|
import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
|
|
@@ -33,7 +36,6 @@ export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): Docto
|
|
|
33
36
|
agentFolderDockerfileTemplate(),
|
|
34
37
|
agentFolderGitignoreTemplate(),
|
|
35
38
|
agentFolderNodeModules(),
|
|
36
|
-
agentFolderEnvFile(),
|
|
37
39
|
agentFolderGitRepo(),
|
|
38
40
|
configValid(),
|
|
39
41
|
hostdHomeWritable(),
|
|
@@ -152,7 +154,7 @@ function agentFolderDockerfileTemplate(): DoctorCheck {
|
|
|
152
154
|
fix: {
|
|
153
155
|
description: 'Regenerate the Dockerfile from the typeclaw template.',
|
|
154
156
|
autoFix: async () => {
|
|
155
|
-
await
|
|
157
|
+
await refreshDockerfile(ctx.cwd)
|
|
156
158
|
return { summary: 'refreshed Dockerfile from template', changedPaths: [DOCKERFILE] }
|
|
157
159
|
},
|
|
158
160
|
},
|
|
@@ -181,7 +183,7 @@ function agentFolderGitignoreTemplate(): DoctorCheck {
|
|
|
181
183
|
fix: {
|
|
182
184
|
description: 'Regenerate .gitignore from the typeclaw template.',
|
|
183
185
|
autoFix: async () => {
|
|
184
|
-
await
|
|
186
|
+
await refreshGitignore(ctx.cwd)
|
|
185
187
|
return { summary: 'refreshed .gitignore from template', changedPaths: [GITIGNORE_FILE] }
|
|
186
188
|
},
|
|
187
189
|
},
|
|
@@ -209,24 +211,6 @@ function agentFolderNodeModules(): DoctorCheck {
|
|
|
209
211
|
}
|
|
210
212
|
}
|
|
211
213
|
|
|
212
|
-
function agentFolderEnvFile(): DoctorCheck {
|
|
213
|
-
return {
|
|
214
|
-
name: 'agent-folder.env-file',
|
|
215
|
-
category: 'agent-folder',
|
|
216
|
-
description: '.env file is present',
|
|
217
|
-
applies: (ctx) => ctx.hasAgentFolder,
|
|
218
|
-
async run(ctx) {
|
|
219
|
-
if (existsSync(join(ctx.cwd, '.env'))) return { status: 'ok', message: '.env present' }
|
|
220
|
-
return {
|
|
221
|
-
status: 'warning',
|
|
222
|
-
message: '.env is missing',
|
|
223
|
-
details: ['Channels and external API integrations will not have their secrets injected.'],
|
|
224
|
-
fix: { description: 'Create a .env file with the credentials your agent needs.' },
|
|
225
|
-
}
|
|
226
|
-
},
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
214
|
function agentFolderGitRepo(): DoctorCheck {
|
|
231
215
|
return {
|
|
232
216
|
name: 'agent-folder.git-repo',
|
|
@@ -384,7 +368,7 @@ function buildExpectedDockerfile(cwd: string): string | null {
|
|
|
384
368
|
try {
|
|
385
369
|
const cfg = loadConfigStrictForTemplate(cwd)
|
|
386
370
|
if (cfg === null) return null
|
|
387
|
-
return buildDockerfile(cfg.dockerfile)
|
|
371
|
+
return buildDockerfile(cfg.dockerfile, { baseImageVersion: resolveBaseImageVersion(cwd) })
|
|
388
372
|
} catch {
|
|
389
373
|
return null
|
|
390
374
|
}
|
|
@@ -406,7 +390,7 @@ function loadConfigStrictForTemplate(
|
|
|
406
390
|
const result = validateConfig(cwd)
|
|
407
391
|
if (!result.ok) return null
|
|
408
392
|
const cfg = loadConfigSync(cwd)
|
|
409
|
-
return { dockerfile: cfg.
|
|
393
|
+
return { dockerfile: cfg.docker.file, gitignore: cfg.git.ignore }
|
|
410
394
|
}
|
|
411
395
|
|
|
412
396
|
async function safeRead(path: string): Promise<string | null> {
|
|
@@ -417,10 +401,6 @@ async function safeRead(path: string): Promise<string | null> {
|
|
|
417
401
|
}
|
|
418
402
|
}
|
|
419
403
|
|
|
420
|
-
async function writeAtomic(path: string, content: string): Promise<void> {
|
|
421
|
-
await writeFile(path, content)
|
|
422
|
-
}
|
|
423
|
-
|
|
424
404
|
export function relativeToCwd(cwd: string, path: string): string {
|
|
425
405
|
return relative(cwd, path) || '.'
|
|
426
406
|
}
|
package/src/doctor/commit.ts
CHANGED
|
@@ -18,8 +18,8 @@ export async function commitAutoFixes(opts: CommitOptions): Promise<CommitOutcom
|
|
|
18
18
|
return { kind: 'skipped', reason: 'no successful auto-fixes' }
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
const
|
|
22
|
-
if (
|
|
21
|
+
const requested = uniqueSorted(successes.flatMap((a) => a.changedPaths))
|
|
22
|
+
if (requested.length === 0) {
|
|
23
23
|
return { kind: 'skipped', reason: 'auto-fixes reported no changed paths' }
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -29,13 +29,23 @@ export async function commitAutoFixes(opts: CommitOptions): Promise<CommitOutcom
|
|
|
29
29
|
|
|
30
30
|
const spawnGit = opts.spawnGit ?? defaultSpawnGit
|
|
31
31
|
|
|
32
|
+
const filter = await filterCommittable(spawnGit, opts.cwd, requested)
|
|
33
|
+
if (filter.kind === 'failed') return filter
|
|
34
|
+
const pathsStaged = filter.paths
|
|
35
|
+
if (pathsStaged.length === 0) {
|
|
36
|
+
return {
|
|
37
|
+
kind: 'skipped',
|
|
38
|
+
reason: `all changed path(s) are gitignored or untracked-and-ignored (${requested.join(', ')})`,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
const add = await spawnGit(['add', '--', ...pathsStaged], opts.cwd)
|
|
33
43
|
if (add.exitCode !== 0) {
|
|
34
44
|
return { kind: 'failed', reason: `git add failed: ${add.stderr.trim() || `exit ${add.exitCode}`}` }
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
const message = buildCommitMessage(opts.attempts)
|
|
38
|
-
const commit = await spawnGit(['commit', '-m', message], opts.cwd)
|
|
48
|
+
const commit = await spawnGit(['commit', '-m', message, '--only', '--', ...pathsStaged], opts.cwd)
|
|
39
49
|
if (commit.exitCode !== 0) {
|
|
40
50
|
return { kind: 'failed', reason: `git commit failed: ${commit.stderr.trim() || `exit ${commit.exitCode}`}` }
|
|
41
51
|
}
|
|
@@ -45,6 +55,37 @@ export async function commitAutoFixes(opts: CommitOptions): Promise<CommitOutcom
|
|
|
45
55
|
return { kind: 'committed', commitSha, pathsStaged }
|
|
46
56
|
}
|
|
47
57
|
|
|
58
|
+
// TypeClaw-owned files like `Dockerfile` live in the "truly-ignored" gitignore
|
|
59
|
+
// category — they're regenerated from the CLI template on every `typeclaw
|
|
60
|
+
// start`, so tracking them would produce noisy "Update Dockerfile" commits.
|
|
61
|
+
// `commitSystemFile` in src/container/start.ts skips them silently because
|
|
62
|
+
// `git status --porcelain -- <ignored>` returns empty. We replicate that
|
|
63
|
+
// behavior here so `doctor --fix` produces the same skip semantics instead
|
|
64
|
+
// of failing with `git add` hints about the ignored file.
|
|
65
|
+
//
|
|
66
|
+
// A non-zero git-status exit IS NOT the same signal as 'empty stdout' — the
|
|
67
|
+
// former means git itself failed (broken index, malformed pathspec, etc.).
|
|
68
|
+
// Surface that as { kind: 'failed' } so the user sees the real cause instead
|
|
69
|
+
// of a misleading 'all paths are gitignored' message.
|
|
70
|
+
async function filterCommittable(
|
|
71
|
+
spawnGit: SpawnGit,
|
|
72
|
+
cwd: string,
|
|
73
|
+
paths: string[],
|
|
74
|
+
): Promise<{ kind: 'ok'; paths: string[] } | { kind: 'failed'; reason: string }> {
|
|
75
|
+
const out: string[] = []
|
|
76
|
+
for (const p of paths) {
|
|
77
|
+
const status = await spawnGit(['status', '--porcelain', '--', p], cwd)
|
|
78
|
+
if (status.exitCode !== 0) {
|
|
79
|
+
return {
|
|
80
|
+
kind: 'failed',
|
|
81
|
+
reason: `git status failed for ${p}: ${status.stderr.trim() || `exit ${status.exitCode}`}`,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (status.stdout.trim().length > 0) out.push(p)
|
|
85
|
+
}
|
|
86
|
+
return { kind: 'ok', paths: out }
|
|
87
|
+
}
|
|
88
|
+
|
|
48
89
|
export function buildCommitMessage(attempts: FixAttempt[]): string {
|
|
49
90
|
const successes = attempts.filter((a): a is Extract<FixAttempt, { ok: true }> => a.ok === true)
|
|
50
91
|
const subject = `typeclaw doctor: auto-fix ${successes.length} issue${successes.length === 1 ? '' : 's'}`
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolveHostPort } from '@/container'
|
|
1
|
+
import { resolveHostPort, resolveTuiToken } from '@/container'
|
|
2
2
|
import type { ClientMessage, DoctorCheckPayload, DoctorFixPayload, ServerMessage } from '@/shared'
|
|
3
3
|
|
|
4
4
|
export type PluginBridgeOptions = {
|
|
@@ -80,17 +80,26 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
|
|
|
80
80
|
if (url === undefined) {
|
|
81
81
|
try {
|
|
82
82
|
const port = await resolveHostPort({ cwd: opts.cwd })
|
|
83
|
-
|
|
83
|
+
const token = await resolveTuiToken({ cwd: opts.cwd })
|
|
84
|
+
url = buildBridgeUrl(port, token)
|
|
84
85
|
} catch (err) {
|
|
85
86
|
return { kind: 'unreachable', reason: err instanceof Error ? err.message : String(err) }
|
|
86
87
|
}
|
|
87
88
|
}
|
|
88
89
|
const ws = new WebSocket(url)
|
|
90
|
+
const displayUrl = redactUrl(url)
|
|
89
91
|
try {
|
|
90
92
|
await new Promise<void>((resolve, reject) => {
|
|
93
|
+
// `timer` is declared up front so `cleanup` can reference it without
|
|
94
|
+
// hitting the TDZ if the WS fires a synchronous error event during
|
|
95
|
+
// addEventListener (theoretical, but const-after-cleanup-definition
|
|
96
|
+
// would throw ReferenceError instead of being the intended no-op).
|
|
97
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
91
98
|
const cleanup = () => {
|
|
99
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
92
100
|
ws.removeEventListener('open', onOpen)
|
|
93
101
|
ws.removeEventListener('error', onError)
|
|
102
|
+
ws.removeEventListener('close', onClose)
|
|
94
103
|
}
|
|
95
104
|
const onOpen = () => {
|
|
96
105
|
cleanup()
|
|
@@ -98,10 +107,28 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
|
|
|
98
107
|
}
|
|
99
108
|
const onError = (err: unknown) => {
|
|
100
109
|
cleanup()
|
|
101
|
-
reject(
|
|
110
|
+
reject(new Error(`failed to connect to ${displayUrl}: ${err instanceof Error ? err.message : String(err)}`))
|
|
102
111
|
}
|
|
112
|
+
const onClose = () => {
|
|
113
|
+
cleanup()
|
|
114
|
+
reject(new Error(`connection to ${displayUrl} closed before opening`))
|
|
115
|
+
}
|
|
116
|
+
// Bun's WebSocket has no built-in connect timeout. Without this, a TCP
|
|
117
|
+
// handshake that completes but never produces an Upgrade response
|
|
118
|
+
// (e.g. a wedged docker/orbstack port-forward) leaves the WS stuck in
|
|
119
|
+
// CONNECTING forever — neither 'open' nor 'error' ever fires, and
|
|
120
|
+
// `typeclaw doctor` hangs. The per-request timeout downstream doesn't
|
|
121
|
+
// help because we never reach it.
|
|
122
|
+
timer = setTimeout(() => {
|
|
123
|
+
cleanup()
|
|
124
|
+
try {
|
|
125
|
+
ws.close()
|
|
126
|
+
} catch {}
|
|
127
|
+
reject(new Error(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
|
|
128
|
+
}, timeoutMs)
|
|
103
129
|
ws.addEventListener('open', onOpen, { once: true })
|
|
104
130
|
ws.addEventListener('error', onError, { once: true })
|
|
131
|
+
ws.addEventListener('close', onClose, { once: true })
|
|
105
132
|
})
|
|
106
133
|
} catch (err) {
|
|
107
134
|
return { kind: 'unreachable', reason: err instanceof Error ? err.message : String(err) }
|
|
@@ -109,6 +136,22 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
|
|
|
109
136
|
return { kind: 'ok', ws, timeoutMs }
|
|
110
137
|
}
|
|
111
138
|
|
|
139
|
+
function buildBridgeUrl(port: number, token: string | null): string {
|
|
140
|
+
const url = new URL(`ws://127.0.0.1:${port}`)
|
|
141
|
+
if (token !== null) url.searchParams.set('token', token)
|
|
142
|
+
return url.toString()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function redactUrl(url: string): string {
|
|
146
|
+
try {
|
|
147
|
+
const parsed = new URL(url)
|
|
148
|
+
if (parsed.searchParams.has('token')) parsed.searchParams.set('token', '<redacted>')
|
|
149
|
+
return parsed.toString()
|
|
150
|
+
} catch {
|
|
151
|
+
return url
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
112
155
|
async function withRequest<R extends { kind: string }>(
|
|
113
156
|
ws: WebSocket,
|
|
114
157
|
timeoutMs: number,
|
package/src/init/dockerfile.ts
CHANGED
|
@@ -132,6 +132,44 @@ export const NETWORK_BLOCK_IPV6_NETS = ['fc00::/7', 'fe80::/10', 'ff00::/8', '::
|
|
|
132
132
|
// `set -eu` propagates rule-install failures up to PID 1 exit, which kills
|
|
133
133
|
// the container. Failing closed is the right thing: an unenforced
|
|
134
134
|
// blockInternal=true is worse than blockInternal=false.
|
|
135
|
+
//
|
|
136
|
+
// Carve-out ordering is load-bearing. iptables OUTPUT is first-match-wins,
|
|
137
|
+
// and we use -A (append). So the order written into the shim is the order
|
|
138
|
+
// rules will be evaluated:
|
|
139
|
+
// 1. loopback ACCEPT
|
|
140
|
+
// 2. hostd port ACCEPT (narrow: tcp + single dport on the host gateway)
|
|
141
|
+
// 3. resolver ACCEPT (narrow: udp/tcp dport 53 to each /etc/resolv.conf
|
|
142
|
+
// nameserver) — gated on TYPECLAW_NETWORK_AUTO_ALLOW_RESOLVERS=1
|
|
143
|
+
// 4. user-supplied allowlist ACCEPT (wholesale: -d <cidr>) — driven by
|
|
144
|
+
// TYPECLAW_NETWORK_ALLOW comma-separated env
|
|
145
|
+
// 5. RFC1918 + link-local + CGNAT + multicast + reserved REJECTs
|
|
146
|
+
// A resolver at 10.0.0.2 hits (3) and ACCEPTs before (5) DROPs it.
|
|
147
|
+
//
|
|
148
|
+
// The resolver carve-out reads /etc/resolv.conf inside the container, NOT
|
|
149
|
+
// on the host. Docker propagates the host's resolver into the container by
|
|
150
|
+
// default (Docker Desktop and OrbStack rewrite it to the embedded DNS
|
|
151
|
+
// proxy at 127.0.0.11; Docker on Linux copies the host's resolv.conf
|
|
152
|
+
// verbatim unless --dns is passed). On Docker Desktop / OrbStack the
|
|
153
|
+
// nameserver is loopback and the rule is a no-op (loopback is already
|
|
154
|
+
// ACCEPT'd by rule 1). On native Linux + EC2/GCE/Azure VMs, the
|
|
155
|
+
// nameserver is the VPC resolver inside RFC1918 — exactly the case this
|
|
156
|
+
// carve-out targets.
|
|
157
|
+
//
|
|
158
|
+
// `awk '/^nameserver/{print $2}'` extracts only the IP, skipping
|
|
159
|
+
// comments, `search`, `options`, and malformed lines. We don't validate
|
|
160
|
+
// the IP further: a malformed nameserver line would have crashed glibc's
|
|
161
|
+
// resolver long before the shim ran, so we trust resolv.conf's contents.
|
|
162
|
+
// IPv6 nameservers (rare in practice, never the case on EC2/GCE/Azure)
|
|
163
|
+
// are skipped by `grep -v ':'` to avoid feeding a v6 address to iptables.
|
|
164
|
+
// `iptables -C` (check) is intentionally NOT used to dedupe — duplicate
|
|
165
|
+
// ACCEPT rules are harmless (still first-match-wins), and the check
|
|
166
|
+
// flag's exit code is hard to reason about under `set -e`.
|
|
167
|
+
//
|
|
168
|
+
// The user-supplied allowlist (TYPECLAW_NETWORK_ALLOW) is splat on
|
|
169
|
+
// commas. Each entry is fed directly to iptables -d, which accepts both
|
|
170
|
+
// bare IPs and CIDR notation. Validation already happened in config.ts at
|
|
171
|
+
// parse time, so the shim trusts the env. Empty env → loop body never
|
|
172
|
+
// runs, zero ACCEPT rules added.
|
|
135
173
|
export function buildEntrypointShim(): string {
|
|
136
174
|
const ipv4Rules = NETWORK_BLOCK_IPV4_NETS.map(
|
|
137
175
|
(net) => `iptables -A OUTPUT -d ${net} -j REJECT --reject-with icmp-port-unreachable`,
|
|
@@ -162,6 +200,26 @@ if [ -n "\${hostd_port:-}" ]; then
|
|
|
162
200
|
iptables -A OUTPUT -p tcp -d "$host_gw_ip" --dport "$hostd_port" -j ACCEPT
|
|
163
201
|
fi
|
|
164
202
|
fi
|
|
203
|
+
|
|
204
|
+
# Resolver carve-out: parse /etc/resolv.conf nameservers and ACCEPT
|
|
205
|
+
# udp+tcp dport 53 to each. Gated on TYPECLAW_NETWORK_AUTO_ALLOW_RESOLVERS=1.
|
|
206
|
+
if [ "\${TYPECLAW_NETWORK_AUTO_ALLOW_RESOLVERS:-0}" = "1" ] && [ -r /etc/resolv.conf ]; then
|
|
207
|
+
for ns in $(awk '/^[[:space:]]*nameserver[[:space:]]+/{print $2}' /etc/resolv.conf | grep -v ':' || true); do
|
|
208
|
+
iptables -A OUTPUT -p udp -d "$ns" --dport 53 -j ACCEPT
|
|
209
|
+
iptables -A OUTPUT -p tcp -d "$ns" --dport 53 -j ACCEPT
|
|
210
|
+
done
|
|
211
|
+
fi
|
|
212
|
+
|
|
213
|
+
# User-supplied allowlist carve-out: comma-separated CIDRs/IPs from
|
|
214
|
+
# TYPECLAW_NETWORK_ALLOW. Already validated at config-parse time.
|
|
215
|
+
if [ -n "\${TYPECLAW_NETWORK_ALLOW:-}" ]; then
|
|
216
|
+
IFS=','
|
|
217
|
+
for cidr in $TYPECLAW_NETWORK_ALLOW; do
|
|
218
|
+
[ -z "$cidr" ] && continue
|
|
219
|
+
iptables -A OUTPUT -d "$cidr" -j ACCEPT
|
|
220
|
+
done
|
|
221
|
+
unset IFS
|
|
222
|
+
fi
|
|
165
223
|
${ipv4Rules.join('\n')}
|
|
166
224
|
|
|
167
225
|
ip6tables -A OUTPUT -o lo -j ACCEPT
|
|
@@ -326,7 +384,7 @@ ${ghKeyringLayer}# Layer 2 (changes when the package list changes): the actual a
|
|
|
326
384
|
# Cache mounts make a re-install nearly free when this layer is invalidated:
|
|
327
385
|
# .deb files come straight from the host's BuildKit cache instead of being
|
|
328
386
|
# refetched from Debian/GitHub mirrors. Package set is composed from the
|
|
329
|
-
# \`
|
|
387
|
+
# \`docker.file\` config block in typeclaw.json — toggles for tmux/python/gh/
|
|
330
388
|
# ffmpeg fan out into the args below. Baseline (git/ca-certificates/curl/
|
|
331
389
|
# gnupg) is always installed because downstream layers depend on it.
|
|
332
390
|
#
|
|
@@ -359,7 +417,7 @@ ${renderEntrypointShimLayer()}
|
|
|
359
417
|
|
|
360
418
|
function renderToggleAptInstallLayer(toggleAptArgs: string[]): string {
|
|
361
419
|
return `# Layer 1 (toggle apt install): packages requested via typeclaw.json
|
|
362
|
-
# #
|
|
420
|
+
# #docker.file toggles. Baseline + Chrome runtime libs are already in the
|
|
363
421
|
# base image; this layer only adds gh/tmux/python/ffmpeg if enabled.
|
|
364
422
|
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
|
|
365
423
|
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\
|
|
@@ -374,7 +432,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
|
|
|
374
432
|
// two cannot drift — the published image is a function of this source, not
|
|
375
433
|
// a checked-in Dockerfile that needs hand-syncing. The base intentionally
|
|
376
434
|
// stops before the per-agent layers (gh keyring, apt feature toggles,
|
|
377
|
-
//
|
|
435
|
+
// docker.file.append, ENV, ENTRYPOINT) so users can still toggle them via
|
|
378
436
|
// typeclaw.json without forcing a base-image rebuild.
|
|
379
437
|
//
|
|
380
438
|
// Layer 2's apt-get install line installs only the baseline packages, NOT
|
|
@@ -529,7 +587,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
|
|
|
529
587
|
|
|
530
588
|
function renderCustomDockerfileLines(lines: string[]): string {
|
|
531
589
|
if (lines.length === 0) return ''
|
|
532
|
-
return `# Custom lines from typeclaw.json#
|
|
590
|
+
return `# Custom lines from typeclaw.json#docker.file.append.
|
|
533
591
|
${lines.join('\n')}
|
|
534
592
|
|
|
535
593
|
`
|
package/src/init/gitignore.ts
CHANGED
|
@@ -39,7 +39,7 @@ channels/
|
|
|
39
39
|
|
|
40
40
|
function renderCustomGitignoreEntries(entries: string[]): string {
|
|
41
41
|
if (entries.length === 0) return ''
|
|
42
|
-
return `# Custom entries from typeclaw.json#
|
|
42
|
+
return `# Custom entries from typeclaw.json#git.ignore.append.
|
|
43
43
|
${entries.join('\n')}
|
|
44
44
|
|
|
45
45
|
`
|