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.
- package/README.md +4 -0
- package/auth.schema.json +238 -7
- package/package.json +1 -1
- package/secrets.schema.json +238 -7
- package/src/agent/auth.ts +19 -38
- package/src/agent/tools/channel-fetch-attachment.ts +6 -0
- package/src/agent/tools/channel-history.ts +10 -1
- package/src/agent/tools/channel-log.ts +32 -0
- package/src/agent/tools/channel-reply.ts +18 -1
- package/src/agent/tools/channel-send.ts +13 -1
- package/src/bundled-plugins/tool-result-cap/README.md +67 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +56 -0
- package/src/bundled-plugins/tool-result-cap/index.ts +51 -0
- package/src/channels/adapters/kakaotalk.ts +25 -16
- package/src/channels/manager.ts +47 -38
- package/src/cli/channel.ts +3 -3
- package/src/cli/index.ts +3 -0
- package/src/cli/init.ts +2 -1
- package/src/cli/ui.ts +11 -0
- package/src/config/config.ts +61 -4
- package/src/container/index.ts +2 -0
- package/src/container/start.ts +98 -2
- package/src/doctor/checks.ts +7 -27
- package/src/doctor/commit.ts +44 -3
- package/src/doctor/plugin-bridge.ts +19 -0
- package/src/hostd/daemon.ts +28 -3
- package/src/hostd/protocol.ts +7 -0
- package/src/init/auto-upgrade.ts +368 -0
- package/src/init/dockerfile.ts +83 -14
- package/src/init/index.ts +123 -77
- package/src/init/kakaotalk-auth.ts +9 -3
- package/src/init/run-bun-install.ts +34 -0
- package/src/run/bundled-plugins.ts +7 -0
- package/src/run/index.ts +9 -0
- package/src/secrets/defaults.ts +67 -0
- package/src/secrets/hydrate.ts +99 -0
- package/src/secrets/index.ts +6 -12
- package/src/secrets/kakao-store.ts +129 -0
- package/src/secrets/migrate-kakaotalk.ts +82 -0
- package/src/secrets/migrate.ts +5 -4
- package/src/secrets/resolve.ts +57 -0
- package/src/secrets/schema.ts +162 -42
- package/src/secrets/storage.ts +253 -47
- package/src/skills/typeclaw-config/SKILL.md +47 -8
- package/typeclaw.schema.json +49 -2
- 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
|
+
}
|
package/src/init/dockerfile.ts
CHANGED
|
@@ -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 (
|
|
93
|
-
//
|
|
94
|
-
//
|
|
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
|
|
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
|
-
#
|
|
268
|
-
#
|
|
269
|
-
#
|
|
270
|
-
#
|
|
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
|
-
//
|
|
286
|
-
//
|
|
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
|