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.
@@ -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/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
- let plan = await planStart({ cwd, hostPort, imageExists: imageExisted, forceBuild, hostdControl })
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({ cwd, hostPort, imageExists: true, forceBuild: false, hostdControl })
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', `127.0.0.1:${hostPort}:${CONTAINER_PORT}`]
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.dockerfile, { baseImageVersion: resolveBaseImageVersion(cwd) }),
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.gitignore))
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 plan = await planStart({ cwd, hostPort, imageExists: true, forceBuild: false })
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 =
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync } from 'node:fs'
2
- import { readFile, writeFile } from 'node:fs/promises'
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 writeAtomic(dockerfilePath, expected)
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 writeAtomic(gitignorePath, expected)
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.dockerfile, gitignore: cfg.gitignore }
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
  }
@@ -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 pathsStaged = uniqueSorted(successes.flatMap((a) => a.changedPaths))
22
- if (pathsStaged.length === 0) {
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
- url = `ws://localhost:${port}`
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(err instanceof Error ? err : new Error(`failed to connect to ${url}`))
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,
@@ -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
- # \`dockerfile\` config block in typeclaw.json — toggles for tmux/python/gh/
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
- # #dockerfile toggles. Baseline + Chrome runtime libs are already in the
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
- // dockerfile.append, ENV, ENTRYPOINT) so users can still toggle them via
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#dockerfile.append.
590
+ return `# Custom lines from typeclaw.json#docker.file.append.
533
591
  ${lines.join('\n')}
534
592
 
535
593
  `
@@ -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#gitignore.append.
42
+ return `# Custom entries from typeclaw.json#git.ignore.append.
43
43
  ${entries.join('\n')}
44
44
 
45
45
  `