typeclaw 0.1.5 → 0.2.0
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 +14 -12
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +7 -4
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +23 -9
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +799 -319
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +6 -1
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +8 -1
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +385 -12
- package/src/config/index.ts +7 -0
- package/src/config/models-mutation.ts +209 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +5 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +39 -58
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +50 -33
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +32 -6
- package/src/init/index.ts +190 -61
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +55 -6
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +68 -0
- package/src/server/index.ts +122 -11
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +38 -64
- package/src/skills/typeclaw-memory/SKILL.md +1 -1
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- package/typeclaw.schema.json +57 -45
package/src/hostd/daemon.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { kakaoChannelBlockSchema } from '@/secrets/schema'
|
|
|
11
11
|
import { SecretsBackend } from '@/secrets/storage'
|
|
12
12
|
|
|
13
13
|
import { isDaemonReachable } from './client'
|
|
14
|
+
import type { KakaoRenewalCallbacks, KakaoRenewalLogEvent } from './kakao-renewal-manager'
|
|
14
15
|
import { ensureDirs, registrationFilePath, registrationsDir, socketPath } from './paths'
|
|
15
16
|
import type {
|
|
16
17
|
HttpInfoResult,
|
|
@@ -54,6 +55,11 @@ export type DaemonOptions = {
|
|
|
54
55
|
// fields trigger broker spawn alongside supervisor registration. Tests omit
|
|
55
56
|
// it to keep the broker out of unrelated suites.
|
|
56
57
|
portbroker?: PortbrokerCallbacks
|
|
58
|
+
// KakaoTalk credential renewal capability. When provided, the daemon
|
|
59
|
+
// starts a per-container daily renewal tick on register and stops it on
|
|
60
|
+
// deregister. Omit to disable in tests / when the agent has no kakaotalk
|
|
61
|
+
// channel configured.
|
|
62
|
+
kakaoRenewal?: KakaoRenewalCallbacks
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
export type RestartPreflight = (input: {
|
|
@@ -94,6 +100,7 @@ export type DaemonLogEvent =
|
|
|
94
100
|
| { kind: 'shutdown-requested' }
|
|
95
101
|
| { kind: 'port-forward-event'; event: PortForwardEvent }
|
|
96
102
|
| { kind: 'tailscale-serve-event'; event: TailscaleServeEvent }
|
|
103
|
+
| KakaoRenewalLogEvent
|
|
97
104
|
|
|
98
105
|
export type Daemon = {
|
|
99
106
|
registered: () => string[]
|
|
@@ -284,6 +291,9 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
284
291
|
onTailscaleServeEvent: (event) => log({ kind: 'tailscale-serve-event', event }),
|
|
285
292
|
})
|
|
286
293
|
}
|
|
294
|
+
if (opts.kakaoRenewal) {
|
|
295
|
+
opts.kakaoRenewal.start({ containerName: payload.containerName, cwd: payload.cwd })
|
|
296
|
+
}
|
|
287
297
|
}
|
|
288
298
|
|
|
289
299
|
const handleRegister = async (req: RegisterPayload): Promise<RpcResponse> => {
|
|
@@ -309,6 +319,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
309
319
|
restartTokens.delete(req.containerName)
|
|
310
320
|
gcMisses.delete(req.containerName)
|
|
311
321
|
if (opts.portbroker) await opts.portbroker.stop(req.containerName, 'deregistered').catch(() => {})
|
|
322
|
+
if (opts.kakaoRenewal) await opts.kakaoRenewal.stop(req.containerName).catch(() => {})
|
|
312
323
|
await removeRegistrationFile(req.containerName)
|
|
313
324
|
if (hadCwd) log({ kind: 'deregister', containerName: req.containerName, reason: 'requested' })
|
|
314
325
|
return { ok: true }
|
|
@@ -574,6 +585,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
574
585
|
const hadCwd = cwds.delete(name)
|
|
575
586
|
restartTokens.delete(name)
|
|
576
587
|
if (opts.portbroker) await opts.portbroker.stop(name, 'deregistered').catch(() => {})
|
|
588
|
+
if (opts.kakaoRenewal) await opts.kakaoRenewal.stop(name).catch(() => {})
|
|
577
589
|
await removeRegistrationFile(name)
|
|
578
590
|
if (hadCwd) log({ kind: 'deregister', containerName: name, reason: 'gone' })
|
|
579
591
|
return { ok: true }
|
|
@@ -601,6 +613,10 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
601
613
|
const names = Array.from(cwds.keys())
|
|
602
614
|
await Promise.allSettled(names.map((n) => opts.portbroker!.stop(n, 'broker-stopped')))
|
|
603
615
|
}
|
|
616
|
+
if (opts.kakaoRenewal) {
|
|
617
|
+
const names = Array.from(cwds.keys())
|
|
618
|
+
await Promise.allSettled(names.map((n) => opts.kakaoRenewal!.stop(n)))
|
|
619
|
+
}
|
|
604
620
|
cwds.clear()
|
|
605
621
|
restartTokens.clear()
|
|
606
622
|
try {
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { renewCurrentAccount, type AttemptLoginFn } from '@/secrets/kakao-renewal'
|
|
2
|
+
import { createKeyStore, type KeyStore } from '@/secrets/keys'
|
|
3
|
+
|
|
4
|
+
import { keysDir } from './paths'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TICK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
|
7
|
+
|
|
8
|
+
export type KakaoRenewalCallbacks = {
|
|
9
|
+
start: (input: KakaoRenewalStartInput) => void
|
|
10
|
+
stop: (containerName: string) => Promise<void>
|
|
11
|
+
drain: () => Promise<void>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type KakaoRenewalStartInput = {
|
|
15
|
+
containerName: string
|
|
16
|
+
cwd: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type KakaoRenewalLogEvent =
|
|
20
|
+
| { kind: 'kakao-renewal-tick-start'; containerName: string }
|
|
21
|
+
| { kind: 'kakao-renewal-tick-skipped'; containerName: string; reason: string; ageMs?: number }
|
|
22
|
+
| { kind: 'kakao-renewal-tick-ok'; containerName: string; accountId: string; previousUpdatedAt: string }
|
|
23
|
+
| {
|
|
24
|
+
kind: 'kakao-renewal-tick-reauth-required'
|
|
25
|
+
containerName: string
|
|
26
|
+
accountId: string
|
|
27
|
+
reason: string
|
|
28
|
+
message: string
|
|
29
|
+
}
|
|
30
|
+
| { kind: 'kakao-renewal-tick-transient-failure'; containerName: string; accountId: string; reason: string }
|
|
31
|
+
| { kind: 'kakao-renewal-tick-error'; containerName: string; error: string }
|
|
32
|
+
| { kind: 'kakao-renewal-restart-scheduled'; containerName: string; accountId: string }
|
|
33
|
+
| { kind: 'kakao-renewal-restart-failed'; containerName: string; accountId: string; reason: string }
|
|
34
|
+
|
|
35
|
+
export type KakaoRenewalManagerOptions = {
|
|
36
|
+
onLog?: (event: KakaoRenewalLogEvent) => void
|
|
37
|
+
tickIntervalMs?: number
|
|
38
|
+
keyStoreFactory?: () => KeyStore
|
|
39
|
+
attemptLogin?: AttemptLoginFn
|
|
40
|
+
schedule?: (fn: () => void, intervalMs: number) => { stop: () => void }
|
|
41
|
+
// Invoked after a successful renewal so the host can restart the container
|
|
42
|
+
// and the in-memory adapter picks up the fresh tokens. Without this, the
|
|
43
|
+
// cron writes new tokens to secrets.json but the live LOCO client keeps the
|
|
44
|
+
// old token in its closure and still hits 401 at the ~7-day wall. Production
|
|
45
|
+
// wires this to the same restart path the `restart` RPC uses; tests can
|
|
46
|
+
// observe it via a fake.
|
|
47
|
+
onRenewalOk?: (input: { containerName: string; cwd: string; accountId: string }) => Promise<void>
|
|
48
|
+
// Optional predicate: only start the renewal cron for containers whose
|
|
49
|
+
// `typeclaw.json` actually has a `channels.kakaotalk` block. Without this,
|
|
50
|
+
// every typeclaw agent on the host emits daily `no_account` skip logs.
|
|
51
|
+
shouldRenew?: (input: KakaoRenewalStartInput) => boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Per-container daily renewal tick. Mirrors portbroker-manager.ts: hostd calls
|
|
55
|
+
// start() on register and stop() on deregister, and the manager owns timer
|
|
56
|
+
// lifecycle plus the actual renewal work. The keystore lives on the host
|
|
57
|
+
// (~/.typeclaw/keys/<name>.key), unreachable from inside the container —
|
|
58
|
+
// that's load-bearing for encryption.ts's threat model.
|
|
59
|
+
export function createKakaoRenewalManager(opts: KakaoRenewalManagerOptions = {}): KakaoRenewalCallbacks {
|
|
60
|
+
const intervalMs = opts.tickIntervalMs ?? DEFAULT_TICK_INTERVAL_MS
|
|
61
|
+
const keyStore = (opts.keyStoreFactory ?? (() => createKeyStore({ keysDir: keysDir() })))()
|
|
62
|
+
const log = opts.onLog ?? (() => {})
|
|
63
|
+
const schedule =
|
|
64
|
+
opts.schedule ??
|
|
65
|
+
((fn: () => void, ms: number) => {
|
|
66
|
+
const handle = setInterval(fn, ms)
|
|
67
|
+
return { stop: () => clearInterval(handle) }
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const timers = new Map<string, { stop: () => void }>()
|
|
71
|
+
// Track the latest registration input per container so a re-register
|
|
72
|
+
// arriving during an in-flight tick can both (a) prevent the in-flight tick
|
|
73
|
+
// from acting on stale cwd, and (b) trigger a fresh tick once the in-flight
|
|
74
|
+
// one finishes. The Map's value is the most recent input.
|
|
75
|
+
const latestInput = new Map<string, KakaoRenewalStartInput>()
|
|
76
|
+
// Track in-flight tick promises per container so stop()/drain() can await
|
|
77
|
+
// them. Without this, daemon shutdown abandons an in-flight attemptLogin
|
|
78
|
+
// HTTPS request mid-write.
|
|
79
|
+
const inFlight = new Map<string, Promise<void>>()
|
|
80
|
+
// Pending immediate-tick request: set when start() is called while a tick
|
|
81
|
+
// is in flight, so we re-fire one tick after the in-flight settles.
|
|
82
|
+
const pendingRerun = new Set<string>()
|
|
83
|
+
|
|
84
|
+
const runTick = async (input: KakaoRenewalStartInput): Promise<void> => {
|
|
85
|
+
log({ kind: 'kakao-renewal-tick-start', containerName: input.containerName })
|
|
86
|
+
try {
|
|
87
|
+
const result = await renewCurrentAccount({
|
|
88
|
+
containerName: input.containerName,
|
|
89
|
+
agentDir: input.cwd,
|
|
90
|
+
keyStore,
|
|
91
|
+
...(opts.attemptLogin ? { attemptLogin: opts.attemptLogin } : {}),
|
|
92
|
+
})
|
|
93
|
+
if (result.kind === 'skipped') {
|
|
94
|
+
log({
|
|
95
|
+
kind: 'kakao-renewal-tick-skipped',
|
|
96
|
+
containerName: input.containerName,
|
|
97
|
+
reason: result.reason,
|
|
98
|
+
...(result.ageMs !== undefined ? { ageMs: result.ageMs } : {}),
|
|
99
|
+
})
|
|
100
|
+
} else if (result.kind === 'ok') {
|
|
101
|
+
log({
|
|
102
|
+
kind: 'kakao-renewal-tick-ok',
|
|
103
|
+
containerName: input.containerName,
|
|
104
|
+
accountId: result.account_id,
|
|
105
|
+
previousUpdatedAt: result.previousUpdatedAt,
|
|
106
|
+
})
|
|
107
|
+
if (opts.onRenewalOk) {
|
|
108
|
+
log({
|
|
109
|
+
kind: 'kakao-renewal-restart-scheduled',
|
|
110
|
+
containerName: input.containerName,
|
|
111
|
+
accountId: result.account_id,
|
|
112
|
+
})
|
|
113
|
+
try {
|
|
114
|
+
await opts.onRenewalOk({
|
|
115
|
+
containerName: input.containerName,
|
|
116
|
+
cwd: input.cwd,
|
|
117
|
+
accountId: result.account_id,
|
|
118
|
+
})
|
|
119
|
+
} catch (err) {
|
|
120
|
+
log({
|
|
121
|
+
kind: 'kakao-renewal-restart-failed',
|
|
122
|
+
containerName: input.containerName,
|
|
123
|
+
accountId: result.account_id,
|
|
124
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} else if (result.kind === 'reauth_required') {
|
|
129
|
+
log({
|
|
130
|
+
kind: 'kakao-renewal-tick-reauth-required',
|
|
131
|
+
containerName: input.containerName,
|
|
132
|
+
accountId: result.account_id,
|
|
133
|
+
reason: result.reason,
|
|
134
|
+
message: result.message,
|
|
135
|
+
})
|
|
136
|
+
} else {
|
|
137
|
+
log({
|
|
138
|
+
kind: 'kakao-renewal-tick-transient-failure',
|
|
139
|
+
containerName: input.containerName,
|
|
140
|
+
accountId: result.account_id,
|
|
141
|
+
reason: result.reason,
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
} catch (err) {
|
|
145
|
+
// Defensive: renewCurrentAccount's contract is to return a structured
|
|
146
|
+
// result, but a malformed secrets.json or a disk error could surface
|
|
147
|
+
// here. Log and move on — the next tick retries.
|
|
148
|
+
log({
|
|
149
|
+
kind: 'kakao-renewal-tick-error',
|
|
150
|
+
containerName: input.containerName,
|
|
151
|
+
error: err instanceof Error ? err.message : String(err),
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Single-flight per container: dedupe overlapping ticks, but if a new
|
|
157
|
+
// tick request arrives while one is in flight, queue ONE rerun so the new
|
|
158
|
+
// registration's cwd (or a manual nudge) gets a chance after the in-flight
|
|
159
|
+
// settles. The Promise stored in inFlight resolves only after any queued
|
|
160
|
+
// rerun also completes, so stop()/drain() awaiting inFlight is enough to
|
|
161
|
+
// observe a quiescent manager.
|
|
162
|
+
const scheduleTick = (containerName: string): Promise<void> => {
|
|
163
|
+
const existing = inFlight.get(containerName)
|
|
164
|
+
if (existing) {
|
|
165
|
+
pendingRerun.add(containerName)
|
|
166
|
+
return existing
|
|
167
|
+
}
|
|
168
|
+
const promise = (async () => {
|
|
169
|
+
// Loop until no rerun was queued during this tick, so the LAST input
|
|
170
|
+
// recorded for the container is the one we end on. This handles the
|
|
171
|
+
// re-register-while-in-flight + cwd-change case described in the
|
|
172
|
+
// pendingRerun comment above.
|
|
173
|
+
while (true) {
|
|
174
|
+
const input = latestInput.get(containerName)
|
|
175
|
+
if (!input) return
|
|
176
|
+
await runTick(input)
|
|
177
|
+
if (!pendingRerun.has(containerName)) return
|
|
178
|
+
pendingRerun.delete(containerName)
|
|
179
|
+
}
|
|
180
|
+
})().finally(() => {
|
|
181
|
+
inFlight.delete(containerName)
|
|
182
|
+
pendingRerun.delete(containerName)
|
|
183
|
+
})
|
|
184
|
+
inFlight.set(containerName, promise)
|
|
185
|
+
return promise
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
start(input: KakaoRenewalStartInput): void {
|
|
190
|
+
if (opts.shouldRenew && !opts.shouldRenew(input)) return
|
|
191
|
+
const existing = timers.get(input.containerName)
|
|
192
|
+
if (existing) existing.stop()
|
|
193
|
+
latestInput.set(input.containerName, input)
|
|
194
|
+
const handle = schedule(() => {
|
|
195
|
+
void scheduleTick(input.containerName)
|
|
196
|
+
}, intervalMs)
|
|
197
|
+
timers.set(input.containerName, handle)
|
|
198
|
+
void scheduleTick(input.containerName)
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
async stop(containerName: string): Promise<void> {
|
|
202
|
+
const handle = timers.get(containerName)
|
|
203
|
+
if (handle) {
|
|
204
|
+
timers.delete(containerName)
|
|
205
|
+
handle.stop()
|
|
206
|
+
}
|
|
207
|
+
latestInput.delete(containerName)
|
|
208
|
+
// Await any in-flight tick before resolving so the caller can rely on
|
|
209
|
+
// "stop returned → no work outstanding". Daemon shutdown depends on
|
|
210
|
+
// this; without it, a mid-tick `attemptLogin` HTTPS call is abandoned.
|
|
211
|
+
const promise = inFlight.get(containerName)
|
|
212
|
+
if (promise) await promise.catch(() => {})
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
async drain(): Promise<void> {
|
|
216
|
+
for (const [, handle] of timers) handle.stop()
|
|
217
|
+
timers.clear()
|
|
218
|
+
latestInput.clear()
|
|
219
|
+
const promises = Array.from(inFlight.values())
|
|
220
|
+
await Promise.allSettled(promises)
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
}
|
package/src/hostd/paths.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { join } from 'node:path'
|
|
|
9
9
|
const CONTAINER_HOST_RUN_DIR = '/run/typeclaw-host'
|
|
10
10
|
const SOCKET_FILE = 'hostd.sock'
|
|
11
11
|
const REGISTRATIONS_DIR = 'registrations'
|
|
12
|
+
const KEYS_DIR = 'keys'
|
|
12
13
|
|
|
13
14
|
// Defense-in-depth: containerName arrives from RPC payloads (some of which
|
|
14
15
|
// originate inside the container). Docker already forbids slashes and most
|
|
@@ -51,6 +52,10 @@ export function registrationsDir(): string {
|
|
|
51
52
|
return join(runDir(), REGISTRATIONS_DIR)
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
export function keysDir(): string {
|
|
56
|
+
return join(homeRoot(), KEYS_DIR)
|
|
57
|
+
}
|
|
58
|
+
|
|
54
59
|
// Throws on any name that could traverse out of registrationsDir() or
|
|
55
60
|
// confuse the filesystem. Caller's responsibility to handle the error;
|
|
56
61
|
// don't catch-and-ignore — an invalid name is a protocol violation.
|
|
@@ -76,7 +81,9 @@ export async function ensureDirs(): Promise<void> {
|
|
|
76
81
|
await mkdir(runDir(), { recursive: true })
|
|
77
82
|
await mkdir(logDir(), { recursive: true })
|
|
78
83
|
await mkdir(registrationsDir(), { recursive: true })
|
|
84
|
+
await mkdir(keysDir(), { recursive: true })
|
|
79
85
|
await chmod(runDir(), 0o700).catch(() => {})
|
|
80
86
|
await chmod(logDir(), 0o700).catch(() => {})
|
|
81
87
|
await chmod(registrationsDir(), 0o700).catch(() => {})
|
|
88
|
+
await chmod(keysDir(), 0o700).catch(() => {})
|
|
82
89
|
}
|
package/src/init/dockerfile.ts
CHANGED
|
@@ -136,14 +136,38 @@ export const NETWORK_BLOCK_IPV6_NETS = ['fc00::/7', 'fe80::/10', 'ff00::/8', '::
|
|
|
136
136
|
// Carve-out ordering is load-bearing. iptables OUTPUT is first-match-wins,
|
|
137
137
|
// and we use -A (append). So the order written into the shim is the order
|
|
138
138
|
// rules will be evaluated:
|
|
139
|
-
// 1.
|
|
140
|
-
//
|
|
141
|
-
//
|
|
139
|
+
// 1. ESTABLISHED,RELATED ACCEPT (return path for any connection initiated
|
|
140
|
+
// from outside the container — see comment block below)
|
|
141
|
+
// 2. loopback ACCEPT
|
|
142
|
+
// 3. hostd port ACCEPT (narrow: tcp + single dport on the host gateway)
|
|
143
|
+
// 4. resolver ACCEPT (narrow: udp/tcp dport 53 to each /etc/resolv.conf
|
|
142
144
|
// nameserver) — gated on TYPECLAW_NETWORK_AUTO_ALLOW_RESOLVERS=1
|
|
143
|
-
//
|
|
145
|
+
// 5. user-supplied allowlist ACCEPT (wholesale: -d <cidr>) — driven by
|
|
144
146
|
// TYPECLAW_NETWORK_ALLOW comma-separated env
|
|
145
|
-
//
|
|
146
|
-
// A resolver at 10.0.0.2 hits (
|
|
147
|
+
// 6. RFC1918 + link-local + CGNAT + multicast + reserved REJECTs
|
|
148
|
+
// A resolver at 10.0.0.2 hits (4) and ACCEPTs before (6) DROPs it.
|
|
149
|
+
//
|
|
150
|
+
// Rule 1 (conntrack ESTABLISHED,RELATED) is what makes Docker port-forward
|
|
151
|
+
// reply traffic survive the RFC1918 REJECT. On Docker Desktop and OrbStack
|
|
152
|
+
// the bridge gateway is in 192.168.0.0/16 (OrbStack: 192.168.215.1 or
|
|
153
|
+
// 192.168.139.1; Docker Desktop: 192.168.65.1). A host -> container request
|
|
154
|
+
// via `docker run -p 127.0.0.1:HOST:CONTAINER` arrives at the container
|
|
155
|
+
// from the bridge gateway IP. Without rule 1, the reply packets would
|
|
156
|
+
// match rule 6 (192.168.0.0/16 REJECT) and never reach the host — TCP
|
|
157
|
+
// handshake completes (kernel SYN/ACK is in INPUT, not OUTPUT), the
|
|
158
|
+
// request body is delivered, but the agent's HTTP response is dropped at
|
|
159
|
+
// OUTPUT. Symptom: `curl http://127.0.0.1:HOST` connects but receives
|
|
160
|
+
// zero bytes and times out. Stateful inversion via conntrack is the
|
|
161
|
+
// canonical fix: ESTABLISHED matches packets belonging to a connection
|
|
162
|
+
// the kernel already tracks (including the inbound port-forward), and
|
|
163
|
+
// RELATED covers ICMP error packets for those connections. No new
|
|
164
|
+
// outbound capability is granted — a compromised agent still cannot
|
|
165
|
+
// initiate connections to RFC1918, only respond to inbound ones.
|
|
166
|
+
//
|
|
167
|
+
// Requires the `xt_conntrack` kernel module (universal on Linux 2.6.20+
|
|
168
|
+
// and on every Docker/OrbStack VM kernel) and the userspace iptables
|
|
169
|
+
// `conntrack` match (shipped in the `iptables` Debian package on trixie
|
|
170
|
+
// alongside the binary itself; no extra apt install needed).
|
|
147
171
|
//
|
|
148
172
|
// The resolver carve-out reads /etc/resolv.conf inside the container, NOT
|
|
149
173
|
// on the host. Docker propagates the host's resolver into the container by
|
|
@@ -186,6 +210,7 @@ if [ "\${TYPECLAW_NETWORK_BLOCK_INTERNAL:-0}" != "1" ]; then
|
|
|
186
210
|
exec bun run typeclaw "$@"
|
|
187
211
|
fi
|
|
188
212
|
|
|
213
|
+
iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
|
189
214
|
iptables -A OUTPUT -o lo -j ACCEPT
|
|
190
215
|
|
|
191
216
|
# Hostd HTTP control carve-out: narrow ACCEPT, scoped to one TCP port on
|
|
@@ -222,6 +247,7 @@ if [ -n "\${TYPECLAW_NETWORK_ALLOW:-}" ]; then
|
|
|
222
247
|
fi
|
|
223
248
|
${ipv4Rules.join('\n')}
|
|
224
249
|
|
|
250
|
+
ip6tables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
|
225
251
|
ip6tables -A OUTPUT -o lo -j ACCEPT
|
|
226
252
|
${ipv6Rules.join('\n')}
|
|
227
253
|
|