typeclaw 0.1.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/LICENSE +21 -0
- package/README.md +134 -0
- package/auth.schema.json +63 -0
- package/cron.schema.json +96 -0
- package/package.json +72 -0
- package/scripts/emit-base-dockerfile.ts +5 -0
- package/scripts/generate-schema.ts +34 -0
- package/secrets.schema.json +63 -0
- package/src/agent/auth.ts +119 -0
- package/src/agent/compaction.ts +35 -0
- package/src/agent/git-nudge.ts +95 -0
- package/src/agent/index.ts +451 -0
- package/src/agent/plugin-tools.ts +269 -0
- package/src/agent/reload-tool.ts +71 -0
- package/src/agent/self.ts +45 -0
- package/src/agent/session-origin.ts +288 -0
- package/src/agent/subagents.ts +253 -0
- package/src/agent/system-prompt.ts +68 -0
- package/src/agent/tools/channel-fetch-attachment.ts +118 -0
- package/src/agent/tools/channel-history.ts +119 -0
- package/src/agent/tools/channel-reply.ts +182 -0
- package/src/agent/tools/channel-send.ts +212 -0
- package/src/agent/tools/ddg.ts +218 -0
- package/src/agent/tools/restart.ts +122 -0
- package/src/agent/tools/stream-snapshot.ts +181 -0
- package/src/agent/tools/webfetch/fetch.ts +102 -0
- package/src/agent/tools/webfetch/index.ts +1 -0
- package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
- package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
- package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
- package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
- package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
- package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
- package/src/agent/tools/webfetch/tool.ts +281 -0
- package/src/agent/tools/webfetch/types.ts +33 -0
- package/src/agent/tools/websearch.ts +96 -0
- package/src/agent/tools/wikipedia.ts +52 -0
- package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
- package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
- package/src/bundled-plugins/agent-browser/index.ts +179 -0
- package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
- package/src/bundled-plugins/agent-browser/shim.ts +152 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
- package/src/bundled-plugins/guard/index.ts +26 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
- package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
- package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
- package/src/bundled-plugins/guard/policy.ts +18 -0
- package/src/bundled-plugins/memory/README.md +71 -0
- package/src/bundled-plugins/memory/append-tool.ts +84 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
- package/src/bundled-plugins/memory/dreaming.ts +470 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
- package/src/bundled-plugins/memory/index.ts +238 -0
- package/src/bundled-plugins/memory/load-memory.ts +122 -0
- package/src/bundled-plugins/memory/memory-logger.ts +257 -0
- package/src/bundled-plugins/memory/secret-detector.ts +49 -0
- package/src/bundled-plugins/memory/watermark.ts +15 -0
- package/src/bundled-plugins/security/index.ts +35 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
- package/src/bundled-plugins/security/policy.ts +9 -0
- package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
- package/src/channels/adapters/discord-bot-classify.ts +148 -0
- package/src/channels/adapters/discord-bot.ts +640 -0
- package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
- package/src/channels/adapters/kakaotalk-classify.ts +77 -0
- package/src/channels/adapters/kakaotalk.ts +622 -0
- package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
- package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
- package/src/channels/adapters/slack-bot-classify.ts +213 -0
- package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
- package/src/channels/adapters/slack-bot-time.ts +10 -0
- package/src/channels/adapters/slack-bot.ts +881 -0
- package/src/channels/adapters/telegram-bot-classify.ts +155 -0
- package/src/channels/adapters/telegram-bot-format.ts +309 -0
- package/src/channels/adapters/telegram-bot.ts +604 -0
- package/src/channels/engagement.ts +227 -0
- package/src/channels/index.ts +21 -0
- package/src/channels/manager.ts +292 -0
- package/src/channels/membership-cache.ts +116 -0
- package/src/channels/membership-from-history.ts +53 -0
- package/src/channels/membership.ts +30 -0
- package/src/channels/participants.ts +47 -0
- package/src/channels/persistence.ts +209 -0
- package/src/channels/reloadable.ts +28 -0
- package/src/channels/router.ts +1570 -0
- package/src/channels/schema.ts +273 -0
- package/src/channels/types.ts +160 -0
- package/src/cli/channel.ts +403 -0
- package/src/cli/compose-status.ts +95 -0
- package/src/cli/compose.ts +240 -0
- package/src/cli/hostd.ts +163 -0
- package/src/cli/index.ts +27 -0
- package/src/cli/init.ts +592 -0
- package/src/cli/logs.ts +38 -0
- package/src/cli/reload.ts +68 -0
- package/src/cli/restart.ts +66 -0
- package/src/cli/run.ts +77 -0
- package/src/cli/shell.ts +33 -0
- package/src/cli/start.ts +57 -0
- package/src/cli/status.ts +178 -0
- package/src/cli/stop.ts +31 -0
- package/src/cli/tui.ts +35 -0
- package/src/cli/ui.ts +110 -0
- package/src/commands/index.ts +74 -0
- package/src/compose/discover.ts +43 -0
- package/src/compose/index.ts +25 -0
- package/src/compose/logs.ts +162 -0
- package/src/compose/restart.ts +69 -0
- package/src/compose/start.ts +62 -0
- package/src/compose/status.ts +28 -0
- package/src/compose/stop.ts +43 -0
- package/src/config/config.ts +424 -0
- package/src/config/index.ts +25 -0
- package/src/config/providers.ts +234 -0
- package/src/config/reloadable.ts +47 -0
- package/src/container/index.ts +27 -0
- package/src/container/logs.ts +37 -0
- package/src/container/port.ts +137 -0
- package/src/container/shared.ts +290 -0
- package/src/container/shell.ts +58 -0
- package/src/container/start.ts +670 -0
- package/src/container/status.ts +76 -0
- package/src/container/stop.ts +120 -0
- package/src/container/verify-running.ts +149 -0
- package/src/cron/consumer.ts +138 -0
- package/src/cron/index.ts +54 -0
- package/src/cron/reloadable.ts +64 -0
- package/src/cron/scheduler.ts +200 -0
- package/src/cron/schema.ts +96 -0
- package/src/hostd/client.ts +113 -0
- package/src/hostd/daemon.ts +587 -0
- package/src/hostd/index.ts +25 -0
- package/src/hostd/paths.ts +82 -0
- package/src/hostd/portbroker-manager.ts +101 -0
- package/src/hostd/protocol.ts +48 -0
- package/src/hostd/spawn.ts +224 -0
- package/src/hostd/supervisor.ts +60 -0
- package/src/hostd/tailscale.ts +172 -0
- package/src/hostd/version.ts +115 -0
- package/src/init/dockerfile.ts +327 -0
- package/src/init/ensure-deps.ts +152 -0
- package/src/init/gitignore.ts +46 -0
- package/src/init/hatching.ts +60 -0
- package/src/init/index.ts +786 -0
- package/src/init/kakaotalk-auth.ts +114 -0
- package/src/init/models-dev.ts +130 -0
- package/src/init/oauth-login.ts +74 -0
- package/src/init/packagejson.ts +94 -0
- package/src/init/paths.ts +2 -0
- package/src/init/run-bun-install.ts +20 -0
- package/src/markdown/chunk.ts +299 -0
- package/src/markdown/index.ts +1 -0
- package/src/plugin/context.ts +40 -0
- package/src/plugin/define.ts +35 -0
- package/src/plugin/hooks.ts +204 -0
- package/src/plugin/index.ts +63 -0
- package/src/plugin/loader.ts +111 -0
- package/src/plugin/manager.ts +136 -0
- package/src/plugin/registry.ts +145 -0
- package/src/plugin/skills.ts +62 -0
- package/src/plugin/types.ts +172 -0
- package/src/portbroker/bind-with-forward.ts +102 -0
- package/src/portbroker/container-server.ts +305 -0
- package/src/portbroker/forward-result-bus.ts +36 -0
- package/src/portbroker/hostd-client.ts +443 -0
- package/src/portbroker/index.ts +33 -0
- package/src/portbroker/policy.ts +24 -0
- package/src/portbroker/proc-net-tcp.ts +72 -0
- package/src/portbroker/protocol.ts +39 -0
- package/src/reload/client.ts +59 -0
- package/src/reload/index.ts +3 -0
- package/src/reload/registry.ts +60 -0
- package/src/reload/types.ts +13 -0
- package/src/run/bundled-plugins.ts +24 -0
- package/src/run/channel-session-factory.ts +105 -0
- package/src/run/index.ts +432 -0
- package/src/run/plugin-runtime.ts +43 -0
- package/src/run/schema-with-plugins.ts +14 -0
- package/src/secrets/index.ts +13 -0
- package/src/secrets/migrate.ts +95 -0
- package/src/secrets/schema.ts +75 -0
- package/src/secrets/storage.ts +231 -0
- package/src/server/index.ts +436 -0
- package/src/sessions/index.ts +23 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/local-time.ts +21 -0
- package/src/shared/protocol.ts +25 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
- package/src/skills/typeclaw-config/SKILL.md +643 -0
- package/src/skills/typeclaw-cron/SKILL.md +159 -0
- package/src/skills/typeclaw-git/SKILL.md +89 -0
- package/src/skills/typeclaw-memory/SKILL.md +174 -0
- package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
- package/src/skills/typeclaw-plugins/SKILL.md +594 -0
- package/src/skills/typeclaw-skills/SKILL.md +246 -0
- package/src/stream/broker.ts +161 -0
- package/src/stream/index.ts +16 -0
- package/src/stream/types.ts +69 -0
- package/src/tui/client.ts +45 -0
- package/src/tui/format.ts +317 -0
- package/src/tui/index.ts +225 -0
- package/src/tui/theme.ts +41 -0
- package/typeclaw.schema.json +826 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { chmod, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import type { Socket, UnixSocketListener } from 'bun'
|
|
6
|
+
|
|
7
|
+
import type { PortForward } from '@/config'
|
|
8
|
+
import { defaultDockerExec, type DockerExec } from '@/container'
|
|
9
|
+
import type { PortForwardEvent } from '@/portbroker'
|
|
10
|
+
|
|
11
|
+
import { isDaemonReachable } from './client'
|
|
12
|
+
import { ensureDirs, registrationFilePath, registrationsDir, socketPath } from './paths'
|
|
13
|
+
import type {
|
|
14
|
+
HttpInfoResult,
|
|
15
|
+
ListResult,
|
|
16
|
+
Request,
|
|
17
|
+
Response as RpcResponse,
|
|
18
|
+
RestartResult,
|
|
19
|
+
ShutdownResult,
|
|
20
|
+
StatusResult,
|
|
21
|
+
VersionResult,
|
|
22
|
+
} from './protocol'
|
|
23
|
+
import { buildSupervisor, type SupervisorLogEvent, type SupervisorRestart } from './supervisor'
|
|
24
|
+
import type { TailscaleServeEvent } from './tailscale'
|
|
25
|
+
import { UNVERSIONED_SENTINEL } from './version'
|
|
26
|
+
|
|
27
|
+
export type DaemonOptions = {
|
|
28
|
+
exec?: DockerExec
|
|
29
|
+
onLog?: (event: DaemonLogEvent | SupervisorLogEvent) => void
|
|
30
|
+
gcIntervalMs?: number
|
|
31
|
+
gcMissesToDeregister?: number
|
|
32
|
+
socket?: string
|
|
33
|
+
// When provided, the daemon honors `restart` RPCs by invoking this with the
|
|
34
|
+
// (containerName, cwd) it captured at register time. Omit to disable the
|
|
35
|
+
// capability in tests.
|
|
36
|
+
restart?: SupervisorRestart
|
|
37
|
+
restartPreflight?: RestartPreflight
|
|
38
|
+
// Source-tree fingerprint captured at daemon boot. Reported via the
|
|
39
|
+
// `version` RPC so the CLI can detect when its on-disk source has drifted
|
|
40
|
+
// from what the running daemon loaded, and trigger a respawn over the
|
|
41
|
+
// `shutdown` RPC. Omit to advertise as unversioned (drift detection
|
|
42
|
+
// disabled — both peers compare equal on the sentinel).
|
|
43
|
+
version?: string
|
|
44
|
+
// Invoked after the daemon finishes its self-initiated stop in response to
|
|
45
|
+
// a `shutdown` RPC. Production wiring exits the process here so the host
|
|
46
|
+
// can spawn a fresh daemon; tests omit it to keep the process alive.
|
|
47
|
+
onShutdown?: () => void
|
|
48
|
+
httpHost?: string
|
|
49
|
+
httpPort?: number
|
|
50
|
+
// Port-broker capability. When provided, register-RPC's portForward/wsHostPort
|
|
51
|
+
// fields trigger broker spawn alongside supervisor registration. Tests omit
|
|
52
|
+
// it to keep the broker out of unrelated suites.
|
|
53
|
+
portbroker?: PortbrokerCallbacks
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type RestartPreflight = (input: {
|
|
57
|
+
containerName: string
|
|
58
|
+
cwd: string
|
|
59
|
+
build?: boolean
|
|
60
|
+
}) => Promise<RpcResponse | null>
|
|
61
|
+
|
|
62
|
+
export type PortbrokerCallbacks = {
|
|
63
|
+
start: (input: PortbrokerStartInput) => void
|
|
64
|
+
stop: (containerName: string, reason: 'deregistered' | 'broker-stopped') => Promise<void>
|
|
65
|
+
// Returns ports the broker is currently exposing on the host for this
|
|
66
|
+
// container. Empty array when the container is unregistered, when the broker
|
|
67
|
+
// is disabled (`portForward.allow: []`), or when nothing inside the
|
|
68
|
+
// container has bound a forwardable port yet. Read-only — used by the
|
|
69
|
+
// `status` RPC to surface live forward state.
|
|
70
|
+
forwardedPorts: (containerName: string) => number[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type PortbrokerStartInput = {
|
|
74
|
+
containerName: string
|
|
75
|
+
cwd: string
|
|
76
|
+
policy: PortForward
|
|
77
|
+
wsHostPort: number
|
|
78
|
+
brokerToken: string
|
|
79
|
+
onEvent: (event: PortForwardEvent) => void
|
|
80
|
+
onTailscaleServeEvent: (event: TailscaleServeEvent) => void
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type DaemonLogEvent =
|
|
84
|
+
| { kind: 'daemon-listening'; socket: string }
|
|
85
|
+
| { kind: 'daemon-http-listening'; host: string; port: number }
|
|
86
|
+
| { kind: 'daemon-http-port-fallback'; preferred: number; actual: number }
|
|
87
|
+
| { kind: 'daemon-stopping' }
|
|
88
|
+
| { kind: 'register'; containerName: string }
|
|
89
|
+
| { kind: 'deregister'; containerName: string; reason: 'requested' | 'gone' }
|
|
90
|
+
| { kind: 'registration-skipped'; containerName: string; reason: string }
|
|
91
|
+
| { kind: 'shutdown-requested' }
|
|
92
|
+
| { kind: 'port-forward-event'; event: PortForwardEvent }
|
|
93
|
+
| { kind: 'tailscale-serve-event'; event: TailscaleServeEvent }
|
|
94
|
+
|
|
95
|
+
export type Daemon = {
|
|
96
|
+
registered: () => string[]
|
|
97
|
+
stop: () => Promise<void>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const DEFAULT_GC_INTERVAL_MS = 30_000
|
|
101
|
+
const DEFAULT_GC_MISSES_TO_DEREGISTER = 3
|
|
102
|
+
const MAX_REQUEST_BUFFER_BYTES = 64 * 1024
|
|
103
|
+
const MAX_HTTP_REQUEST_BYTES = 64 * 1024
|
|
104
|
+
|
|
105
|
+
// Preferred port for the HTTP control surface. Adjacent to CONTAINER_PORT
|
|
106
|
+
// (8973) for mnemonics. Stability matters: containers cache the URL in
|
|
107
|
+
// TYPECLAW_HOSTD_URL at `docker run` time, so a respawn that picks a fresh
|
|
108
|
+
// random port would leave running containers with stale URLs and no way to
|
|
109
|
+
// reach hostd. We try 8974 first and only fall back to an ephemeral port if
|
|
110
|
+
// it's already in use by some other local service.
|
|
111
|
+
const STABLE_HTTP_PORT = 8974
|
|
112
|
+
|
|
113
|
+
type ServerState = { buf: string }
|
|
114
|
+
|
|
115
|
+
function json(response: RpcResponse, status = 200): globalThis.Response {
|
|
116
|
+
return new Response(JSON.stringify(response), {
|
|
117
|
+
status,
|
|
118
|
+
headers: { 'content-type': 'application/json' },
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function bearerToken(value: string | null): string | null {
|
|
123
|
+
if (!value) return null
|
|
124
|
+
const prefix = 'Bearer '
|
|
125
|
+
if (!value.startsWith(prefix)) return null
|
|
126
|
+
return value.slice(prefix.length)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
type RestoredPayload = {
|
|
130
|
+
containerName: string
|
|
131
|
+
cwd: string
|
|
132
|
+
restartToken?: string
|
|
133
|
+
wsHostPort?: number
|
|
134
|
+
portForward?: PortForward
|
|
135
|
+
brokerToken?: string
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isValidRestoredPayload(value: unknown, expectedName: string): value is RestoredPayload {
|
|
139
|
+
if (!value || typeof value !== 'object') return false
|
|
140
|
+
const v = value as Record<string, unknown>
|
|
141
|
+
if (v.containerName !== expectedName) return false
|
|
142
|
+
if (typeof v.cwd !== 'string') return false
|
|
143
|
+
if (v.restartToken !== undefined && typeof v.restartToken !== 'string') return false
|
|
144
|
+
if (v.wsHostPort !== undefined && (typeof v.wsHostPort !== 'number' || !Number.isFinite(v.wsHostPort))) return false
|
|
145
|
+
if (v.brokerToken !== undefined && typeof v.brokerToken !== 'string') return false
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function restorePersistedRegistrations(
|
|
150
|
+
apply: (payload: RestoredPayload) => void,
|
|
151
|
+
log: (event: DaemonLogEvent | SupervisorLogEvent) => void,
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
let entries: string[]
|
|
154
|
+
try {
|
|
155
|
+
entries = await readdir(registrationsDir())
|
|
156
|
+
} catch {
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
for (const entry of entries) {
|
|
160
|
+
if (!entry.endsWith('.json')) continue
|
|
161
|
+
const expectedName = entry.slice(0, -'.json'.length)
|
|
162
|
+
const filePath = join(registrationsDir(), entry)
|
|
163
|
+
let parsed: unknown
|
|
164
|
+
try {
|
|
165
|
+
parsed = JSON.parse(await readFile(filePath, 'utf8'))
|
|
166
|
+
} catch (error) {
|
|
167
|
+
log({ kind: 'registration-skipped', containerName: expectedName, reason: stringifyError(error) })
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
if (!isValidRestoredPayload(parsed, expectedName)) {
|
|
171
|
+
log({ kind: 'registration-skipped', containerName: expectedName, reason: 'schema mismatch' })
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
apply(parsed)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function stringifyError(error: unknown): string {
|
|
179
|
+
return error instanceof Error ? error.message : String(error)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
183
|
+
await ensureDirs()
|
|
184
|
+
const path = opts.socket ?? socketPath()
|
|
185
|
+
|
|
186
|
+
if (existsSync(path)) {
|
|
187
|
+
if (await isDaemonReachable(500)) {
|
|
188
|
+
throw new Error(`another typeclaw host daemon is already listening at ${path}`)
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
await unlink(path)
|
|
192
|
+
} catch {}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const log = opts.onLog ?? (() => {})
|
|
196
|
+
const exec = opts.exec ?? defaultDockerExec
|
|
197
|
+
const gcIntervalMs = opts.gcIntervalMs ?? DEFAULT_GC_INTERVAL_MS
|
|
198
|
+
const gcMissesToDeregister = opts.gcMissesToDeregister ?? DEFAULT_GC_MISSES_TO_DEREGISTER
|
|
199
|
+
const version = opts.version ?? UNVERSIONED_SENTINEL
|
|
200
|
+
const cwds = new Map<string, string>()
|
|
201
|
+
const restartTokens = new Map<string, string>()
|
|
202
|
+
const perContainerSerial = new Map<string, Promise<unknown>>()
|
|
203
|
+
const gcMisses = new Map<string, number>()
|
|
204
|
+
let stopped = false
|
|
205
|
+
let httpPort = 0
|
|
206
|
+
|
|
207
|
+
const supervisor = opts.restart
|
|
208
|
+
? buildSupervisor({
|
|
209
|
+
restart: opts.restart,
|
|
210
|
+
onLog: (event) => log(event),
|
|
211
|
+
isStopped: () => stopped,
|
|
212
|
+
})
|
|
213
|
+
: null
|
|
214
|
+
|
|
215
|
+
// Per-container serialization: register/deregister chains through the same
|
|
216
|
+
// promise per containerName, so a deregister arriving mid-register cannot
|
|
217
|
+
// observe a partial state.
|
|
218
|
+
const runSerially = <T>(name: string, op: () => Promise<T>): Promise<T> => {
|
|
219
|
+
const prev = perContainerSerial.get(name) ?? Promise.resolve()
|
|
220
|
+
const next = prev.then(op, op)
|
|
221
|
+
perContainerSerial.set(
|
|
222
|
+
name,
|
|
223
|
+
next.catch(() => {}),
|
|
224
|
+
)
|
|
225
|
+
return next
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
type RegisterPayload = {
|
|
229
|
+
containerName: string
|
|
230
|
+
cwd: string
|
|
231
|
+
restartToken?: string
|
|
232
|
+
wsHostPort?: number
|
|
233
|
+
portForward?: PortForward
|
|
234
|
+
brokerToken?: string
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Atomic write: temp + rename within registrationsDir() so a crash mid-write
|
|
238
|
+
// never leaves a half-written file that boot-time restore would misparse.
|
|
239
|
+
const persistRegistration = async (payload: RegisterPayload): Promise<void> => {
|
|
240
|
+
const final = registrationFilePath(payload.containerName)
|
|
241
|
+
const tmp = `${final}.${process.pid}.tmp`
|
|
242
|
+
const record = {
|
|
243
|
+
containerName: payload.containerName,
|
|
244
|
+
cwd: payload.cwd,
|
|
245
|
+
restartToken: payload.restartToken,
|
|
246
|
+
wsHostPort: payload.wsHostPort,
|
|
247
|
+
portForward: payload.portForward,
|
|
248
|
+
brokerToken: payload.brokerToken,
|
|
249
|
+
}
|
|
250
|
+
await writeFile(tmp, JSON.stringify(record), { mode: 0o600 })
|
|
251
|
+
await rename(tmp, final)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const removeRegistrationFile = async (containerName: string): Promise<void> => {
|
|
255
|
+
try {
|
|
256
|
+
await unlink(registrationFilePath(containerName))
|
|
257
|
+
} catch {}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const applyRegistration = (payload: RegisterPayload): void => {
|
|
261
|
+
const alreadyRegistered = cwds.has(payload.containerName)
|
|
262
|
+
cwds.set(payload.containerName, payload.cwd)
|
|
263
|
+
if (payload.restartToken) restartTokens.set(payload.containerName, payload.restartToken)
|
|
264
|
+
else restartTokens.delete(payload.containerName)
|
|
265
|
+
if (!alreadyRegistered) {
|
|
266
|
+
log({ kind: 'register', containerName: payload.containerName })
|
|
267
|
+
}
|
|
268
|
+
if (
|
|
269
|
+
opts.portbroker &&
|
|
270
|
+
payload.wsHostPort !== undefined &&
|
|
271
|
+
payload.portForward !== undefined &&
|
|
272
|
+
payload.brokerToken !== undefined
|
|
273
|
+
) {
|
|
274
|
+
opts.portbroker.start({
|
|
275
|
+
containerName: payload.containerName,
|
|
276
|
+
cwd: payload.cwd,
|
|
277
|
+
policy: payload.portForward,
|
|
278
|
+
wsHostPort: payload.wsHostPort,
|
|
279
|
+
brokerToken: payload.brokerToken,
|
|
280
|
+
onEvent: (event) => log({ kind: 'port-forward-event', event }),
|
|
281
|
+
onTailscaleServeEvent: (event) => log({ kind: 'tailscale-serve-event', event }),
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const handleRegister = async (req: RegisterPayload): Promise<RpcResponse> => {
|
|
287
|
+
if (stopped) return { ok: false, reason: 'daemon stopping' }
|
|
288
|
+
return runSerially(req.containerName, async () => {
|
|
289
|
+
if (stopped) return { ok: false, reason: 'daemon stopping' }
|
|
290
|
+
try {
|
|
291
|
+
await persistRegistration(req)
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return {
|
|
294
|
+
ok: false,
|
|
295
|
+
reason: `failed to persist registration: ${error instanceof Error ? error.message : String(error)}`,
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
applyRegistration(req)
|
|
299
|
+
return { ok: true }
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const handleDeregister = async (req: { containerName: string }): Promise<RpcResponse> =>
|
|
304
|
+
runSerially(req.containerName, async () => {
|
|
305
|
+
const hadCwd = cwds.delete(req.containerName)
|
|
306
|
+
restartTokens.delete(req.containerName)
|
|
307
|
+
gcMisses.delete(req.containerName)
|
|
308
|
+
if (opts.portbroker) await opts.portbroker.stop(req.containerName, 'deregistered').catch(() => {})
|
|
309
|
+
await removeRegistrationFile(req.containerName)
|
|
310
|
+
if (hadCwd) log({ kind: 'deregister', containerName: req.containerName, reason: 'requested' })
|
|
311
|
+
return { ok: true }
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
const handleList = (): RpcResponse => {
|
|
315
|
+
const result: ListResult = {
|
|
316
|
+
registrations: Array.from(cwds.entries()).map(([containerName, cwd]) => ({ containerName, cwd })),
|
|
317
|
+
}
|
|
318
|
+
return { ok: true, result }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const handleStatus = (req: { containerName: string }): RpcResponse => {
|
|
322
|
+
const cwd = cwds.get(req.containerName)
|
|
323
|
+
if (!cwd) return { ok: false, reason: `not registered: ${req.containerName}` }
|
|
324
|
+
const result: StatusResult = {
|
|
325
|
+
containerName: req.containerName,
|
|
326
|
+
cwd,
|
|
327
|
+
forwardedPorts: opts.portbroker?.forwardedPorts(req.containerName) ?? [],
|
|
328
|
+
}
|
|
329
|
+
return { ok: true, result }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Auth: only restart containers that registered with this daemon. The
|
|
333
|
+
// socket is 0o600 + UID-bound, but inside a container any process that
|
|
334
|
+
// reaches the mounted socket could otherwise restart any peer container on
|
|
335
|
+
// the host. Scoping by registered name limits the blast radius to the set
|
|
336
|
+
// of containers this user already started.
|
|
337
|
+
const handleRestart = async (req: { containerName: string; build?: boolean }): Promise<RpcResponse> => {
|
|
338
|
+
if (!supervisor) return { ok: false, reason: 'restart capability not enabled on this daemon' }
|
|
339
|
+
if (req.build !== undefined && typeof req.build !== 'boolean') {
|
|
340
|
+
return { ok: false, reason: 'restart.build must be a boolean if provided' }
|
|
341
|
+
}
|
|
342
|
+
const cwd = cwds.get(req.containerName)
|
|
343
|
+
if (!cwd) return { ok: false, reason: `not registered: ${req.containerName}` }
|
|
344
|
+
const preflight = opts.restartPreflight
|
|
345
|
+
? await opts.restartPreflight({ containerName: req.containerName, cwd, build: req.build })
|
|
346
|
+
: null
|
|
347
|
+
if (preflight) return preflight
|
|
348
|
+
const ack = supervisor.scheduleRestart({ containerName: req.containerName, cwd, build: req.build })
|
|
349
|
+
if (!ack.ok) return ack
|
|
350
|
+
const result: RestartResult = { containerName: req.containerName, scheduled: true }
|
|
351
|
+
return { ok: true, result }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const handleHttpInfo = (): RpcResponse => {
|
|
355
|
+
const result: HttpInfoResult = { port: httpPort }
|
|
356
|
+
return { ok: true, result }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const handleVersion = (): RpcResponse => {
|
|
360
|
+
const result: VersionResult = { version }
|
|
361
|
+
return { ok: true, result }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Honors a `shutdown` RPC by ACKing first, then tearing the daemon down on
|
|
365
|
+
// the next tick so the reply has time to drain over the socket. The CLI's
|
|
366
|
+
// respawn flow polls the socket file's disappearance to know when it can
|
|
367
|
+
// safely spawn a fresh daemon, which is why teardown must complete (and
|
|
368
|
+
// unlink the socket) before exit. Why an RPC instead of the pidfile-based
|
|
369
|
+
// SIGTERM the AGENTS.md "PID-reuse safety" rule warns about: the socket
|
|
370
|
+
// round-trip itself proves we are talking to the daemon we just registered
|
|
371
|
+
// with, so a stale pidfile cannot redirect the kill to an unrelated process.
|
|
372
|
+
const handleShutdown = (): RpcResponse => {
|
|
373
|
+
if (stopped) return { ok: true, result: { scheduled: true } satisfies ShutdownResult }
|
|
374
|
+
log({ kind: 'shutdown-requested' })
|
|
375
|
+
setTimeout(() => {
|
|
376
|
+
void daemonHandle.stop().then(() => {
|
|
377
|
+
if (opts.onShutdown) opts.onShutdown()
|
|
378
|
+
})
|
|
379
|
+
}, 0)
|
|
380
|
+
return { ok: true, result: { scheduled: true } satisfies ShutdownResult }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const dispatch = async (req: Request): Promise<RpcResponse> => {
|
|
384
|
+
switch (req.kind) {
|
|
385
|
+
case 'register':
|
|
386
|
+
return handleRegister(req)
|
|
387
|
+
case 'deregister':
|
|
388
|
+
return handleDeregister(req)
|
|
389
|
+
case 'list':
|
|
390
|
+
return handleList()
|
|
391
|
+
case 'status':
|
|
392
|
+
return handleStatus(req)
|
|
393
|
+
case 'restart':
|
|
394
|
+
return handleRestart(req)
|
|
395
|
+
case 'http-info':
|
|
396
|
+
return handleHttpInfo()
|
|
397
|
+
case 'version':
|
|
398
|
+
return handleVersion()
|
|
399
|
+
case 'shutdown':
|
|
400
|
+
return handleShutdown()
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const respond = (sock: Socket<ServerState>, response: RpcResponse): void => {
|
|
405
|
+
try {
|
|
406
|
+
sock.write(`${JSON.stringify(response)}\n`)
|
|
407
|
+
} catch {}
|
|
408
|
+
try {
|
|
409
|
+
sock.end()
|
|
410
|
+
} catch {}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const handleData = (sock: Socket<ServerState>, chunk: Buffer): void => {
|
|
414
|
+
sock.data.buf += chunk.toString('utf8')
|
|
415
|
+
if (sock.data.buf.length > MAX_REQUEST_BUFFER_BYTES) {
|
|
416
|
+
respond(sock, { ok: false, reason: 'request exceeds buffer limit' })
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
let newline = sock.data.buf.indexOf('\n')
|
|
420
|
+
while (newline >= 0) {
|
|
421
|
+
const line = sock.data.buf.slice(0, newline)
|
|
422
|
+
sock.data.buf = sock.data.buf.slice(newline + 1)
|
|
423
|
+
let req: Request
|
|
424
|
+
try {
|
|
425
|
+
req = JSON.parse(line) as Request
|
|
426
|
+
} catch {
|
|
427
|
+
respond(sock, { ok: false, reason: 'invalid request json' })
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
void dispatch(req).then(
|
|
431
|
+
(response) => respond(sock, response),
|
|
432
|
+
(error) => respond(sock, { ok: false, reason: error instanceof Error ? error.message : String(error) }),
|
|
433
|
+
)
|
|
434
|
+
newline = sock.data.buf.indexOf('\n')
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const httpFetch = async (req: globalThis.Request): Promise<globalThis.Response> => {
|
|
439
|
+
const url = new URL(req.url)
|
|
440
|
+
if (req.method !== 'POST' || url.pathname !== '/rpc') {
|
|
441
|
+
return json({ ok: false, reason: 'not found' }, 404)
|
|
442
|
+
}
|
|
443
|
+
const token = bearerToken(req.headers.get('authorization'))
|
|
444
|
+
if (!token) return json({ ok: false, reason: 'missing bearer token' }, 401)
|
|
445
|
+
const contentLength = Number(req.headers.get('content-length') ?? '0')
|
|
446
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_HTTP_REQUEST_BYTES) {
|
|
447
|
+
return json({ ok: false, reason: 'request exceeds buffer limit' }, 413)
|
|
448
|
+
}
|
|
449
|
+
let rpc: Request
|
|
450
|
+
try {
|
|
451
|
+
const body = await req.text()
|
|
452
|
+
if (body.length > MAX_HTTP_REQUEST_BYTES) return json({ ok: false, reason: 'request exceeds buffer limit' }, 413)
|
|
453
|
+
rpc = JSON.parse(body) as Request
|
|
454
|
+
} catch {
|
|
455
|
+
return json({ ok: false, reason: 'invalid request json' }, 400)
|
|
456
|
+
}
|
|
457
|
+
if (rpc.kind !== 'restart') {
|
|
458
|
+
return json({ ok: false, reason: 'http transport only supports restart' }, 403)
|
|
459
|
+
}
|
|
460
|
+
if (restartTokens.get(rpc.containerName) !== token) {
|
|
461
|
+
return json({ ok: false, reason: 'invalid restart token' }, 403)
|
|
462
|
+
}
|
|
463
|
+
return json(await handleRestart(rpc))
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const httpHostname = opts.httpHost ?? '0.0.0.0'
|
|
467
|
+
// Try the stable port first so containers' cached TYPECLAW_HOSTD_URL stays
|
|
468
|
+
// valid across hostd respawns. EADDRINUSE means another local service holds
|
|
469
|
+
// it — fall back to ephemeral so the daemon still comes up. The fallback
|
|
470
|
+
// doesn't break NEW container starts (the URL is captured fresh from
|
|
471
|
+
// httpServer.port), but it does break the URL of containers that started
|
|
472
|
+
// when 8974 was free and are still running. That trade-off favors keeping
|
|
473
|
+
// hostd alive over preserving every URL — fail-hard would brick the whole
|
|
474
|
+
// dev workflow whenever a port collision is hit.
|
|
475
|
+
const tryServe = (port: number): ReturnType<typeof Bun.serve> | { error: 'EADDRINUSE' } => {
|
|
476
|
+
try {
|
|
477
|
+
return Bun.serve({ hostname: httpHostname, port, fetch: httpFetch })
|
|
478
|
+
} catch (error) {
|
|
479
|
+
if (error instanceof Error && (error as Error & { code?: string }).code === 'EADDRINUSE') {
|
|
480
|
+
return { error: 'EADDRINUSE' }
|
|
481
|
+
}
|
|
482
|
+
throw error
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const preferredPort = opts.httpPort ?? STABLE_HTTP_PORT
|
|
486
|
+
const stableAttempt = tryServe(preferredPort)
|
|
487
|
+
const httpServer =
|
|
488
|
+
'error' in stableAttempt ? Bun.serve({ hostname: httpHostname, port: 0, fetch: httpFetch }) : stableAttempt
|
|
489
|
+
if ('error' in stableAttempt) {
|
|
490
|
+
log({ kind: 'daemon-http-port-fallback', preferred: preferredPort, actual: httpServer.port ?? 0 })
|
|
491
|
+
}
|
|
492
|
+
httpPort = httpServer.port ?? 0
|
|
493
|
+
log({ kind: 'daemon-http-listening', host: httpHostname, port: httpPort })
|
|
494
|
+
|
|
495
|
+
// Boot-time restore: replay every persisted registration into the in-memory
|
|
496
|
+
// maps and revive portbroker for it. Runs before Bun.listen so the socket
|
|
497
|
+
// is never accepting RPCs against a half-restored registry. A bad file
|
|
498
|
+
// (parse error, schema mismatch) is logged-and-skipped — one corrupt
|
|
499
|
+
// registration must not gate every other container's recovery.
|
|
500
|
+
await restorePersistedRegistrations(applyRegistration, log)
|
|
501
|
+
|
|
502
|
+
const listener: UnixSocketListener<ServerState> = Bun.listen<ServerState>({
|
|
503
|
+
unix: path,
|
|
504
|
+
socket: {
|
|
505
|
+
open: (sock) => {
|
|
506
|
+
sock.data = { buf: '' }
|
|
507
|
+
},
|
|
508
|
+
data: handleData,
|
|
509
|
+
close: () => {},
|
|
510
|
+
error: () => {},
|
|
511
|
+
},
|
|
512
|
+
})
|
|
513
|
+
// Restrict socket to the owning user; ~/.typeclaw/run is also 0700.
|
|
514
|
+
await chmod(path, 0o600).catch(() => {})
|
|
515
|
+
log({ kind: 'daemon-listening', socket: path })
|
|
516
|
+
|
|
517
|
+
// GC tick distinguishes "container confirmed gone" from "docker call failed":
|
|
518
|
+
// a `docker ps` blip should not deregister a live container registration, so
|
|
519
|
+
// we require gcMissesToDeregister consecutive confirmed absences.
|
|
520
|
+
const probeContainerAlive = async (name: string): Promise<'alive' | 'gone' | 'unknown'> => {
|
|
521
|
+
try {
|
|
522
|
+
const result = await exec(['ps', '-a', '--filter', `name=^${name}$`, '--format', '{{.Names}}'])
|
|
523
|
+
if (result.exitCode !== 0) return 'unknown'
|
|
524
|
+
const names = result.stdout
|
|
525
|
+
.trim()
|
|
526
|
+
.split('\n')
|
|
527
|
+
.filter((s) => s.length > 0)
|
|
528
|
+
return names.includes(name) ? 'alive' : 'gone'
|
|
529
|
+
} catch {
|
|
530
|
+
return 'unknown'
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const runGc = async (): Promise<void> => {
|
|
535
|
+
for (const name of Array.from(cwds.keys())) {
|
|
536
|
+
const status = await probeContainerAlive(name)
|
|
537
|
+
if (status === 'alive') {
|
|
538
|
+
gcMisses.delete(name)
|
|
539
|
+
continue
|
|
540
|
+
}
|
|
541
|
+
if (status === 'unknown') continue
|
|
542
|
+
const misses = (gcMisses.get(name) ?? 0) + 1
|
|
543
|
+
if (misses < gcMissesToDeregister) {
|
|
544
|
+
gcMisses.set(name, misses)
|
|
545
|
+
continue
|
|
546
|
+
}
|
|
547
|
+
gcMisses.delete(name)
|
|
548
|
+
void runSerially(name, async () => {
|
|
549
|
+
const hadCwd = cwds.delete(name)
|
|
550
|
+
restartTokens.delete(name)
|
|
551
|
+
if (opts.portbroker) await opts.portbroker.stop(name, 'deregistered').catch(() => {})
|
|
552
|
+
await removeRegistrationFile(name)
|
|
553
|
+
if (hadCwd) log({ kind: 'deregister', containerName: name, reason: 'gone' })
|
|
554
|
+
return { ok: true }
|
|
555
|
+
})
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const gcTimer = setInterval(() => {
|
|
560
|
+
if (stopped || cwds.size === 0) return
|
|
561
|
+
void runGc()
|
|
562
|
+
}, gcIntervalMs)
|
|
563
|
+
|
|
564
|
+
const daemonHandle: Daemon = {
|
|
565
|
+
registered: () => Array.from(cwds.keys()),
|
|
566
|
+
stop: async () => {
|
|
567
|
+
if (stopped) return
|
|
568
|
+
stopped = true
|
|
569
|
+
log({ kind: 'daemon-stopping' })
|
|
570
|
+
clearInterval(gcTimer)
|
|
571
|
+
try {
|
|
572
|
+
listener.stop(true)
|
|
573
|
+
} catch {}
|
|
574
|
+
httpServer.stop(true)
|
|
575
|
+
if (opts.portbroker) {
|
|
576
|
+
const names = Array.from(cwds.keys())
|
|
577
|
+
await Promise.allSettled(names.map((n) => opts.portbroker!.stop(n, 'broker-stopped')))
|
|
578
|
+
}
|
|
579
|
+
cwds.clear()
|
|
580
|
+
restartTokens.clear()
|
|
581
|
+
try {
|
|
582
|
+
if (existsSync(path)) await unlink(path)
|
|
583
|
+
} catch {}
|
|
584
|
+
},
|
|
585
|
+
}
|
|
586
|
+
return daemonHandle
|
|
587
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export { isDaemonReachable, send } from './client'
|
|
2
|
+
export { startDaemon, type Daemon, type DaemonLogEvent, type DaemonOptions } from './daemon'
|
|
3
|
+
export {
|
|
4
|
+
containerSocketPath,
|
|
5
|
+
ensureDirs,
|
|
6
|
+
homeRoot,
|
|
7
|
+
lockfilePath,
|
|
8
|
+
logDir,
|
|
9
|
+
logfilePath,
|
|
10
|
+
pidfilePath,
|
|
11
|
+
runDir,
|
|
12
|
+
socketPath,
|
|
13
|
+
} from './paths'
|
|
14
|
+
export type {
|
|
15
|
+
ListResult,
|
|
16
|
+
Request,
|
|
17
|
+
Response,
|
|
18
|
+
RestartResult,
|
|
19
|
+
ShutdownResult,
|
|
20
|
+
StatusResult,
|
|
21
|
+
VersionResult,
|
|
22
|
+
} from './protocol'
|
|
23
|
+
export { ensureDaemon, type EnsureDaemonOptions, type EnsureDaemonResult } from './spawn'
|
|
24
|
+
export type { SupervisorOptions } from './supervisor'
|
|
25
|
+
export { computeSourceVersion, resolveSrcRoot, UNVERSIONED_SENTINEL, type SourceVersion } from './version'
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { chmod, mkdir } from 'node:fs/promises'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
// Fixed in-container path where the host daemon's run dir is bind-mounted.
|
|
6
|
+
// The agent uses this to reach the host daemon (e.g. for the `restart` tool).
|
|
7
|
+
// Kept stable so the agent never has to discover the host's `~/.typeclaw`
|
|
8
|
+
// location at runtime.
|
|
9
|
+
const CONTAINER_HOST_RUN_DIR = '/run/typeclaw-host'
|
|
10
|
+
const SOCKET_FILE = 'hostd.sock'
|
|
11
|
+
const REGISTRATIONS_DIR = 'registrations'
|
|
12
|
+
|
|
13
|
+
// Defense-in-depth: containerName arrives from RPC payloads (some of which
|
|
14
|
+
// originate inside the container). Docker already forbids slashes and most
|
|
15
|
+
// punctuation in names, but we don't want to trust the wire to enforce that.
|
|
16
|
+
// The character class mirrors Docker's container-naming rules; anything
|
|
17
|
+
// else is rejected so a malicious payload can't escape registrationsDir().
|
|
18
|
+
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/
|
|
19
|
+
|
|
20
|
+
export function homeRoot(): string {
|
|
21
|
+
const override = process.env.TYPECLAW_HOME
|
|
22
|
+
if (override && override.length > 0) return override
|
|
23
|
+
return join(homedir(), '.typeclaw')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function runDir(): string {
|
|
27
|
+
return join(homeRoot(), 'run')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function logDir(): string {
|
|
31
|
+
return join(homeRoot(), 'log')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function socketPath(): string {
|
|
35
|
+
return join(runDir(), SOCKET_FILE)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function pidfilePath(): string {
|
|
39
|
+
return join(runDir(), 'hostd.pid')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function lockfilePath(): string {
|
|
43
|
+
return join(runDir(), 'hostd.lock')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function logfilePath(): string {
|
|
47
|
+
return join(logDir(), 'hostd.log')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function registrationsDir(): string {
|
|
51
|
+
return join(runDir(), REGISTRATIONS_DIR)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Throws on any name that could traverse out of registrationsDir() or
|
|
55
|
+
// confuse the filesystem. Caller's responsibility to handle the error;
|
|
56
|
+
// don't catch-and-ignore — an invalid name is a protocol violation.
|
|
57
|
+
export function registrationFilePath(containerName: string): string {
|
|
58
|
+
if (!SAFE_NAME.test(containerName)) {
|
|
59
|
+
throw new Error(`invalid container name for registration file: ${JSON.stringify(containerName)}`)
|
|
60
|
+
}
|
|
61
|
+
return join(registrationsDir(), `${containerName}.json`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// In-container path to the same socket the host daemon listens on. The
|
|
65
|
+
// container-stage agent tool dials this path; the host bind-mounts the host
|
|
66
|
+
// run dir at CONTAINER_HOST_RUN_DIR so the socket is reachable.
|
|
67
|
+
export function containerSocketPath(): string {
|
|
68
|
+
return join(CONTAINER_HOST_RUN_DIR, SOCKET_FILE)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function containerHostRunDir(): string {
|
|
72
|
+
return CONTAINER_HOST_RUN_DIR
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function ensureDirs(): Promise<void> {
|
|
76
|
+
await mkdir(runDir(), { recursive: true })
|
|
77
|
+
await mkdir(logDir(), { recursive: true })
|
|
78
|
+
await mkdir(registrationsDir(), { recursive: true })
|
|
79
|
+
await chmod(runDir(), 0o700).catch(() => {})
|
|
80
|
+
await chmod(logDir(), 0o700).catch(() => {})
|
|
81
|
+
await chmod(registrationsDir(), 0o700).catch(() => {})
|
|
82
|
+
}
|