typeclaw 0.1.0 → 0.1.2
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 +12 -12
- package/package.json +3 -2
- package/src/agent/auth.ts +10 -4
- package/src/agent/doctor.ts +173 -0
- package/src/agent/subagents.ts +24 -2
- package/src/bundled-plugins/backup/README.md +81 -0
- package/src/bundled-plugins/backup/index.ts +209 -0
- package/src/bundled-plugins/backup/runner.ts +231 -0
- package/src/bundled-plugins/backup/subagents.ts +200 -0
- package/src/bundled-plugins/memory/index.ts +42 -1
- package/src/bundled-plugins/security/index.ts +5 -1
- package/src/bundled-plugins/security/policies/git-exfil.ts +184 -4
- package/src/bundled-plugins/security/policies/remote-taint-state.ts +59 -0
- package/src/channels/adapters/kakaotalk-attachment.ts +224 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +20 -1
- package/src/channels/adapters/kakaotalk-fetch-attachment.ts +91 -0
- package/src/channels/adapters/kakaotalk.ts +58 -3
- package/src/channels/router.ts +40 -2
- package/src/cli/compose.ts +92 -1
- package/src/cli/doctor.ts +100 -0
- package/src/cli/index.ts +1 -0
- package/src/compose/doctor.ts +141 -0
- package/src/compose/index.ts +8 -0
- package/src/compose/logs.ts +32 -19
- package/src/config/config.ts +20 -0
- package/src/container/log-colors.ts +75 -0
- package/src/container/log-timestamps.ts +84 -0
- package/src/container/logs.ts +71 -5
- package/src/container/start.ts +23 -8
- package/src/cron/consumer.ts +29 -7
- package/src/doctor/checks.ts +426 -0
- package/src/doctor/commit.ts +71 -0
- package/src/doctor/index.ts +287 -0
- package/src/doctor/plugin-bridge.ts +147 -0
- package/src/doctor/report.ts +142 -0
- package/src/doctor/types.ts +87 -0
- package/src/init/cli-version.ts +81 -0
- package/src/init/dockerfile.ts +223 -25
- package/src/init/ensure-deps.ts +2 -2
- package/src/init/index.ts +23 -13
- package/src/init/run-bun-install.ts +17 -1
- package/src/plugin/hooks.ts +32 -0
- package/src/plugin/index.ts +7 -0
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/registry.ts +32 -3
- package/src/plugin/types.ts +65 -0
- package/src/run/bundled-plugins.ts +8 -0
- package/src/run/index.ts +10 -5
- package/src/secrets/env.ts +43 -0
- package/src/secrets/index.ts +2 -0
- package/src/server/index.ts +103 -5
- package/src/shared/index.ts +3 -0
- package/src/shared/protocol.ts +22 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +26 -3
- package/src/skills/typeclaw-config/SKILL.md +1 -1
- package/tsconfig.json +30 -0
- package/typeclaw.schema.json +50 -4
package/src/config/config.ts
CHANGED
|
@@ -104,6 +104,23 @@ export const gitignoreSchema = z
|
|
|
104
104
|
|
|
105
105
|
export type GitignoreConfig = z.infer<typeof gitignoreSchema>
|
|
106
106
|
|
|
107
|
+
// `blockInternal` is the kill-switch for the container-stage egress filter
|
|
108
|
+
// installed by Dockerfile entrypoint shim: when true, the container is granted
|
|
109
|
+
// CAP_NET_ADMIN at boot just long enough to install iptables OUTPUT rules
|
|
110
|
+
// that DROP traffic to RFC1918, link-local (incl. cloud metadata), CGNAT,
|
|
111
|
+
// multicast/reserved, IPv6 ULA/link-local/multicast. The capability is then
|
|
112
|
+
// dropped from the bounding set via setpriv before the agent process exec's,
|
|
113
|
+
// so no child (python, curl, bun-spawned anything) can mutate or recover it.
|
|
114
|
+
// Default is `false` so existing agent folders are unaffected by an upgrade;
|
|
115
|
+
// `typeclaw init` writes `true` for new agents (handled separately in init).
|
|
116
|
+
export const networkSchema = z
|
|
117
|
+
.object({
|
|
118
|
+
blockInternal: z.boolean().default(false),
|
|
119
|
+
})
|
|
120
|
+
.default({ blockInternal: false })
|
|
121
|
+
|
|
122
|
+
export type NetworkConfig = z.infer<typeof networkSchema>
|
|
123
|
+
|
|
107
124
|
export const configSchema = z
|
|
108
125
|
.object({
|
|
109
126
|
$schema: z.string().optional(),
|
|
@@ -123,6 +140,7 @@ export const configSchema = z
|
|
|
123
140
|
alias: z.array(z.string().trim().min(1)).default([]),
|
|
124
141
|
channels: channelsSchema,
|
|
125
142
|
portForward: portForwardSchema,
|
|
143
|
+
network: networkSchema,
|
|
126
144
|
dockerfile: dockerfileSchema,
|
|
127
145
|
gitignore: gitignoreSchema,
|
|
128
146
|
})
|
|
@@ -213,6 +231,7 @@ export const FIELD_EFFECTS: Record<string, FieldEffect> = {
|
|
|
213
231
|
alias: 'applied',
|
|
214
232
|
channels: 'applied',
|
|
215
233
|
portForward: 'restart-required',
|
|
234
|
+
network: 'restart-required',
|
|
216
235
|
dockerfile: 'restart-required',
|
|
217
236
|
gitignore: 'restart-required',
|
|
218
237
|
}
|
|
@@ -276,6 +295,7 @@ export function extractPluginConfigs(raw: unknown): Record<string, unknown> {
|
|
|
276
295
|
'plugins',
|
|
277
296
|
'channels',
|
|
278
297
|
'portForward',
|
|
298
|
+
'network',
|
|
279
299
|
'dockerfile',
|
|
280
300
|
'gitignore',
|
|
281
301
|
])
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Per-source line tinting for `typeclaw logs` and `typeclaw compose logs`.
|
|
2
|
+
// PR #146's wall-clock timestamp prefix made every line start identically,
|
|
3
|
+
// dropping readability when many sources interleave. We restore visual
|
|
4
|
+
// grouping by tinting each line based on its first `[tag]` (`[plugin:memory]`,
|
|
5
|
+
// `[memory-logger]`, etc.). Same trick `compose/logs.ts#colorFor` uses for
|
|
6
|
+
// agent names — stable hash → palette index → ANSI escape that wraps the
|
|
7
|
+
// whole line body.
|
|
8
|
+
//
|
|
9
|
+
// Lines without a `[tag]` get no tint (only the leading timestamp is dimmed),
|
|
10
|
+
// so untagged docker output, raw stack traces, and channel adapter logs
|
|
11
|
+
// stay readable without inheriting a misleading color.
|
|
12
|
+
|
|
13
|
+
import type { WritableStream as NodeWritable } from 'node:stream/web'
|
|
14
|
+
|
|
15
|
+
const ANSI_RESET = '\x1b[0m'
|
|
16
|
+
const ANSI_DIM = '\x1b[2m'
|
|
17
|
+
const ANSI_CYAN = '\x1b[36m'
|
|
18
|
+
const ANSI_YELLOW = '\x1b[33m'
|
|
19
|
+
const ANSI_GREEN = '\x1b[32m'
|
|
20
|
+
const ANSI_MAGENTA = '\x1b[35m'
|
|
21
|
+
const ANSI_BLUE = '\x1b[34m'
|
|
22
|
+
const ANSI_BRIGHT_CYAN = '\x1b[96m'
|
|
23
|
+
const ANSI_BRIGHT_YELLOW = '\x1b[93m'
|
|
24
|
+
const ANSI_BRIGHT_GREEN = '\x1b[92m'
|
|
25
|
+
const ANSI_BRIGHT_MAGENTA = '\x1b[95m'
|
|
26
|
+
const ANSI_BRIGHT_BLUE = '\x1b[94m'
|
|
27
|
+
|
|
28
|
+
const TAG_PALETTE = [
|
|
29
|
+
ANSI_CYAN,
|
|
30
|
+
ANSI_YELLOW,
|
|
31
|
+
ANSI_GREEN,
|
|
32
|
+
ANSI_MAGENTA,
|
|
33
|
+
ANSI_BLUE,
|
|
34
|
+
ANSI_BRIGHT_CYAN,
|
|
35
|
+
ANSI_BRIGHT_YELLOW,
|
|
36
|
+
ANSI_BRIGHT_GREEN,
|
|
37
|
+
ANSI_BRIGHT_MAGENTA,
|
|
38
|
+
ANSI_BRIGHT_BLUE,
|
|
39
|
+
] as const
|
|
40
|
+
|
|
41
|
+
// Anchored to line start with a trailing space so a date that happens to
|
|
42
|
+
// appear mid-line doesn't get dimmed.
|
|
43
|
+
const TIMESTAMP_RE = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})(?= )/
|
|
44
|
+
|
|
45
|
+
const TAG_RE = /\[([^\]\n]+)\]/
|
|
46
|
+
|
|
47
|
+
// `useColor=false` returns the input verbatim; the contract that protects
|
|
48
|
+
// pipes, file redirects, NO_COLOR, and tests from ANSI leakage.
|
|
49
|
+
export function colorize(line: string, useColor: boolean): string {
|
|
50
|
+
if (!useColor || line.length === 0) return line
|
|
51
|
+
|
|
52
|
+
const ts = TIMESTAMP_RE.exec(line)
|
|
53
|
+
const timestampLen = ts ? ts[0].length : 0
|
|
54
|
+
const dimmedTimestamp = ts ? `${ANSI_DIM}${ts[0]}${ANSI_RESET}` : ''
|
|
55
|
+
const rest = line.slice(timestampLen)
|
|
56
|
+
|
|
57
|
+
const tag = TAG_RE.exec(rest)
|
|
58
|
+
if (!tag) return `${dimmedTimestamp}${rest}`
|
|
59
|
+
|
|
60
|
+
const tint = paletteColor(tag[1] ?? '')
|
|
61
|
+
return `${dimmedTimestamp}${tint}${rest}${ANSI_RESET}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function paletteColor(seed: string): string {
|
|
65
|
+
let h = 0
|
|
66
|
+
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0
|
|
67
|
+
return TAG_PALETTE[h % TAG_PALETTE.length] ?? TAG_PALETTE[0]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function supportsColor(stream: NodeJS.WritableStream | NodeWritable): boolean {
|
|
71
|
+
const tty = (stream as unknown as { isTTY?: boolean }).isTTY === true
|
|
72
|
+
if (!tty) return false
|
|
73
|
+
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Docker emits each `--timestamps` line as `<RFC3339Nano> <body>\n`, e.g.
|
|
2
|
+
// `2026-05-13T14:23:01.123456789Z hello world\n`. RFC3339Nano is precise but
|
|
3
|
+
// painful to read live; humans want a wall-clock prefix. This module parses
|
|
4
|
+
// the leading token, reformats it to `YYYY-MM-DD HH:MM:SS` in the host's
|
|
5
|
+
// local timezone, and passes everything after the first space through.
|
|
6
|
+
//
|
|
7
|
+
// Stateful chunker mirroring src/compose/logs.ts#makeLinePrefixer: only emits
|
|
8
|
+
// newline-terminated lines and flushes the un-terminated tail on EOF, so
|
|
9
|
+
// interleaved reads from `docker logs` can never shred a line mid-character.
|
|
10
|
+
|
|
11
|
+
import { colorize } from './log-colors'
|
|
12
|
+
|
|
13
|
+
// `2026-05-13T14:23:01.123456789Z` or `...+09:00` etc. We accept anything
|
|
14
|
+
// from Docker that Date can parse, but anchor on the ISO date+time prefix to
|
|
15
|
+
// avoid eating non-timestamped log content.
|
|
16
|
+
const TIMESTAMP_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})) (.*)$/
|
|
17
|
+
|
|
18
|
+
export type TimestampReformatter = {
|
|
19
|
+
write: (chunk: string) => string
|
|
20
|
+
flush: () => string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ReformatterOptions = {
|
|
24
|
+
// Color is opt-in and applied per emitted line *after* timestamp rewriting.
|
|
25
|
+
// Defaults to `false` so tests stay deterministic and pipes/files stay
|
|
26
|
+
// ANSI-free unless callers explicitly opt in via supportsColor(out).
|
|
27
|
+
color?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function makeLogTimestampReformatter(
|
|
31
|
+
now: () => Date = () => new Date(),
|
|
32
|
+
options: ReformatterOptions = {},
|
|
33
|
+
): TimestampReformatter {
|
|
34
|
+
const useColor = options.color ?? false
|
|
35
|
+
let buffer = ''
|
|
36
|
+
return {
|
|
37
|
+
write(chunk: string): string {
|
|
38
|
+
buffer += chunk
|
|
39
|
+
const nl = buffer.lastIndexOf('\n')
|
|
40
|
+
if (nl < 0) return ''
|
|
41
|
+
const complete = buffer.slice(0, nl + 1)
|
|
42
|
+
buffer = buffer.slice(nl + 1)
|
|
43
|
+
return complete
|
|
44
|
+
.split('\n')
|
|
45
|
+
.slice(0, -1)
|
|
46
|
+
.map((line) => `${colorize(reformatLine(line, now), useColor)}\n`)
|
|
47
|
+
.join('')
|
|
48
|
+
},
|
|
49
|
+
flush(): string {
|
|
50
|
+
if (buffer.length === 0) return ''
|
|
51
|
+
const out = `${colorize(reformatLine(buffer, now), useColor)}\n`
|
|
52
|
+
buffer = ''
|
|
53
|
+
return out
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Exported for tests. Format: `YYYY-MM-DD HH:MM:SS <rest>`. Falls back to the
|
|
59
|
+
// raw line if it doesn't look like a Docker `--timestamps` line, and falls
|
|
60
|
+
// back to `now()` if Docker's timestamp doesn't parse (defensive — shouldn't
|
|
61
|
+
// happen, but losing one timestamp shouldn't hide the log body).
|
|
62
|
+
export function reformatLine(line: string, now: () => Date = () => new Date()): string {
|
|
63
|
+
const match = TIMESTAMP_RE.exec(line)
|
|
64
|
+
if (!match) return line
|
|
65
|
+
const [, raw, body] = match
|
|
66
|
+
if (raw === undefined || body === undefined) return line
|
|
67
|
+
const parsed = new Date(raw)
|
|
68
|
+
const stamp = formatLocal(Number.isNaN(parsed.getTime()) ? now() : parsed)
|
|
69
|
+
return `${stamp} ${body}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatLocal(d: Date): string {
|
|
73
|
+
const year = d.getFullYear()
|
|
74
|
+
const month = pad2(d.getMonth() + 1)
|
|
75
|
+
const day = pad2(d.getDate())
|
|
76
|
+
const hour = pad2(d.getHours())
|
|
77
|
+
const minute = pad2(d.getMinutes())
|
|
78
|
+
const second = pad2(d.getSeconds())
|
|
79
|
+
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function pad2(n: number): string {
|
|
83
|
+
return n < 10 ? `0${n}` : String(n)
|
|
84
|
+
}
|
package/src/container/logs.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { supportsColor } from './log-colors'
|
|
2
|
+
import { makeLogTimestampReformatter, type TimestampReformatter } from './log-timestamps'
|
|
1
3
|
import { containerExists, containerNameFromCwd, getBun } from './shared'
|
|
2
4
|
|
|
3
5
|
export type LogsPlan = {
|
|
@@ -7,7 +9,25 @@ export type LogsPlan = {
|
|
|
7
9
|
|
|
8
10
|
export type LogsResult = { ok: true; containerName: string; exitCode: number } | { ok: false; reason: string }
|
|
9
11
|
|
|
10
|
-
export
|
|
12
|
+
export type LogsOptions = {
|
|
13
|
+
cwd: string
|
|
14
|
+
follow: boolean
|
|
15
|
+
out?: NodeJS.WritableStream
|
|
16
|
+
err?: NodeJS.WritableStream
|
|
17
|
+
signal?: AbortSignal
|
|
18
|
+
// When undefined, defaults to TTY+NO_COLOR detection on `out`/`err`.
|
|
19
|
+
// Tests pass `false` for deterministic plain output.
|
|
20
|
+
useColor?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function logs({
|
|
24
|
+
cwd,
|
|
25
|
+
follow,
|
|
26
|
+
out = process.stdout,
|
|
27
|
+
err = process.stderr,
|
|
28
|
+
signal,
|
|
29
|
+
useColor,
|
|
30
|
+
}: LogsOptions): Promise<LogsResult> {
|
|
11
31
|
const bun = getBun()
|
|
12
32
|
if (!bun) return { ok: false, reason: 'bun runtime not available' }
|
|
13
33
|
|
|
@@ -18,14 +38,30 @@ export async function logs({ cwd, follow }: { cwd: string; follow: boolean }): P
|
|
|
18
38
|
return { ok: false, reason: `Container ${containerName} not found. Run \`typeclaw start\` first.` }
|
|
19
39
|
}
|
|
20
40
|
|
|
21
|
-
const cmd = ['docker', 'logs']
|
|
41
|
+
const cmd = ['docker', 'logs', '--timestamps']
|
|
22
42
|
if (follow) cmd.push('-f')
|
|
23
43
|
cmd.push(containerName)
|
|
24
44
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
45
|
+
const proc = bun.spawn({ cmd, cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
46
|
+
|
|
47
|
+
const onAbort = (): void => {
|
|
48
|
+
try {
|
|
49
|
+
proc.kill('SIGTERM')
|
|
50
|
+
} catch {
|
|
51
|
+
// already exited
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
55
|
+
|
|
56
|
+
const colorOut = useColor ?? supportsColor(out)
|
|
57
|
+
const colorErr = useColor ?? supportsColor(err)
|
|
58
|
+
await Promise.all([
|
|
59
|
+
pumpWithTimestamps(proc.stdout, out, makeLogTimestampReformatter(undefined, { color: colorOut })),
|
|
60
|
+
pumpWithTimestamps(proc.stderr, err, makeLogTimestampReformatter(undefined, { color: colorErr })),
|
|
61
|
+
])
|
|
28
62
|
const exitCode = await proc.exited
|
|
63
|
+
signal?.removeEventListener('abort', onAbort)
|
|
64
|
+
|
|
29
65
|
return { ok: true, containerName, exitCode }
|
|
30
66
|
} catch (error) {
|
|
31
67
|
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
@@ -35,3 +71,33 @@ export async function logs({ cwd, follow }: { cwd: string; follow: boolean }): P
|
|
|
35
71
|
export function planLogs(cwd: string, { follow }: { follow: boolean }): LogsPlan {
|
|
36
72
|
return { containerName: containerNameFromCwd(cwd), follow }
|
|
37
73
|
}
|
|
74
|
+
|
|
75
|
+
// Exported for `compose/logs.ts` so the multi-agent path reuses the same
|
|
76
|
+
// reformatter and stays consistent with single-agent output.
|
|
77
|
+
export async function pumpWithTimestamps(
|
|
78
|
+
stream: ReadableStream<Uint8Array>,
|
|
79
|
+
sink: NodeJS.WritableStream,
|
|
80
|
+
reformatter: TimestampReformatter = makeLogTimestampReformatter(),
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
const decoder = new TextDecoder()
|
|
83
|
+
const reader = stream.getReader()
|
|
84
|
+
try {
|
|
85
|
+
while (true) {
|
|
86
|
+
const { done, value } = await reader.read()
|
|
87
|
+
if (done) break
|
|
88
|
+
if (value && value.byteLength > 0) {
|
|
89
|
+
const out = reformatter.write(decoder.decode(value, { stream: true }))
|
|
90
|
+
if (out.length > 0) sink.write(out)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const tail = decoder.decode()
|
|
94
|
+
if (tail.length > 0) {
|
|
95
|
+
const out = reformatter.write(tail)
|
|
96
|
+
if (out.length > 0) sink.write(out)
|
|
97
|
+
}
|
|
98
|
+
const flushed = reformatter.flush()
|
|
99
|
+
if (flushed.length > 0) sink.write(flushed)
|
|
100
|
+
} finally {
|
|
101
|
+
reader.releaseLock()
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/container/start.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { configSchema, expandMountPath, type Config } from '@/config/config'
|
|
|
7
7
|
import { send as sendToDaemon } from '@/hostd/client'
|
|
8
8
|
import type { HttpInfoResult } from '@/hostd/protocol'
|
|
9
9
|
import { ensureDaemon } from '@/hostd/spawn'
|
|
10
|
+
import { resolveBaseImageVersion } from '@/init/cli-version'
|
|
10
11
|
import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
|
|
11
12
|
import { ensureDepsInstalled, type EnsureDepsResult } from '@/init/ensure-deps'
|
|
12
13
|
import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
|
|
@@ -142,7 +143,6 @@ export async function start({
|
|
|
142
143
|
// one-shot and idempotent — once `workspaces` is set, refreshPackageJson
|
|
143
144
|
// is a no-op, so users who never edit their agent folder pay zero cost on
|
|
144
145
|
// subsequent starts and users who customized `workspaces` are not clobbered.
|
|
145
|
-
await refreshDockerfile(cwd)
|
|
146
146
|
await refreshGitignore(cwd)
|
|
147
147
|
const pkgRefresh = await refreshPackageJson(cwd)
|
|
148
148
|
await commitSystemFile(cwd, GITIGNORE_FILE, 'Update .gitignore')
|
|
@@ -161,6 +161,11 @@ export async function start({
|
|
|
161
161
|
return { ok: false, reason: `dependency install failed: ${deps.reason}` }
|
|
162
162
|
}
|
|
163
163
|
await commitSystemFile(cwd, DEPENDENCY_FILES, 'Update dependencies')
|
|
164
|
+
// Dockerfile refresh AFTER ensureDeps so the version pin in the FROM
|
|
165
|
+
// line resolves against the agent's installed node_modules/typeclaw —
|
|
166
|
+
// ensures the base image's CLI version matches the runtime the
|
|
167
|
+
// container will actually load.
|
|
168
|
+
await refreshDockerfile(cwd)
|
|
164
169
|
|
|
165
170
|
if (state.exists) {
|
|
166
171
|
// Container holds the name but is not running. Without `--rm`, this is
|
|
@@ -315,7 +320,8 @@ export async function planStart({
|
|
|
315
320
|
const imageTag = imageTagFromCwd(cwd)
|
|
316
321
|
|
|
317
322
|
const devSourcePath = await detectDevSource(cwd)
|
|
318
|
-
const
|
|
323
|
+
const cfg = await loadTypeclawConfig(cwd)
|
|
324
|
+
const mounts = cfg.mounts
|
|
319
325
|
|
|
320
326
|
// No `--rm`: a crashed container's logs MUST survive past exit so users can
|
|
321
327
|
// debug the failure. `typeclaw stop` removes the container explicitly, and
|
|
@@ -324,6 +330,17 @@ export async function planStart({
|
|
|
324
330
|
// a running container or one the user has not started again yet.
|
|
325
331
|
const runArgs = ['run', '-d', '--name', containerName, '-p', `127.0.0.1:${hostPort}:${CONTAINER_PORT}`]
|
|
326
332
|
|
|
333
|
+
// Network egress filter: when `typeclaw.json#network.blockInternal` is true,
|
|
334
|
+
// grant the container CAP_NET_ADMIN at boot so the entrypoint shim can
|
|
335
|
+
// install iptables OUTPUT rules. The shim drops the capability from the
|
|
336
|
+
// bounding set via setpriv before exec'ing the agent — see the shim source
|
|
337
|
+
// in src/init/dockerfile.ts for the full handoff. The `-e` flag is what
|
|
338
|
+
// tells the shim to take the on-path; absent or set to anything other than
|
|
339
|
+
// "1", the shim is a no-op.
|
|
340
|
+
if (cfg.network.blockInternal) {
|
|
341
|
+
runArgs.push('--cap-add=NET_ADMIN', '-e', 'TYPECLAW_NETWORK_BLOCK_INTERNAL=1')
|
|
342
|
+
}
|
|
343
|
+
|
|
327
344
|
if (hostdControl) {
|
|
328
345
|
runArgs.push('--add-host', HOST_GATEWAY_ALIAS)
|
|
329
346
|
}
|
|
@@ -386,7 +403,10 @@ export async function planStart({
|
|
|
386
403
|
|
|
387
404
|
export async function refreshDockerfile(cwd: string): Promise<void> {
|
|
388
405
|
const cfg = await loadTypeclawConfig(cwd)
|
|
389
|
-
await writeFile(
|
|
406
|
+
await writeFile(
|
|
407
|
+
join(cwd, DOCKERFILE),
|
|
408
|
+
buildDockerfile(cfg.dockerfile, { baseImageVersion: resolveBaseImageVersion(cwd) }),
|
|
409
|
+
)
|
|
390
410
|
}
|
|
391
411
|
|
|
392
412
|
export async function refreshGitignore(cwd: string): Promise<void> {
|
|
@@ -576,11 +596,6 @@ async function detectDevSource(cwd: string): Promise<string | null> {
|
|
|
576
596
|
// folder mid-init). Anything else — malformed JSON, schema-invalid config,
|
|
577
597
|
// invalid mount entry — must surface so the user sees they configured a mount
|
|
578
598
|
// that won't be applied.
|
|
579
|
-
async function loadMounts(cwd: string): Promise<Config['mounts']> {
|
|
580
|
-
const cfg = await loadTypeclawConfig(cwd)
|
|
581
|
-
return cfg.mounts
|
|
582
|
-
}
|
|
583
|
-
|
|
584
599
|
async function loadTypeclawConfig(cwd: string): Promise<Config> {
|
|
585
600
|
return configSchema.parse(await loadConfigJson(cwd))
|
|
586
601
|
}
|
package/src/cron/consumer.ts
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
|
+
import type { SessionOrigin } from '@/agent/session-origin'
|
|
1
2
|
import type { HookBus } from '@/plugin'
|
|
2
3
|
import type { Stream, Unsubscribe } from '@/stream'
|
|
3
4
|
|
|
4
5
|
import type { CronJob, ExecJob, PromptJob } from './schema'
|
|
5
6
|
|
|
6
|
-
// `hooks`, `sessionId`, and `getTranscriptPath` are optional so
|
|
7
|
-
// stay one-liners. When present, the consumer fires
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// the
|
|
11
|
-
//
|
|
7
|
+
// `hooks`, `sessionId`, `agentDir`, and `getTranscriptPath` are optional so
|
|
8
|
+
// test fakes can stay one-liners. When present, the consumer fires
|
|
9
|
+
// `session.turn.start`/`session.turn.end` around `prompt()`, then
|
|
10
|
+
// `session.idle` after, then `session.end` on dispose — mirroring the
|
|
11
|
+
// lifecycle signals the TUI server emits in `src/server/index.ts`. Without
|
|
12
|
+
// this the bundled memory plugin's debounced `memory-logger` never spawns for
|
|
13
|
+
// cron prompt jobs (it only wakes on `session.idle`), and the bundled backup
|
|
14
|
+
// plugin's turn counter would miss cron-driven activity.
|
|
12
15
|
export type CronSession = {
|
|
13
16
|
prompt: (text: string) => Promise<void>
|
|
14
17
|
dispose?: () => void
|
|
15
18
|
hooks?: HookBus
|
|
16
19
|
sessionId?: string
|
|
20
|
+
agentDir?: string
|
|
17
21
|
getTranscriptPath?: () => string | undefined
|
|
22
|
+
origin?: SessionOrigin
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
export type CronConsumerLogger = {
|
|
@@ -102,8 +107,25 @@ async function runPrompt(
|
|
|
102
107
|
return
|
|
103
108
|
}
|
|
104
109
|
const session = await createSessionForCron(job)
|
|
110
|
+
const turnEvent =
|
|
111
|
+
session.hooks && session.sessionId !== undefined && session.agentDir !== undefined
|
|
112
|
+
? {
|
|
113
|
+
sessionId: session.sessionId,
|
|
114
|
+
agentDir: session.agentDir,
|
|
115
|
+
...(session.origin !== undefined ? { origin: session.origin } : {}),
|
|
116
|
+
}
|
|
117
|
+
: undefined
|
|
105
118
|
try {
|
|
106
|
-
|
|
119
|
+
if (session.hooks && turnEvent !== undefined) {
|
|
120
|
+
await session.hooks.runSessionTurnStart(turnEvent)
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
await session.prompt(job.prompt)
|
|
124
|
+
} finally {
|
|
125
|
+
if (session.hooks && turnEvent !== undefined) {
|
|
126
|
+
await session.hooks.runSessionTurnEnd(turnEvent)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
107
129
|
if (session.hooks && session.sessionId !== undefined) {
|
|
108
130
|
await session.hooks.runSessionIdle({
|
|
109
131
|
sessionId: session.sessionId,
|