typeclaw 0.1.2 → 0.1.4

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.
Files changed (46) hide show
  1. package/README.md +4 -0
  2. package/auth.schema.json +238 -7
  3. package/package.json +1 -1
  4. package/secrets.schema.json +238 -7
  5. package/src/agent/auth.ts +19 -38
  6. package/src/agent/tools/channel-fetch-attachment.ts +6 -0
  7. package/src/agent/tools/channel-history.ts +10 -1
  8. package/src/agent/tools/channel-log.ts +32 -0
  9. package/src/agent/tools/channel-reply.ts +18 -1
  10. package/src/agent/tools/channel-send.ts +13 -1
  11. package/src/bundled-plugins/tool-result-cap/README.md +67 -0
  12. package/src/bundled-plugins/tool-result-cap/cap-result.ts +56 -0
  13. package/src/bundled-plugins/tool-result-cap/index.ts +51 -0
  14. package/src/channels/adapters/kakaotalk.ts +25 -16
  15. package/src/channels/manager.ts +47 -38
  16. package/src/cli/channel.ts +3 -3
  17. package/src/cli/index.ts +3 -0
  18. package/src/cli/init.ts +2 -1
  19. package/src/cli/ui.ts +11 -0
  20. package/src/config/config.ts +61 -4
  21. package/src/container/index.ts +2 -0
  22. package/src/container/start.ts +98 -2
  23. package/src/doctor/checks.ts +7 -27
  24. package/src/doctor/commit.ts +44 -3
  25. package/src/doctor/plugin-bridge.ts +19 -0
  26. package/src/hostd/daemon.ts +28 -3
  27. package/src/hostd/protocol.ts +7 -0
  28. package/src/init/auto-upgrade.ts +368 -0
  29. package/src/init/dockerfile.ts +83 -14
  30. package/src/init/index.ts +123 -77
  31. package/src/init/kakaotalk-auth.ts +9 -3
  32. package/src/init/run-bun-install.ts +34 -0
  33. package/src/run/bundled-plugins.ts +7 -0
  34. package/src/run/index.ts +9 -0
  35. package/src/secrets/defaults.ts +67 -0
  36. package/src/secrets/hydrate.ts +99 -0
  37. package/src/secrets/index.ts +6 -12
  38. package/src/secrets/kakao-store.ts +129 -0
  39. package/src/secrets/migrate-kakaotalk.ts +82 -0
  40. package/src/secrets/migrate.ts +5 -4
  41. package/src/secrets/resolve.ts +57 -0
  42. package/src/secrets/schema.ts +162 -42
  43. package/src/secrets/storage.ts +253 -47
  44. package/src/skills/typeclaw-config/SKILL.md +47 -8
  45. package/typeclaw.schema.json +49 -2
  46. package/src/secrets/env.ts +0 -43
@@ -0,0 +1,368 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { readFile, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+
5
+ import { resolveScaffoldVersion } from './cli-version'
6
+
7
+ const PACKAGE_FILE = 'package.json'
8
+ const TYPECLAW = 'typeclaw'
9
+
10
+ // Two semver quirks drive every branch in this module:
11
+ //
12
+ // 1. Pre-1.0 caret: `^0.1.0` resolves to `>=0.1.0 <0.2.0`. A CLI bump from
13
+ // 0.1.x to 0.2.0 falls OUT of the agent's range, so we must rewrite the
14
+ // spec for those crossings.
15
+ //
16
+ // 2. `bun install` honors the lockfile: when the lockfile entry already
17
+ // satisfies the declared spec, `bun install` is a no-op even if a newer
18
+ // in-range version exists upstream. To actually upgrade an in-range dep
19
+ // we MUST use `bun update <pkg> --latest`. See src/init/run-bun-install.ts.
20
+ //
21
+ // The decision matrix anchors on the INSTALLED version (the truth), not the
22
+ // declared range floor (a promise the agent may not yet have kept).
23
+
24
+ export type AutoUpgradeOutcome =
25
+ | { kind: 'skipped-dev-mode' }
26
+ | { kind: 'skipped-no-dep' }
27
+ | { kind: 'skipped-non-release-spec'; declared: string }
28
+ | { kind: 'skipped-already-running' }
29
+ | { kind: 'up-to-date'; installedVersion: string }
30
+ | { kind: 'exact-pin-respected'; declared: string; cliVersion: string }
31
+ | { kind: 'spec-rewritten'; from: string; to: string; cliVersion: string }
32
+ | { kind: 'reinstall-needed'; from: string; to: string }
33
+
34
+ export type AutoUpgradeOptions = {
35
+ cwd: string
36
+ // Test seam: lets tests simulate dev-mode (null) and arbitrary release
37
+ // versions without depending on the test runner's actual CLI version.
38
+ scaffoldVersion?: string | null
39
+ }
40
+
41
+ export async function autoUpgradeTypeclawDep(options: AutoUpgradeOptions): Promise<AutoUpgradeOutcome> {
42
+ const { cwd } = options
43
+ const scaffold = options.scaffoldVersion !== undefined ? options.scaffoldVersion : resolveScaffoldVersion()
44
+ if (scaffold === null) return { kind: 'skipped-dev-mode' }
45
+
46
+ const cliVersion = stripCaret(scaffold)
47
+ if (cliVersion === null) return { kind: 'skipped-dev-mode' }
48
+
49
+ const pkg = await readAgentPackageJson(cwd)
50
+ if (pkg === null) return { kind: 'skipped-no-dep' }
51
+
52
+ const declared = pkg.parsed.dependencies?.[TYPECLAW]
53
+ if (typeof declared !== 'string') return { kind: 'skipped-no-dep' }
54
+
55
+ const declaredKind = classifyDepSpec(declared)
56
+ if (declaredKind.kind === 'non-release') {
57
+ return { kind: 'skipped-non-release-spec', declared }
58
+ }
59
+
60
+ const installed = readInstalledTypeclawVersion(cwd)
61
+
62
+ // "Upgrade only, never downgrade" — anchored on the INSTALLED version
63
+ // (the truth), with the declared range floor used ONLY when nothing is
64
+ // installed yet (best proxy for "what would land if bun install ran").
65
+ //
66
+ // Anchoring on `installed` first closes the half-applied-rewrite hole:
67
+ // a previous start may have written ^0.2.0 to package.json but failed
68
+ // its install, leaving node_modules at 0.1.x. The declared floor would
69
+ // wrongly say "up-to-date"; the installed version correctly says "retry."
70
+ if (installed !== null && compareReleaseVersions(installed, cliVersion) >= 0) {
71
+ return { kind: 'up-to-date', installedVersion: installed }
72
+ }
73
+ if (installed === null && declaredKind.kind !== 'exact') {
74
+ const declaredFloor = formatTriple(declaredKind.version)
75
+ if (compareReleaseVersions(declaredFloor, cliVersion) >= 0) {
76
+ return { kind: 'up-to-date', installedVersion: declaredFloor }
77
+ }
78
+ }
79
+
80
+ if (declaredKind.kind === 'exact') {
81
+ const declaredVersion = formatTriple(declaredKind.version)
82
+ // Exact pin matches CLI but installed is stale (or missing): we still
83
+ // need to install. The user wrote the right spec — they just haven't
84
+ // materialized it yet. Return reinstall-needed; caller will run
85
+ // `bun update typeclaw --latest` against that exact spec.
86
+ if (declaredVersion === cliVersion) {
87
+ return { kind: 'reinstall-needed', from: installed ?? '<missing>', to: cliVersion }
88
+ }
89
+ // Exact pin diverges from CLI. User intent wins; we warn but never
90
+ // rewrite. If installed is ALSO ahead of CLI (e.g. exact pin 0.1.5,
91
+ // CLI 0.1.2), the up-to-date check above already returned.
92
+ return { kind: 'exact-pin-respected', declared, cliVersion }
93
+ }
94
+
95
+ if (!rangeSatisfies(declaredKind, cliVersion)) {
96
+ const newSpec = `^${cliVersion}`
97
+ await writeDepSpec(cwd, pkg.raw, pkg.parsed, newSpec)
98
+ return { kind: 'spec-rewritten', from: declared, to: newSpec, cliVersion }
99
+ }
100
+
101
+ // Declared range includes CLI. Three sub-cases:
102
+ // - installed === null: fresh agent, nothing on disk yet. ensureDeps
103
+ // will install for the missing-dep reason; nothing for us to add.
104
+ // - installed > CLI but in range: we already returned up-to-date above.
105
+ // - installed < CLI: force an upgrade via `bun update typeclaw --latest`.
106
+ if (installed === null) {
107
+ return { kind: 'up-to-date', installedVersion: cliVersion }
108
+ }
109
+ return { kind: 'reinstall-needed', from: installed, to: cliVersion }
110
+ }
111
+
112
+ export function outcomeForcesInstall(outcome: AutoUpgradeOutcome): boolean {
113
+ return outcome.kind === 'spec-rewritten' || outcome.kind === 'reinstall-needed'
114
+ }
115
+
116
+ // The version we expect to find in node_modules/typeclaw after the
117
+ // auto-upgrade-triggered install completes. Callers use this to verify
118
+ // the install actually moved the on-disk version (not just resolved the
119
+ // lockfile). Returns null when no install was forced — verification is
120
+ // skipped on no-op outcomes.
121
+ export function expectedInstalledAfterUpgrade(outcome: AutoUpgradeOutcome): string | null {
122
+ if (outcome.kind === 'spec-rewritten') return outcome.cliVersion
123
+ if (outcome.kind === 'reinstall-needed') return outcome.to
124
+ return null
125
+ }
126
+
127
+ export function describeAutoUpgrade(outcome: AutoUpgradeOutcome): string {
128
+ switch (outcome.kind) {
129
+ case 'spec-rewritten':
130
+ return `Upgrading agent typeclaw ${outcome.from} → ${outcome.to} to match CLI`
131
+ case 'reinstall-needed':
132
+ return `Upgrading agent typeclaw ${outcome.from} → ${outcome.to} to match CLI`
133
+ case 'exact-pin-respected':
134
+ return `Agent typeclaw is exact-pinned to ${outcome.declared}; CLI is ${outcome.cliVersion}. Not upgrading (remove the exact pin to allow auto-upgrade).`
135
+ default:
136
+ return ''
137
+ }
138
+ }
139
+
140
+ export function readInstalledTypeclawVersionFromAgent(cwd: string): string | null {
141
+ return readInstalledTypeclawVersion(cwd)
142
+ }
143
+
144
+ type ParsedPackage = {
145
+ raw: string
146
+ parsed: { dependencies?: Record<string, string> } & Record<string, unknown>
147
+ }
148
+
149
+ async function readAgentPackageJson(cwd: string): Promise<ParsedPackage | null> {
150
+ const path = join(cwd, PACKAGE_FILE)
151
+ if (!existsSync(path)) return null
152
+ let raw: string
153
+ try {
154
+ raw = await readFile(path, 'utf8')
155
+ } catch {
156
+ return null
157
+ }
158
+ let parsed: unknown
159
+ try {
160
+ parsed = JSON.parse(raw)
161
+ } catch {
162
+ return null
163
+ }
164
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
165
+ return { raw, parsed: parsed as ParsedPackage['parsed'] }
166
+ }
167
+
168
+ function readInstalledTypeclawVersion(cwd: string): string | null {
169
+ const path = join(cwd, 'node_modules', TYPECLAW, PACKAGE_FILE)
170
+ if (!existsSync(path)) return null
171
+ let raw: string
172
+ try {
173
+ raw = readFileSync(path, 'utf8')
174
+ } catch {
175
+ return null
176
+ }
177
+ try {
178
+ const parsed = JSON.parse(raw) as { version?: string }
179
+ if (typeof parsed.version === 'string' && isReleaseVersion(parsed.version)) return parsed.version
180
+ } catch {}
181
+ return null
182
+ }
183
+
184
+ type DepSpecKind =
185
+ | { kind: 'exact'; version: [number, number, number]; raw: string }
186
+ | { kind: 'caret'; version: [number, number, number] }
187
+ | { kind: 'tilde'; version: [number, number, number] }
188
+ | { kind: 'non-release' }
189
+
190
+ function classifyDepSpec(spec: string): DepSpecKind {
191
+ const trimmed = spec.trim()
192
+ const exactMatch = trimmed.match(/^=?(\d+)\.(\d+)\.(\d+)$/)
193
+ if (exactMatch) {
194
+ const [, a, b, c] = exactMatch
195
+ return { kind: 'exact', version: parseTriple(a!, b!, c!), raw: trimmed }
196
+ }
197
+ const caretMatch = trimmed.match(/^\^(\d+)\.(\d+)\.(\d+)$/)
198
+ if (caretMatch) {
199
+ const [, a, b, c] = caretMatch
200
+ return { kind: 'caret', version: parseTriple(a!, b!, c!) }
201
+ }
202
+ const tildeMatch = trimmed.match(/^~(\d+)\.(\d+)\.(\d+)$/)
203
+ if (tildeMatch) {
204
+ const [, a, b, c] = tildeMatch
205
+ return { kind: 'tilde', version: parseTriple(a!, b!, c!) }
206
+ }
207
+ return { kind: 'non-release' }
208
+ }
209
+
210
+ function parseTriple(a: string, b: string, c: string): [number, number, number] {
211
+ return [Number.parseInt(a, 10), Number.parseInt(b, 10), Number.parseInt(c, 10)]
212
+ }
213
+
214
+ function formatTriple(v: [number, number, number]): string {
215
+ return `${v[0]}.${v[1]}.${v[2]}`
216
+ }
217
+
218
+ // npm/bun caret+tilde semantics, narrowed to plain X.Y.Z bases:
219
+ // ^0.1.2 → >=0.1.2 <0.2.0 (pre-1.0 caret pins minor — the bug we fix)
220
+ // ^1.2.3 → >=1.2.3 <2.0.0
221
+ // ~0.1.2 → >=0.1.2 <0.2.0
222
+ // ~1.2.3 → >=1.2.3 <1.3.0
223
+ function rangeSatisfies(range: Exclude<DepSpecKind, { kind: 'exact' | 'non-release' }>, version: string): boolean {
224
+ const v = parseVersion(version)
225
+ if (v === null) return false
226
+ const [base, ceiling] = rangeBounds(range)
227
+ return compareTriples(v, base) >= 0 && compareTriples(v, ceiling) < 0
228
+ }
229
+
230
+ function rangeBounds(
231
+ range: Exclude<DepSpecKind, { kind: 'exact' | 'non-release' }>,
232
+ ): [[number, number, number], [number, number, number]] {
233
+ const [maj, min, pat] = range.version
234
+ if (range.kind === 'caret') {
235
+ if (maj > 0)
236
+ return [
237
+ [maj, min, pat],
238
+ [maj + 1, 0, 0],
239
+ ]
240
+ if (min > 0)
241
+ return [
242
+ [maj, min, pat],
243
+ [maj, min + 1, 0],
244
+ ]
245
+ return [
246
+ [maj, min, pat],
247
+ [maj, min, pat + 1],
248
+ ]
249
+ }
250
+ return [
251
+ [maj, min, pat],
252
+ [maj, min + 1, 0],
253
+ ]
254
+ }
255
+
256
+ function parseVersion(version: string): [number, number, number] | null {
257
+ const m = version.match(/^(\d+)\.(\d+)\.(\d+)$/)
258
+ if (!m) return null
259
+ const [, a, b, c] = m
260
+ return parseTriple(a!, b!, c!)
261
+ }
262
+
263
+ function compareTriples(a: [number, number, number], b: [number, number, number]): number {
264
+ for (let i = 0; i < 3; i++) {
265
+ const ai = a[i]!
266
+ const bi = b[i]!
267
+ if (ai !== bi) return ai - bi
268
+ }
269
+ return 0
270
+ }
271
+
272
+ function compareReleaseVersions(a: string, b: string): number {
273
+ const av = parseVersion(a)
274
+ const bv = parseVersion(b)
275
+ if (av === null || bv === null) return 0
276
+ return compareTriples(av, bv)
277
+ }
278
+
279
+ function stripCaret(scaffold: string): string | null {
280
+ const m = scaffold.match(/^\^?(\d+\.\d+\.\d+)$/)
281
+ return m ? (m[1] ?? null) : null
282
+ }
283
+
284
+ function isReleaseVersion(version: string): boolean {
285
+ return /^\d+\.\d+\.\d+$/.test(version)
286
+ }
287
+
288
+ async function writeDepSpec(cwd: string, raw: string, parsed: ParsedPackage['parsed'], newSpec: string): Promise<void> {
289
+ // Scoped edit: replace the typeclaw spec ONLY inside the dependencies
290
+ // object. The previous implementation used `raw.replace(/"typeclaw":.../)`
291
+ // unscoped, which would silently rewrite devDependencies.typeclaw if it
292
+ // appeared before dependencies.typeclaw in the file (the original spec
293
+ // never moves). We slice the dependencies object's textual range, edit
294
+ // inside it, then splice back to preserve whitespace, key order, and
295
+ // trailing newline. If the slice fails (unusual JSON shape), fall back
296
+ // to a full JSON round-trip — formatting churn is acceptable; silently
297
+ // updating the wrong key is not.
298
+ const scoped = sliceDependenciesRange(raw, parsed)
299
+ if (scoped !== null) {
300
+ const { start, end } = scoped
301
+ const block = raw.slice(start, end)
302
+ const replaced = block.replace(
303
+ /("typeclaw"\s*:\s*)"[^"]+"/,
304
+ (_m, prefix: string) => `${prefix}${JSON.stringify(newSpec)}`,
305
+ )
306
+ if (replaced !== block) {
307
+ await writeFile(join(cwd, PACKAGE_FILE), `${raw.slice(0, start)}${replaced}${raw.slice(end)}`)
308
+ return
309
+ }
310
+ }
311
+ const deps = { ...parsed.dependencies, [TYPECLAW]: newSpec }
312
+ const next = { ...parsed, dependencies: deps }
313
+ const indent = detectIndent(raw)
314
+ await writeFile(join(cwd, PACKAGE_FILE), `${JSON.stringify(next, null, indent)}\n`)
315
+ }
316
+
317
+ // Returns the [start, end) byte range of the "dependencies" object's value
318
+ // in `raw`, or null if it can't be located unambiguously. Uses a brace-
319
+ // counting tokenizer that respects string literals so a `dependencies` key
320
+ // inside a string value (e.g. inside a `description`) cannot fool it.
321
+ function sliceDependenciesRange(raw: string, parsed: ParsedPackage['parsed']): { start: number; end: number } | null {
322
+ if (parsed.dependencies === undefined || parsed.dependencies === null) return null
323
+ const keyMatch = raw.match(/"dependencies"\s*:\s*\{/)
324
+ if (!keyMatch || keyMatch.index === undefined) return null
325
+ const startOfOpenBrace = keyMatch.index + keyMatch[0].length - 1
326
+ const closeBrace = findMatchingCloseBrace(raw, startOfOpenBrace)
327
+ if (closeBrace === null) return null
328
+ return { start: startOfOpenBrace, end: closeBrace + 1 }
329
+ }
330
+
331
+ function findMatchingCloseBrace(raw: string, openIndex: number): number | null {
332
+ let depth = 0
333
+ let inString = false
334
+ let escape = false
335
+ for (let i = openIndex; i < raw.length; i++) {
336
+ const ch = raw[i]
337
+ if (escape) {
338
+ escape = false
339
+ continue
340
+ }
341
+ if (inString) {
342
+ if (ch === '\\') escape = true
343
+ else if (ch === '"') inString = false
344
+ continue
345
+ }
346
+ if (ch === '"') {
347
+ inString = true
348
+ continue
349
+ }
350
+ if (ch === '{') depth++
351
+ else if (ch === '}') {
352
+ depth--
353
+ if (depth === 0) return i
354
+ }
355
+ }
356
+ return null
357
+ }
358
+
359
+ function detectIndent(raw: string): number | string {
360
+ // Default to 2 — matches `JSON.stringify(_, _, 2)` behavior and the
361
+ // project's existing scaffold style. Only override when we can see a
362
+ // clear non-2 indent on the first indented line.
363
+ const match = raw.match(/\n([\t ]+)\S/)
364
+ if (!match) return 2
365
+ const sample = match[1]!
366
+ if (sample.startsWith('\t')) return '\t'
367
+ return sample.length
368
+ }
@@ -89,11 +89,13 @@ export const NETWORK_BLOCK_IPV6_NETS = ['fc00::/7', 'fe80::/10', 'ff00::/8', '::
89
89
  // Renders the shell script that runs as PID 1 inside the container. Two
90
90
  // modes, picked at boot time from `$TYPECLAW_NETWORK_BLOCK_INTERNAL`:
91
91
  //
92
- // off (default, blockInternal=false or env unset): no rules installed,
93
- // no setpriv. Just exec `bun run typeclaw "$@"`. Identical observable
94
- // behavior to the pre-feature container.
92
+ // off (env unset or != "1"): no rules installed, no setpriv. Just exec
93
+ // `bun run typeclaw "$@"`. Identical observable behavior to a container
94
+ // without this feature. This is the opt-out path for users who set
95
+ // `network.blockInternal: false` in their `typeclaw.json`.
95
96
  //
96
- // on (blockInternal=true): walks IPv4 + IPv6 block lists and installs
97
+ // on (default, env = "1" via `network.blockInternal: true`): walks IPv4 +
98
+ // IPv6 block lists and installs
97
99
  // REJECT rules in the OUTPUT chain. Loopback (`-o lo`) is ACCEPT'd first
98
100
  // so dev-server dogfooding still works. The hostd HTTP control port on
99
101
  // `host.docker.internal` is re-allowed at runtime — narrowly, single
@@ -130,6 +132,44 @@ export const NETWORK_BLOCK_IPV6_NETS = ['fc00::/7', 'fe80::/10', 'ff00::/8', '::
130
132
  // `set -eu` propagates rule-install failures up to PID 1 exit, which kills
131
133
  // the container. Failing closed is the right thing: an unenforced
132
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.
133
173
  export function buildEntrypointShim(): string {
134
174
  const ipv4Rules = NETWORK_BLOCK_IPV4_NETS.map(
135
175
  (net) => `iptables -A OUTPUT -d ${net} -j REJECT --reject-with icmp-port-unreachable`,
@@ -160,6 +200,26 @@ if [ -n "\${hostd_port:-}" ]; then
160
200
  iptables -A OUTPUT -p tcp -d "$host_gw_ip" --dport "$hostd_port" -j ACCEPT
161
201
  fi
162
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
163
223
  ${ipv4Rules.join('\n')}
164
224
 
165
225
  ip6tables -A OUTPUT -o lo -j ACCEPT
@@ -264,13 +324,10 @@ ${fromAndHeavyLayers}
264
324
 
265
325
  ENV NODE_ENV=production
266
326
 
267
- # Pin agent-messenger's config dir into the agent's workspace/ so KakaoTalk
268
- # (and any future agent-messenger-backed channel) reads/writes credentials
269
- # inside the bind-mounted agent folder. Without this, the SDK would default
270
- # to /root/.config/agent-messenger inside the container, which doesn't
271
- # survive container restarts and isn't visible from the host. The agent
272
- # folder's bind-mount maps /agent → host's agent dir, so the credentials
273
- # end up at <agentDir>/workspace/.agent-messenger/ on the host.
327
+ # Keep agent-messenger's fallback config dir inside workspace/ for any future
328
+ # SDK fallback paths. TypeClaw's KakaoTalk adapter does not write there:
329
+ # credentials live in secrets.json#channels.kakaotalk and container writes go
330
+ # through hostd's secrets-patch RPC.
274
331
  ENV AGENT_MESSENGER_CONFIG_DIR=/agent/workspace/.agent-messenger
275
332
 
276
333
  ${customLines}ENTRYPOINT ["${TYPECLAW_ENTRYPOINT_PATH}"]
@@ -282,8 +339,18 @@ CMD ["run"]
282
339
  // layers (apt baseline, Chrome runtime libs, curl-impersonate, agent-browser,
283
340
  // Chrome for Testing) are already in that image, so the per-agent head only
284
341
  // re-runs the toggle apt install and (optionally) the gh keyring bootstrap.
285
- // When no toggle adds packages, the head is empty after the FROM/WORKDIR/ARG
286
- // trio rebuild cost zero for users who don't change typeclaw.json#dockerfile.
342
+ //
343
+ // The entrypoint shim is ALSO re-emitted here, even though the base image
344
+ // already carries it. Two reasons: (1) older base images published before
345
+ // the shim landed (or before a shim source edit) don't have the up-to-date
346
+ // binary at TYPECLAW_ENTRYPOINT_PATH, and the per-agent ENTRYPOINT line
347
+ // would crash on startup with `stat: no such file or directory`. Re-emitting
348
+ // is ~1KB of image and keeps the contract local: whatever per-agent
349
+ // Dockerfile we emit guarantees the shim path exists, regardless of which
350
+ // base-image vintage we FROM. (2) Edits to `buildEntrypointShim()` ship via
351
+ // npm + `typeclaw start --build` immediately, instead of being blocked on a
352
+ // fresh base-image release. The base image's copy is harmlessly overwritten
353
+ // by this RUN — same path, same chmod.
287
354
  function renderVersionedHead(baseImageVersion: string, ghKeyringLayer: string, toggleAptArgs: string[]): string {
288
355
  const toggleAptLayer = toggleAptArgs.length === 0 ? '' : `${renderToggleAptInstallLayer(toggleAptArgs)}\n\n`
289
356
  return `FROM ${GHCR_BASE_IMAGE_REPO}:${baseImageVersion}
@@ -292,7 +359,9 @@ WORKDIR /agent
292
359
 
293
360
  ARG TARGETARCH
294
361
 
295
- ${ghKeyringLayer}${toggleAptLayer}`
362
+ ${ghKeyringLayer}${toggleAptLayer}${renderEntrypointShimLayer()}
363
+
364
+ `
296
365
  }
297
366
 
298
367
  // FROMs oven/bun:1-slim and rebuilds the full heavy stack inline. Used by