typeclaw 0.36.2 → 0.36.3
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/package.json +2 -2
- package/src/agent/plugin-tools.ts +43 -21
- package/src/agent/system-prompt.ts +6 -0
- package/src/channels/adapters/line-attachment.ts +97 -0
- package/src/channels/adapters/line-classify.ts +14 -3
- package/src/channels/adapters/line.ts +5 -1
- package/src/cli/reload.ts +26 -5
- package/src/container/index.ts +1 -0
- package/src/reload/client.ts +14 -3
- package/src/reload/docker-exec-client.ts +109 -0
- package/src/reload/index.ts +7 -1
- package/src/reload/recover.ts +38 -0
- package/src/sandbox/availability.ts +58 -15
- package/src/sandbox/errors.ts +26 -0
- package/src/sandbox/index.ts +6 -1
- package/src/skills/typeclaw-config/SKILL.md +2 -2
- package/src/skills/typeclaw-monorepo/SKILL.md +7 -5
- package/src/skills/typeclaw-plugins/SKILL.md +11 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typeclaw",
|
|
3
|
-
"version": "0.36.
|
|
3
|
+
"version": "0.36.3",
|
|
4
4
|
"homepage": "https://github.com/typeclaw/typeclaw#readme",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/typeclaw/typeclaw/issues"
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@mariozechner/pi-tui": "^0.67.3",
|
|
49
49
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
50
50
|
"@mozilla/readability": "^0.6.0",
|
|
51
|
-
"agent-messenger": "2.19.
|
|
51
|
+
"agent-messenger": "2.19.3",
|
|
52
52
|
"cheerio": "^1.2.0",
|
|
53
53
|
"citty": "^0.2.2",
|
|
54
54
|
"cron-parser": "^5.5.0",
|
|
@@ -53,7 +53,7 @@ import {
|
|
|
53
53
|
resolveSandboxSymlinks,
|
|
54
54
|
resolveWritableZones,
|
|
55
55
|
SandboxDegradedProcError,
|
|
56
|
-
|
|
56
|
+
SandboxProcProbeUnverifiedError,
|
|
57
57
|
subtractMasked,
|
|
58
58
|
} from '@/sandbox'
|
|
59
59
|
|
|
@@ -644,15 +644,19 @@ async function applyBashSandbox(
|
|
|
644
644
|
// bwrap does --clearenv, so the overlay must be re-introduced via env.set or
|
|
645
645
|
// it would never reach the sandboxed process (the non-sandboxed spawnHook
|
|
646
646
|
// path does not run when the command is rewritten to a bwrap invocation).
|
|
647
|
-
const proc = await resolveProcStrategy()
|
|
647
|
+
const { strategy: proc, degradeReason } = await resolveProcStrategy()
|
|
648
648
|
// Fail fast with an actionable error when /proc degraded to tmpfs AND the
|
|
649
649
|
// command needs a real /proc: under tmpfs Bun would otherwise abort deep in its
|
|
650
|
-
// pipeline with the opaque "NotDir", which the model retries forever.
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
650
|
+
// pipeline with the opaque "NotDir", which the model retries forever. Which
|
|
651
|
+
// error depends on WHY it degraded: a 'definitive' degrade (a real leak / an
|
|
652
|
+
// incapable host) is permanent → SandboxDegradedProcError ("retrying won't
|
|
653
|
+
// help"); an 'unverified' degrade (the safety probe stayed inconclusive through
|
|
654
|
+
// its retry budget, e.g. a boot-time load spike) is transient and re-probes on
|
|
655
|
+
// the next call → SandboxProcProbeUnverifiedError ("retry the same command").
|
|
656
|
+
// Guarded on the command so non-bun bash still runs in the degraded mode (it
|
|
657
|
+
// does not touch /proc/self/{fd,maps}).
|
|
654
658
|
if (proc === 'tmpfs' && commandNeedsRealProc(command)) {
|
|
655
|
-
throw new SandboxDegradedProcError()
|
|
659
|
+
throw degradeReason === 'unverified' ? new SandboxProcProbeUnverifiedError() : new SandboxDegradedProcError()
|
|
656
660
|
}
|
|
657
661
|
const { commandString } = buildSandboxedCommand(command, {
|
|
658
662
|
mounts: [
|
|
@@ -698,26 +702,44 @@ function subtractMaskedProtected(
|
|
|
698
702
|
// --mount-proc` in a container booted WITHOUT the cap (or vice versa). Both
|
|
699
703
|
// probes are cached process-globally, so this resolves to one spawn per
|
|
700
704
|
// container lifetime regardless of how many bash calls hit it.
|
|
701
|
-
|
|
702
|
-
|
|
705
|
+
// A tmpfs degrade carries WHY it happened so the caller can pick a permanent vs
|
|
706
|
+
// retryable error. 'definitive': the probe returned a real cross-userns leak
|
|
707
|
+
// ('unsafe') — the ONLY verdict proven permanent, so it fails closed for good.
|
|
708
|
+
// 'unverified': the safety probe never reached a definitive verdict within its
|
|
709
|
+
// retry budget. That covers BOTH a transient load spike AND a durable
|
|
710
|
+
// incapability (no usable namespaces, a bwrap that starts but cannot set up its
|
|
711
|
+
// sandbox): the probe cannot prove a NEGATIVE capability — only a leak is
|
|
712
|
+
// definitive — so a genuinely incapable host also lands here and simply keeps
|
|
713
|
+
// re-degrading on each call. Since 'inconclusive' is never cached, that costs a
|
|
714
|
+
// re-probe but is correct: the only false case is "capable but briefly
|
|
715
|
+
// saturated", which recovers; an incapable host stays degraded either way.
|
|
716
|
+
// Absent when the strategy is not tmpfs.
|
|
717
|
+
type ProcStrategyResolution =
|
|
718
|
+
| { strategy: 'real-proc' | 'proc-bind'; degradeReason?: undefined }
|
|
719
|
+
| { strategy: 'tmpfs'; degradeReason: 'definitive' | 'unverified' }
|
|
720
|
+
|
|
721
|
+
async function resolveProcStrategy(): Promise<ProcStrategyResolution> {
|
|
722
|
+
if (config.sandbox.realProc && (await canMountRealProc())) return { strategy: 'real-proc' }
|
|
703
723
|
// Retry an 'inconclusive' proc-bind probe (transient under load) before
|
|
704
724
|
// degrading — a single such hiccup must not break external-package runs on a
|
|
705
725
|
// capable host. 'unsafe' still fails closed with no retry.
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
(ms) => Bun.sleep(ms),
|
|
710
|
-
)
|
|
726
|
+
const verdict = await resolveProcBindSafetyWithRetry(
|
|
727
|
+
() => getProcBindSafetyVerdict(),
|
|
728
|
+
(ms) => Bun.sleep(ms),
|
|
711
729
|
)
|
|
712
|
-
|
|
730
|
+
if (verdict === 'safe') return { strategy: 'proc-bind' }
|
|
713
731
|
// Degraded last resort: no working /proc strategy. External package runners
|
|
714
732
|
// (bunx/bun add/bun run <pkg-bin>) will fail with Bun's opaque "NotDir" because
|
|
715
|
-
// /proc/self/{fd,maps} are absent.
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
|
|
720
|
-
|
|
733
|
+
// /proc/self/{fd,maps} are absent. Only a proven 'unsafe' (a real cross-userns
|
|
734
|
+
// leak) is DEFINITIVE — warn once (a real operator-facing limit). An
|
|
735
|
+
// 'inconclusive' is reported as retryable upstream and NOT warned (it would cry
|
|
736
|
+
// wolf every boot storm); a durably-incapable host re-degrades quietly here,
|
|
737
|
+
// since the probe cannot distinguish it from transient load.
|
|
738
|
+
if (verdict === 'unsafe') {
|
|
739
|
+
warnTmpfsProcFallbackOnce()
|
|
740
|
+
return { strategy: 'tmpfs', degradeReason: 'definitive' }
|
|
741
|
+
}
|
|
742
|
+
return { strategy: 'tmpfs', degradeReason: 'unverified' }
|
|
721
743
|
}
|
|
722
744
|
|
|
723
745
|
let tmpfsProcFallbackWarned = false
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { formatLocalDateTime, formatLocalWeekday, resolveLocalTimezoneName } from '@/shared'
|
|
2
2
|
|
|
3
|
+
const PACKAGE_JSON_INSTALL_RULE =
|
|
4
|
+
"After editing `package.json` (adding, removing, or bumping dependencies/plugins), run the project's package manager to update the lockfile and installed dependency state — e.g. `bun install`, `npm install`, `pnpm install`, or `yarn install`, matching the existing lockfile. Commit the lockfile change alongside the `package.json` edit."
|
|
5
|
+
|
|
3
6
|
// The orchestration roster (the `Briefly: ...` enumeration of public subagents)
|
|
4
7
|
// is GENERATED from the registry by `renderPublicSubagentRoster` and threaded in
|
|
5
8
|
// here, so a newly-registered public subagent can never be silently missing from
|
|
@@ -83,6 +86,7 @@ Your agent folder is a git repository.
|
|
|
83
86
|
- Commit any files you created, edited, or deleted before declaring a task done. One logical change = one commit; split unrelated changes.
|
|
84
87
|
- Use \`git add <paths>\` (not \`git add -A\`). Imperative commit messages ("Update SOUL.md to be less formal"); explain *why* in the body if non-obvious.
|
|
85
88
|
- Never commit \`secrets.json\`, \`.env\`, or anything under \`workspace/\` — truly-ignored by design. \`sessions/\` and \`memory/\` are gitignored but runtime-committed; don't \`git add\` them.
|
|
89
|
+
- ${PACKAGE_JSON_INSTALL_RULE}
|
|
86
90
|
- Never \`git push\`, \`git reset --hard\`, \`git rebase\`, or rewrite remote history unless the user explicitly asks.
|
|
87
91
|
|
|
88
92
|
## How to behave
|
|
@@ -251,6 +255,8 @@ Never suppress errors to make things "work", and never fabricate results. If som
|
|
|
251
255
|
|
|
252
256
|
Do not narrate routine, low-risk tool calls — just call the tool. Do not over-explain what you did unless asked.
|
|
253
257
|
|
|
258
|
+
${PACKAGE_JSON_INSTALL_RULE}
|
|
259
|
+
|
|
254
260
|
Your free-write zone is \`workspace/\`. Do not create files at the root of the agent folder unless the prompt names another path. \`public/\` is the guest-visible zone — write there anything meant to be shared with an untrusted caller (a \`guest\`-role turn cannot read \`workspace/\` but can read \`public/\`). Do not edit \`memory/topics/\` directly — the dreaming subagent owns it; to capture something memorable, surface it in your reply or let the memory-logger append to \`memory/streams/\`. Never stage or commit \`secrets.json\`, \`.env\`, \`sessions/\`, \`memory/\`, or \`workspace/\` — those are runtime- or user-managed.
|
|
255
261
|
|
|
256
262
|
See the session-origin block below for what kind of session this is and what's expected of you.`
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { LinePushMessageEvent } from 'agent-messenger/line'
|
|
2
|
+
|
|
3
|
+
import type { InboundAttachment } from '@/channels/types'
|
|
4
|
+
|
|
5
|
+
// Splits an inbound LINE event into (text, attachments[]). Text is what the
|
|
6
|
+
// agent sees in its prompt; attachments[] carries the in-turn id + kind the
|
|
7
|
+
// router uses to resolve `channel_fetch_attachment` / `look_at` by id.
|
|
8
|
+
//
|
|
9
|
+
// LINE differs from KakaoTalk in one load-bearing way: the upstream SDK
|
|
10
|
+
// (`agent-messenger/line`) currently forwards only `content_type` on the push
|
|
11
|
+
// event, NOT `contentMetadata`. So unlike the KakaoTalk splitter, this one has
|
|
12
|
+
// no sticker id / file name / media URL to surface — every attachment is
|
|
13
|
+
// REF-FREE (empty `ref`, no fetchable handle). The placeholder is therefore
|
|
14
|
+
// coarse on purpose (`[LINE sticker]`, `[LINE image]`). When the SDK starts
|
|
15
|
+
// forwarding metadata (agent-messenger#214), enrich this file only; the
|
|
16
|
+
// adapter / classifier contract does not change.
|
|
17
|
+
//
|
|
18
|
+
// Keeping the ref out of the prompt text is the same invariant the KakaoTalk
|
|
19
|
+
// splitter documents: there is exactly ONE way to fetch an attachment — by its
|
|
20
|
+
// in-turn id — so a hallucinated/malformed ref can never reach a tool.
|
|
21
|
+
|
|
22
|
+
export type SplitInboundLine = {
|
|
23
|
+
text: string
|
|
24
|
+
attachments: InboundAttachment[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// LINE thrift ContentType. The SDK stringifies `msg.raw.contentType`, which the
|
|
28
|
+
// thrift layer usually renders as the symbolic name, but the wire enum is
|
|
29
|
+
// numeric (see @evex/linejs-types ContentType). Normalize defends against both
|
|
30
|
+
// forms so a numeric leak ("7") still maps to STICKER rather than falling
|
|
31
|
+
// through to the unknown bucket.
|
|
32
|
+
const NUMERIC_CONTENT_TYPE: Record<string, string> = {
|
|
33
|
+
'0': 'NONE',
|
|
34
|
+
'1': 'IMAGE',
|
|
35
|
+
'2': 'VIDEO',
|
|
36
|
+
'3': 'AUDIO',
|
|
37
|
+
'7': 'STICKER',
|
|
38
|
+
'13': 'CONTACT',
|
|
39
|
+
'14': 'FILE',
|
|
40
|
+
'15': 'LOCATION',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Non-text content types that map cleanly onto the fixed InboundAttachment.kind
|
|
44
|
+
// union. Types with no clean mapping (CONTACT, LOCATION, and anything unknown)
|
|
45
|
+
// route as placeholder-only text — an attachment with an empty ref and an
|
|
46
|
+
// invented kind would offer the agent an unusable handle, so we don't make one.
|
|
47
|
+
const CONTENT_TYPE_TO_KIND: Record<string, InboundAttachment['kind']> = {
|
|
48
|
+
STICKER: 'sticker',
|
|
49
|
+
IMAGE: 'photo',
|
|
50
|
+
VIDEO: 'video',
|
|
51
|
+
AUDIO: 'audio',
|
|
52
|
+
FILE: 'file',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const PLACEHOLDER_ONLY_LABEL: Record<string, string> = {
|
|
56
|
+
CONTACT: 'contact',
|
|
57
|
+
LOCATION: 'location',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function normalizeLineContentType(raw: string | null | undefined): string {
|
|
61
|
+
if (raw === null || raw === undefined) return 'NONE'
|
|
62
|
+
const trimmed = raw.trim()
|
|
63
|
+
if (trimmed === '') return 'NONE'
|
|
64
|
+
const numeric = NUMERIC_CONTENT_TYPE[trimmed]
|
|
65
|
+
if (numeric !== undefined) return numeric
|
|
66
|
+
const upper = trimmed.toUpperCase()
|
|
67
|
+
// LINE text is `NONE` on the wire; treat the `TEXT` spelling as the same so
|
|
68
|
+
// a genuine text message never falls into the placeholder path.
|
|
69
|
+
return upper === 'TEXT' ? 'NONE' : upper
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function splitInboundLine(event: LinePushMessageEvent, startId = 1): SplitInboundLine {
|
|
73
|
+
const contentType = normalizeLineContentType(event.content_type)
|
|
74
|
+
|
|
75
|
+
// NONE is LINE text; a blank NONE message stays an `empty_text` drop in the
|
|
76
|
+
// classifier, so synthesize nothing and pass the raw text through.
|
|
77
|
+
if (contentType === 'NONE') {
|
|
78
|
+
return { text: event.text ?? '', attachments: [] }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const kind = CONTENT_TYPE_TO_KIND[contentType]
|
|
82
|
+
const rawText = event.text ?? ''
|
|
83
|
+
|
|
84
|
+
if (kind !== undefined) {
|
|
85
|
+
const id = startId
|
|
86
|
+
const placeholder = `[LINE ${kind}]`
|
|
87
|
+
const text = rawText === '' ? placeholder : `${rawText}\n${placeholder}`
|
|
88
|
+
return { text, attachments: [{ id, kind, ref: '' }] }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Placeholder-only types (contact, location, unknown/future). No attachment
|
|
92
|
+
// entry — there is nothing fetchable and no valid kind to assign.
|
|
93
|
+
const label = PLACEHOLDER_ONLY_LABEL[contentType] ?? `message: ${contentType}`
|
|
94
|
+
const placeholder = `[LINE ${label}]`
|
|
95
|
+
const text = rawText === '' ? placeholder : `${rawText}\n${placeholder}`
|
|
96
|
+
return { text, attachments: [] }
|
|
97
|
+
}
|
|
@@ -2,7 +2,7 @@ import type { LinePushMessageEvent } from 'agent-messenger/line'
|
|
|
2
2
|
|
|
3
3
|
import { matchesAnyAlias } from '@/channels/engagement'
|
|
4
4
|
import type { ChannelAdapterConfig } from '@/channels/schema'
|
|
5
|
-
import type { InboundMessage } from '@/channels/types'
|
|
5
|
+
import type { InboundAttachment, InboundMessage } from '@/channels/types'
|
|
6
6
|
|
|
7
7
|
export type InboundDropReason = 'self_author' | 'empty_text' | 'unknown_chat' | 'pre_connect'
|
|
8
8
|
|
|
@@ -22,6 +22,13 @@ export type LineInboundContext = {
|
|
|
22
22
|
// LINE push events lack `author_name`, so the adapter resolves it (best
|
|
23
23
|
// effort) and passes it here; falls back to the raw author id.
|
|
24
24
|
authorName?: string
|
|
25
|
+
// The adapter splits the raw event into prompt text + attachments (non-text
|
|
26
|
+
// content types become a placeholder string and a ref-free attachment) and
|
|
27
|
+
// passes the result here, so the classifier routes on the synthesized text
|
|
28
|
+
// rather than the raw `event.text`. Omitted for plain text inbounds, where
|
|
29
|
+
// `event.text` is authoritative.
|
|
30
|
+
text?: string
|
|
31
|
+
attachments?: readonly InboundAttachment[]
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
export function classifyInbound(
|
|
@@ -36,8 +43,11 @@ export function classifyInbound(
|
|
|
36
43
|
return { kind: 'drop', reason: 'self_author' }
|
|
37
44
|
}
|
|
38
45
|
|
|
39
|
-
const text = event.text ?? ''
|
|
40
|
-
|
|
46
|
+
const text = context.text ?? event.text ?? ''
|
|
47
|
+
const attachments = context.attachments ?? []
|
|
48
|
+
if (text === '' && attachments.length === 0) {
|
|
49
|
+
return { kind: 'drop', reason: 'empty_text' }
|
|
50
|
+
}
|
|
41
51
|
|
|
42
52
|
const chatInfo = context.lookupChat(event.chat_id)
|
|
43
53
|
if (chatInfo === null) {
|
|
@@ -65,6 +75,7 @@ export function classifyInbound(
|
|
|
65
75
|
chat: event.chat_id,
|
|
66
76
|
thread: null,
|
|
67
77
|
text,
|
|
78
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
68
79
|
externalMessageId: event.message_id,
|
|
69
80
|
authorId: event.author_id,
|
|
70
81
|
authorName,
|
|
@@ -25,6 +25,7 @@ import type {
|
|
|
25
25
|
SendResult,
|
|
26
26
|
} from '@/channels/types'
|
|
27
27
|
|
|
28
|
+
import { splitInboundLine } from './line-attachment'
|
|
28
29
|
import { createLineChannelResolver } from './line-channel-resolver'
|
|
29
30
|
import { classifyInbound } from './line-classify'
|
|
30
31
|
import { toLinePlainText } from './line-format'
|
|
@@ -217,13 +218,16 @@ export function createLineAdapter(options: LineAdapterOptions): LineAdapter {
|
|
|
217
218
|
|
|
218
219
|
const bucket = channelResolver.lookupChat(event.chat_id)?.workspace ?? '@line-group'
|
|
219
220
|
const inboundTag = await formatChannelTag(bucket, event.chat_id)
|
|
221
|
+
const { text, attachments } = splitInboundLine(event)
|
|
220
222
|
logger.info(
|
|
221
|
-
`[line] inbound message_id=${event.message_id} author=${event.author_id} ${inboundTag}
|
|
223
|
+
`[line] inbound message_id=${event.message_id} author=${event.author_id} ${inboundTag} content_type=${event.content_type} text_len=${text.length} attachments=${attachments.length}`,
|
|
222
224
|
)
|
|
223
225
|
|
|
224
226
|
const verdict = classifyInbound(event, options.configRef(), {
|
|
225
227
|
selfUserId,
|
|
226
228
|
lookupChat: (id) => channelResolver.lookupChat(id),
|
|
229
|
+
text,
|
|
230
|
+
attachments,
|
|
227
231
|
...(options.selfAliasesRef ? { selfAliases: options.selfAliasesRef() } : {}),
|
|
228
232
|
})
|
|
229
233
|
if (verdict.kind === 'drop') {
|
package/src/cli/reload.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { defineCommand } from 'citty'
|
|
|
2
2
|
|
|
3
3
|
import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
|
|
4
4
|
import { findAgentDir } from '@/init'
|
|
5
|
-
import {
|
|
5
|
+
import { requestReloadWithFallback, type ReloadResult } from '@/reload'
|
|
6
6
|
|
|
7
7
|
import { c, errorLine, spinner } from './ui'
|
|
8
8
|
|
|
@@ -24,18 +24,29 @@ export const reload = defineCommand({
|
|
|
24
24
|
},
|
|
25
25
|
},
|
|
26
26
|
async run({ args }) {
|
|
27
|
-
const
|
|
27
|
+
const timeoutMs = Number(args.timeout)
|
|
28
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
29
|
+
console.error(errorLine(`invalid --timeout value: ${args.timeout}`))
|
|
30
|
+
process.exit(1)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const target = args.url === undefined ? await defaultTarget() : { url: args.url }
|
|
28
34
|
|
|
29
35
|
const s = spinner()
|
|
30
36
|
s.start('Reloading...')
|
|
31
37
|
let results: ReloadResult[]
|
|
38
|
+
let recoveredHostError: string | undefined
|
|
32
39
|
try {
|
|
33
|
-
|
|
40
|
+
const response = await requestReloadWithFallback({ ...target, timeoutMs })
|
|
41
|
+
results = response.results
|
|
42
|
+
if (response.transport === 'container-local') recoveredHostError = response.hostError
|
|
34
43
|
} catch (err) {
|
|
35
44
|
s.error(`reload failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
36
45
|
process.exit(1)
|
|
37
46
|
}
|
|
38
47
|
|
|
48
|
+
printReloadRecoveryHint(recoveredHostError)
|
|
49
|
+
|
|
39
50
|
if (results.length === 0) {
|
|
40
51
|
s.stop(c.dim('Nothing to reload.'))
|
|
41
52
|
return
|
|
@@ -61,7 +72,17 @@ export const reload = defineCommand({
|
|
|
61
72
|
},
|
|
62
73
|
})
|
|
63
74
|
|
|
64
|
-
|
|
75
|
+
export function printReloadRecoveryHint(recoveredHostError: string | undefined): void {
|
|
76
|
+
if (recoveredHostError === undefined) return
|
|
77
|
+
console.error(
|
|
78
|
+
c.yellow(
|
|
79
|
+
`Recovered via container-local reload because Docker's published host port is not accepting WebSockets (${recoveredHostError}).`,
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
console.error(c.dim('Run `typeclaw restart --port 0` when safe to repair host TUI/reload connectivity.'))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function defaultTarget(): Promise<{ url: string; cwd: string; token: string | null }> {
|
|
65
86
|
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
66
87
|
const precheck = await requireContainerRunning({ cwd })
|
|
67
88
|
if (!precheck.ok) {
|
|
@@ -72,5 +93,5 @@ async function defaultUrl(): Promise<string> {
|
|
|
72
93
|
const token = await resolveTuiToken({ cwd })
|
|
73
94
|
const url = new URL(`ws://127.0.0.1:${port}`)
|
|
74
95
|
if (token !== null) url.searchParams.set('token', token)
|
|
75
|
-
return url.toString()
|
|
96
|
+
return { url: url.toString(), cwd, token }
|
|
76
97
|
}
|
package/src/container/index.ts
CHANGED
package/src/reload/client.ts
CHANGED
|
@@ -10,6 +10,13 @@ export type RequestReloadOptions = {
|
|
|
10
10
|
|
|
11
11
|
const DEFAULT_TIMEOUT_MS = 30_000
|
|
12
12
|
|
|
13
|
+
export class ReloadConnectionError extends Error {
|
|
14
|
+
constructor(message: string) {
|
|
15
|
+
super(message)
|
|
16
|
+
this.name = 'ReloadConnectionError'
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
13
20
|
export async function requestReload({
|
|
14
21
|
url,
|
|
15
22
|
scope,
|
|
@@ -26,11 +33,15 @@ export async function requestReload({
|
|
|
26
33
|
}
|
|
27
34
|
const onError = (err: unknown) => {
|
|
28
35
|
cleanup()
|
|
29
|
-
reject(
|
|
36
|
+
reject(
|
|
37
|
+
new ReloadConnectionError(
|
|
38
|
+
`failed to connect to ${displayUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
39
|
+
),
|
|
40
|
+
)
|
|
30
41
|
}
|
|
31
42
|
const onClose = () => {
|
|
32
43
|
cleanup()
|
|
33
|
-
reject(new
|
|
44
|
+
reject(new ReloadConnectionError(`connection to ${displayUrl} closed before opening`))
|
|
34
45
|
}
|
|
35
46
|
const cleanup = () => {
|
|
36
47
|
if (timer !== undefined) clearTimeout(timer)
|
|
@@ -41,7 +52,7 @@ export async function requestReload({
|
|
|
41
52
|
timer = setTimeout(() => {
|
|
42
53
|
cleanup()
|
|
43
54
|
ws.close()
|
|
44
|
-
reject(new
|
|
55
|
+
reject(new ReloadConnectionError(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
|
|
45
56
|
}, timeoutMs)
|
|
46
57
|
ws.addEventListener('open', onOpen, { once: true })
|
|
47
58
|
ws.addEventListener('error', onError, { once: true })
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONTAINER_PORT,
|
|
3
|
+
containerNameFromCwd,
|
|
4
|
+
defaultDockerExec,
|
|
5
|
+
sanitizeDockerStderr,
|
|
6
|
+
type DockerExec,
|
|
7
|
+
type DockerExecResult,
|
|
8
|
+
} from '@/container'
|
|
9
|
+
|
|
10
|
+
import type { ReloadResult } from './types'
|
|
11
|
+
|
|
12
|
+
export type RequestReloadViaDockerExecOptions = {
|
|
13
|
+
cwd: string
|
|
14
|
+
token: string | null
|
|
15
|
+
scope?: string
|
|
16
|
+
timeoutMs?: number
|
|
17
|
+
exec?: DockerExec
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type DockerExecReloadEnvelope = { ok: true; results: ReloadResult[] } | { ok: false; reason: string }
|
|
21
|
+
|
|
22
|
+
const DEFAULT_TIMEOUT_MS = 30_000
|
|
23
|
+
|
|
24
|
+
const RELOAD_SCRIPT = String.raw`
|
|
25
|
+
const timeoutMs = Number(process.env.TYPECLAW_RELOAD_TIMEOUT_MS ?? '30000')
|
|
26
|
+
const url = new URL('ws://127.0.0.1:' + (process.env.TYPECLAW_CONTAINER_PORT ?? '8973'))
|
|
27
|
+
if (process.env.TYPECLAW_TUI_TOKEN) url.searchParams.set('token', process.env.TYPECLAW_TUI_TOKEN)
|
|
28
|
+
const ws = new WebSocket(url.toString())
|
|
29
|
+
let settled = false
|
|
30
|
+
const finish = (payload, code) => {
|
|
31
|
+
if (settled) return
|
|
32
|
+
settled = true
|
|
33
|
+
console.log(JSON.stringify(payload))
|
|
34
|
+
if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) ws.close()
|
|
35
|
+
setTimeout(() => process.exit(code), 0)
|
|
36
|
+
}
|
|
37
|
+
const timer = setTimeout(() => finish({ ok: false, reason: 'timed out waiting for container-local reload_result after ' + timeoutMs + 'ms' }, 1), timeoutMs)
|
|
38
|
+
ws.addEventListener('open', () => {
|
|
39
|
+
const scope = process.env.TYPECLAW_RELOAD_SCOPE
|
|
40
|
+
ws.send(JSON.stringify(scope ? { type: 'reload', scope } : { type: 'reload' }))
|
|
41
|
+
})
|
|
42
|
+
ws.addEventListener('message', (event) => {
|
|
43
|
+
const msg = JSON.parse(String(event.data))
|
|
44
|
+
if (msg.type !== 'reload_result') return
|
|
45
|
+
clearTimeout(timer)
|
|
46
|
+
finish({ ok: true, results: msg.results }, 0)
|
|
47
|
+
})
|
|
48
|
+
ws.addEventListener('error', (event) => finish({ ok: false, reason: String(event.message ?? event) }, 1))
|
|
49
|
+
ws.addEventListener('close', () => finish({ ok: false, reason: 'container-local websocket closed before reload_result' }, 1))
|
|
50
|
+
`
|
|
51
|
+
|
|
52
|
+
export async function requestReloadViaDockerExec({
|
|
53
|
+
cwd,
|
|
54
|
+
token,
|
|
55
|
+
scope,
|
|
56
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
57
|
+
exec = defaultDockerExec,
|
|
58
|
+
}: RequestReloadViaDockerExecOptions): Promise<ReloadResult[]> {
|
|
59
|
+
const envArgs = ['-e', `TYPECLAW_CONTAINER_PORT=${CONTAINER_PORT}`, '-e', `TYPECLAW_RELOAD_TIMEOUT_MS=${timeoutMs}`]
|
|
60
|
+
if (token !== null) envArgs.push('-e', `TYPECLAW_TUI_TOKEN=${token}`)
|
|
61
|
+
if (scope !== undefined) envArgs.push('-e', `TYPECLAW_RELOAD_SCOPE=${scope}`)
|
|
62
|
+
|
|
63
|
+
const signal = AbortSignal.timeout(timeoutMs)
|
|
64
|
+
let result: DockerExecResult
|
|
65
|
+
try {
|
|
66
|
+
result = await exec(['exec', ...envArgs, containerNameFromCwd(cwd), 'bun', '-e', RELOAD_SCRIPT], { signal })
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (signal.aborted) throw new Error(`docker exec timed out after ${timeoutMs}ms`)
|
|
69
|
+
throw err
|
|
70
|
+
}
|
|
71
|
+
if (signal.aborted) throw new Error(`docker exec timed out after ${timeoutMs}ms`)
|
|
72
|
+
if (result.exitCode !== 0) {
|
|
73
|
+
const envelope = parseEnvelope(result.stdout)
|
|
74
|
+
if (envelope !== null && !envelope.ok) throw new Error(envelope.reason)
|
|
75
|
+
const reason =
|
|
76
|
+
sanitizeDockerStderr(result.stderr) || result.stdout.trim() || `docker exec exited with code ${result.exitCode}`
|
|
77
|
+
throw new Error(reason)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const envelope = parseEnvelope(result.stdout)
|
|
81
|
+
if (envelope === null) throw new Error('container-local reload returned invalid JSON')
|
|
82
|
+
if (!envelope.ok) throw new Error(envelope.reason)
|
|
83
|
+
return envelope.results
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseEnvelope(stdout: string): DockerExecReloadEnvelope | null {
|
|
87
|
+
const line = stdout
|
|
88
|
+
.split('\n')
|
|
89
|
+
.map((entry) => entry.trim())
|
|
90
|
+
.filter((entry) => entry.length > 0)
|
|
91
|
+
.at(-1)
|
|
92
|
+
if (line === undefined) return null
|
|
93
|
+
try {
|
|
94
|
+
const parsed: unknown = JSON.parse(line)
|
|
95
|
+
return isEnvelope(parsed) ? parsed : null
|
|
96
|
+
} catch {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isEnvelope(value: unknown): value is DockerExecReloadEnvelope {
|
|
102
|
+
if (!isRecord(value) || typeof value.ok !== 'boolean') return false
|
|
103
|
+
if (value.ok) return Array.isArray(value.results)
|
|
104
|
+
return typeof value.reason === 'string'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
108
|
+
return typeof value === 'object' && value !== null
|
|
109
|
+
}
|
package/src/reload/index.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
export { requestReload, type RequestReloadOptions } from './client'
|
|
1
|
+
export { ReloadConnectionError, requestReload, type RequestReloadOptions } from './client'
|
|
2
|
+
export { requestReloadViaDockerExec, type RequestReloadViaDockerExecOptions } from './docker-exec-client'
|
|
2
3
|
export { formatChannelReloadSummary } from './format'
|
|
3
4
|
export { ReloadRegistry } from './registry'
|
|
5
|
+
export {
|
|
6
|
+
requestReloadWithFallback,
|
|
7
|
+
type RequestReloadWithFallbackOptions,
|
|
8
|
+
type RequestReloadWithFallbackResult,
|
|
9
|
+
} from './recover'
|
|
4
10
|
export type { Reloadable, ReloadAllResult, ReloadResult } from './types'
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ReloadConnectionError, requestReload } from './client'
|
|
2
|
+
import { requestReloadViaDockerExec } from './docker-exec-client'
|
|
3
|
+
import type { ReloadResult } from './types'
|
|
4
|
+
|
|
5
|
+
export type RequestReloadWithFallbackOptions = {
|
|
6
|
+
url: string
|
|
7
|
+
cwd?: string
|
|
8
|
+
token?: string | null
|
|
9
|
+
scope?: string
|
|
10
|
+
timeoutMs?: number
|
|
11
|
+
reload?: typeof requestReload
|
|
12
|
+
reloadViaDockerExec?: typeof requestReloadViaDockerExec
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type RequestReloadWithFallbackResult =
|
|
16
|
+
| { transport: 'host'; results: ReloadResult[] }
|
|
17
|
+
| { transport: 'container-local'; results: ReloadResult[]; hostError: string }
|
|
18
|
+
|
|
19
|
+
export async function requestReloadWithFallback({
|
|
20
|
+
url,
|
|
21
|
+
cwd,
|
|
22
|
+
token,
|
|
23
|
+
scope,
|
|
24
|
+
timeoutMs,
|
|
25
|
+
reload = requestReload,
|
|
26
|
+
reloadViaDockerExec = requestReloadViaDockerExec,
|
|
27
|
+
}: RequestReloadWithFallbackOptions): Promise<RequestReloadWithFallbackResult> {
|
|
28
|
+
try {
|
|
29
|
+
return { transport: 'host', results: await reload({ url, scope, timeoutMs }) }
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if (!(err instanceof ReloadConnectionError) || cwd === undefined || token === undefined) throw err
|
|
32
|
+
return {
|
|
33
|
+
transport: 'container-local',
|
|
34
|
+
results: await reloadViaDockerExec({ cwd, token, scope, timeoutMs }),
|
|
35
|
+
hostError: err.message,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -220,29 +220,46 @@ export function canBindProcSafely(options?: { bwrapPath?: string }): Promise<boo
|
|
|
220
220
|
// leak-block guarantee — it only buys more chances to PROVE it.
|
|
221
221
|
export const PROC_BIND_RETRY_BACKOFF_MS = [250, 1_000, 2_000, 4_000] as const
|
|
222
222
|
|
|
223
|
+
// The retrying resolver returns the SAME three states as the probe, never a
|
|
224
|
+
// boolean: 'safe' selects proc-bind; the two failure states stay DELIBERATELY
|
|
225
|
+
// distinct so the caller reacts differently. 'unsafe' is a DEFINITIVE host fact
|
|
226
|
+
// (a real cross-userns environ leak was observed, or the binary is genuinely
|
|
227
|
+
// absent) — permanent, fail closed, retrying buys nothing. 'inconclusive' means
|
|
228
|
+
// the safety probe never returned a definitive verdict within the backoff budget
|
|
229
|
+
// (a boot-time CPU/IO storm tripping the probe's own timeout) — it proves NOTHING
|
|
230
|
+
// about the host, so the SAME container can recover on a later call once the
|
|
231
|
+
// spike passes. Folding these two into a single boolean `false` is what made a
|
|
232
|
+
// transient boot-storm degrade look permanent: the caller degraded to tmpfs AND
|
|
233
|
+
// told the model "retrying won't help", so a capable host stayed broken until
|
|
234
|
+
// restart.
|
|
235
|
+
//
|
|
223
236
|
// proc-bind selection must distinguish "definitely unavailable" from "couldn't
|
|
224
|
-
// verify right now". A DEFINITIVE verdict is final: 'safe'
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
237
|
+
// verify right now". A DEFINITIVE verdict is final: 'safe'; a real userns leak
|
|
238
|
+
// ('unsafe') with NO retry. Only an 'inconclusive' verdict (transient probe
|
|
239
|
+
// failure that proves nothing about the host) is retried, because degrading the
|
|
240
|
+
// bash call to tmpfs over a transient hiccup is what silently broke
|
|
228
241
|
// external-package runs on capable hosts. 'inconclusive' is never cached
|
|
229
242
|
// (see the cache type), so each retry re-probes from scratch. After the backoff
|
|
230
|
-
// budget is exhausted we
|
|
231
|
-
// as safe
|
|
232
|
-
//
|
|
233
|
-
//
|
|
243
|
+
// budget is exhausted we return 'inconclusive' — an unverified leak-block is
|
|
244
|
+
// never treated as safe, but the RESULT (a transient unknown, not a definitive
|
|
245
|
+
// 'unsafe') lets the caller offer a retryable degrade. Pure and
|
|
246
|
+
// dependency-injected (probe + sleep) so the retry policy is unit-testable
|
|
247
|
+
// without spawning processes; production passes getProcBindSafetyVerdict and
|
|
248
|
+
// Bun.sleep.
|
|
234
249
|
export async function resolveProcBindSafetyWithRetry(
|
|
235
250
|
probe: () => Promise<ProcBindSafetyVerdict>,
|
|
236
251
|
sleep: (ms: number) => Promise<void>,
|
|
237
252
|
backoffMs: readonly number[] = PROC_BIND_RETRY_BACKOFF_MS,
|
|
238
|
-
): Promise<
|
|
253
|
+
): Promise<ProcBindSafetyVerdict> {
|
|
239
254
|
for (let attempt = 0; ; attempt++) {
|
|
240
255
|
const verdict = await probe()
|
|
241
|
-
if (verdict === 'safe') return
|
|
242
|
-
if (verdict === 'unsafe') return
|
|
256
|
+
if (verdict === 'safe') return 'safe'
|
|
257
|
+
if (verdict === 'unsafe') return 'unsafe'
|
|
243
258
|
|
|
244
259
|
const backoff = backoffMs[attempt]
|
|
245
|
-
|
|
260
|
+
// Budget exhausted: still unverified. Report 'inconclusive' (NOT 'unsafe') so
|
|
261
|
+
// the caller knows this is a retryable unknown, not a definitive host fact.
|
|
262
|
+
if (backoff === undefined) return 'inconclusive'
|
|
246
263
|
await sleep(backoff)
|
|
247
264
|
}
|
|
248
265
|
}
|
|
@@ -282,9 +299,14 @@ async function probeProcBind(bwrap: string): Promise<ProcBindProbe> {
|
|
|
282
299
|
// marker: that proves the sentinel is dumpable, same-uid, AND that this pid is
|
|
283
300
|
// OUR sentinel (not a reused pid), so the ONLY thing that can deny the read
|
|
284
301
|
// from inside the sandbox is the child-userns boundary (rules out a false
|
|
285
|
-
// "blocked" from dumpable=0 / uid mismatch).
|
|
286
|
-
//
|
|
287
|
-
|
|
302
|
+
// "blocked" from dumpable=0 / uid mismatch). The marker can be absent for a
|
|
303
|
+
// moment right after Bun.spawn: the child pid exists before `/usr/bin/env -i
|
|
304
|
+
// SECRET=... /bin/sleep` has exec'd and replaced its environ. Treating that
|
|
305
|
+
// startup race as immediate INCONCLUSIVE made the retry budget collapse into
|
|
306
|
+
// pure backoff time (~7.25s) and produced the first-tool `bunx` degrade even
|
|
307
|
+
// though the same host proved safe on the next call. Wait briefly for the
|
|
308
|
+
// marker before deciding setup is unsound; a real failure still fails closed.
|
|
309
|
+
if (!(await waitForSentinelMarker(sentinelPid))) return INCONCLUSIVE
|
|
288
310
|
|
|
289
311
|
const proc = Bun.spawn(
|
|
290
312
|
[
|
|
@@ -387,6 +409,9 @@ async function probeProcBind(bwrap: string): Promise<ProcBindProbe> {
|
|
|
387
409
|
// briefly-saturated box; a genuinely wedged runtime still trips it and degrades.
|
|
388
410
|
const PROC_BIND_PROBE_TIMEOUT_MS = 12_000
|
|
389
411
|
|
|
412
|
+
const PROC_BIND_SENTINEL_READY_TIMEOUT_MS = 1_000
|
|
413
|
+
const PROC_BIND_SENTINEL_READY_POLL_MS = 25
|
|
414
|
+
|
|
390
415
|
// Designated probe-script exit codes. ONLY these two are a cacheable verdict;
|
|
391
416
|
// every other code (a setup failure, bwrap startup failure, a signal, 127, …) is
|
|
392
417
|
// inconclusive and must NOT be cached — see the exit-code interpretation in
|
|
@@ -457,6 +482,24 @@ async function parentReadsSentinelMarker(sentinelPid: number): Promise<boolean>
|
|
|
457
482
|
}
|
|
458
483
|
}
|
|
459
484
|
|
|
485
|
+
async function waitForSentinelMarker(
|
|
486
|
+
sentinelPid: number,
|
|
487
|
+
readMarker: (pid: number) => Promise<boolean> = parentReadsSentinelMarker,
|
|
488
|
+
sleep: (ms: number) => Promise<void> = (ms) => Bun.sleep(ms),
|
|
489
|
+
timeoutMs: number = PROC_BIND_SENTINEL_READY_TIMEOUT_MS,
|
|
490
|
+
pollMs: number = PROC_BIND_SENTINEL_READY_POLL_MS,
|
|
491
|
+
now: () => number = Date.now,
|
|
492
|
+
): Promise<boolean> {
|
|
493
|
+
const deadline = now() + timeoutMs
|
|
494
|
+
for (;;) {
|
|
495
|
+
if (await readMarker(sentinelPid)) return true
|
|
496
|
+
if (now() >= deadline) return false
|
|
497
|
+
await sleep(pollMs)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export const _waitForSentinelMarkerForTests = waitForSentinelMarker
|
|
502
|
+
|
|
460
503
|
export function _resetProcBindProbeCacheForTests(): void {
|
|
461
504
|
procBindProbeCache.clear()
|
|
462
505
|
procBindProbeInFlight.clear()
|
package/src/sandbox/errors.ts
CHANGED
|
@@ -41,3 +41,29 @@ export class SandboxDegradedProcError extends Error {
|
|
|
41
41
|
)
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
|
|
45
|
+
// Distinct from SandboxDegradedProcError: that one is the PERMANENT verdict (a
|
|
46
|
+
// real userns leak, or a host with no usable namespaces — retrying is futile).
|
|
47
|
+
// This one fires when the proc-bind safety probe stayed 'inconclusive' through
|
|
48
|
+
// its whole retry budget — typically a boot-time CPU/IO storm tripping the
|
|
49
|
+
// probe's own timeout. The host is very likely capable; the probe just couldn't
|
|
50
|
+
// prove it RIGHT NOW. Because an 'inconclusive' verdict is never cached, the next
|
|
51
|
+
// bash call re-probes from scratch and usually promotes to proc-bind once the
|
|
52
|
+
// spike passes. So the message tells the model the OPPOSITE of the permanent
|
|
53
|
+
// case: retrying IS the fix. Without this split, a single unlucky boot-storm
|
|
54
|
+
// probe degraded a fully-capable container to tmpfs and told the agent it was a
|
|
55
|
+
// permanent environment limit — so it gave up instead of retrying.
|
|
56
|
+
export class SandboxProcProbeUnverifiedError extends Error {
|
|
57
|
+
override readonly name = 'SandboxProcProbeUnverifiedError'
|
|
58
|
+
constructor() {
|
|
59
|
+
super(
|
|
60
|
+
'sandbox /proc strategy could not be verified right now: the cap-free ' +
|
|
61
|
+
'proc-bind safety probe stayed inconclusive (usually transient load on the ' +
|
|
62
|
+
'host while the container was starting up), so this bun package command ' +
|
|
63
|
+
'(bun install / bun add / bunx / bun run) was held back rather than run ' +
|
|
64
|
+
'under a broken /proc. This is almost certainly temporary and NOT a problem ' +
|
|
65
|
+
'with the command or the package: retry the SAME command in a few seconds — ' +
|
|
66
|
+
'the next attempt re-probes and normally succeeds.',
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/sandbox/index.ts
CHANGED
|
@@ -27,7 +27,12 @@ export { resolveSandboxSymlinks, type SandboxSymlinkSpec } from './symlinks'
|
|
|
27
27
|
export { commandNeedsRealProc, isPackageInstallCommand } from './package-install'
|
|
28
28
|
export { ensureSessionTmpDir, isUnderTmp, mapVirtualTmpPath, SESSION_TMP_ROOT, sessionTmpDir } from './session-tmp'
|
|
29
29
|
export { formatCommand, shellQuote } from './quote'
|
|
30
|
-
export {
|
|
30
|
+
export {
|
|
31
|
+
SandboxDegradedProcError,
|
|
32
|
+
SandboxPolicyError,
|
|
33
|
+
SandboxProcProbeUnverifiedError,
|
|
34
|
+
SandboxUnavailableError,
|
|
35
|
+
} from './errors'
|
|
31
36
|
export {
|
|
32
37
|
DEFAULT_SANDBOX_ENV,
|
|
33
38
|
type SandboxCommandFilter,
|
|
@@ -16,7 +16,7 @@ The runtime reads `typeclaw.json` at container startup. Some fields are picked u
|
|
|
16
16
|
- `port` — the TCP port the websocket server binds to inside the container. The TUI on the host stage connects to this. Default `8973`. **Restart-required.**
|
|
17
17
|
- `model` — a fully-qualified `<provider>/<model-id>` string. The runtime resolves this against the built-in provider registry to decide which API to call for every turn. **Live-reloadable.**
|
|
18
18
|
- `mounts` — additional host directories the user has chosen to expose to you. Each entry produces a `docker run -v <hostPath>:/agent/mounts/<name>` flag at `typeclaw start` time, so the directory shows up at `mounts/<name>` inside your agent folder. **The launcher reads this; the running container does not.** Editing `mounts` only takes effect on the next `typeclaw start`. **Restart-required.**
|
|
19
|
-
- `plugins` — array of plugin
|
|
19
|
+
- `plugins` — array of plugin module specifiers loaded at server boot: npm package names for published plugins, or relative paths for local plugins you are authoring. **Restart-required.**
|
|
20
20
|
- `alias` — additional names the agent answers to when a channel message contains its name in plain text (no `<@id>` mention). The agent folder's directory name (`basename(agentDir)`) is always implicit; `alias` adds further forms (Latin transliteration, nicknames, Korean particles, etc.). Used by the channel engagement layer alongside the structural mention/reply/dm triggers. **Live-reloadable.**
|
|
21
21
|
- `channels` — per-adapter engagement triggers and history-prefetch knobs for external messengers (Discord, Slack, Telegram, KakaoTalk), plus the GitHub channel (a webhook-driven adapter that watches repos and reviews PRs — see **GitHub channel** below). Access control lives in `roles`, not here. **Live-reloadable** — edits take effect on the next `reload` without a container restart.
|
|
22
22
|
- `docker.file` — controls what ships in the autogenerated container image. Two layers: (1) **toggles** for opinionated package installs — `tmux`, `gh`, `python`, `xvfb` default on (`true`); `cjkFonts` defaults to `"auto"` (resolved from host locale at start); `ffmpeg`, `cloudflared`, `claudeCode`, `codexCli` default off (`false`) — set a toggle to `false` to omit, or to a version string like `"2.40.0"` to apt-pin (`python`, `cjkFonts`, `cloudflared`, `xvfb`, `claudeCode`, and `codexCli` are boolean-only). Most toggles install apt packages with BuildKit cache mounts; `cloudflared`, `claudeCode`, and `codexCli` are exceptions — `cloudflared` downloads the pinned GitHub release, `claudeCode` runs Anthropic's official `curl | bash` installer, `codexCli` `bun install`s the `@openai/codex` npm package. (2) **`append`** — extra Dockerfile lines spliced in right before `ENTRYPOINT` for anything the toggles don't cover. The whole Dockerfile is rewritten on every `start` from the typeclaw template. Lives under the `docker` namespace alongside future Docker-related blocks (e.g. `docker.compose`). **Restart-required** (next `typeclaw start` rebuilds the image).
|
|
@@ -45,7 +45,7 @@ You yourself cannot run `typeclaw restart` — that is a host-stage command and
|
|
|
45
45
|
| `port` | no | integer | 1–65535. Defaults to `8973` (T9 spelling of "TYPE"). Change only if the default collides with something on the user's host. **Restart-required.** |
|
|
46
46
|
| `model` | no | string | Must be one of the values listed in the **Allowed models** section below. Defaults to `openai/gpt-5.4-nano`. **Live-reloadable.** |
|
|
47
47
|
| `mounts` | no | array of objects | Host directories bind-mounted into your container. Defaults to `[]` (no host paths exposed). Omitted from scaffolded `typeclaw.json` — add it only when the user wants host paths exposed. See **Mounts** section below. **Restart-required.** |
|
|
48
|
-
| `plugins` | no | array of strings | Plugin
|
|
48
|
+
| `plugins` | no | array of strings | Plugin module specifiers loaded at server boot: use npm package names for published plugins (for example, `typeclaw-gws-multi-account`) and relative paths only for local plugins you are authoring (for example, `./packages/my-plugin`). Defaults to `[]`. **Restart-required.** Plugin-owned config blocks live alongside as additional top-level keys; see **Plugin config blocks**. |
|
|
49
49
|
| `alias` | no | array of strings | Additional names the agent answers to in channel engagement, on top of the implicit `basename(agentDir)`. Each entry is a non-empty trimmed string matched case-insensitively as a substring of the inbound text. Defaults to `[]`. Hatching populates this with the agent's chosen name. See **Channels and Alias** below for schema/edit mechanics; the matching behavior lives in the `typeclaw-channels` skill. **Live-reloadable.** |
|
|
50
50
|
| `channels` | no | object | Per-adapter engagement triggers and history-prefetch knobs for external messengers (plus the `github` webhook channel — see **GitHub channel** below). Defaults to `{}` (no adapters configured). `typeclaw init` scaffolds an empty block per requested adapter (e.g. `"discord-bot": {}`) and the schema fills in defaults. Channel access control lives in `roles` — see the `typeclaw-permissions` skill; engagement behavior lives in `typeclaw-channels`. **Live-reloadable.** See **Channels and Alias** below. |
|
|
51
51
|
| `portForward` | no | object | Allow/deny policy for the host-stage portbroker that auto-forwards container LISTEN ports to `127.0.0.1` on the host. Defaults to `{ "allow": "*" }` (forward everything). Omitted from scaffolded `typeclaw.json`. **Restart-required.** See **portForward** section below. |
|
|
@@ -11,10 +11,10 @@ Your agent folder is a **bun monorepo**. The root `package.json` declares `"work
|
|
|
11
11
|
|
|
12
12
|
You have two free-write zones at the agent root: `workspace/` and `packages/`. Both are exempt from the non-workspace-write guard so you can edit them without acknowledging anything, but their relationship to git is opposite, and picking the wrong one is the most common mistake.
|
|
13
13
|
|
|
14
|
-
| Zone | Purpose
|
|
15
|
-
| ------------ |
|
|
16
|
-
| `workspace/` | One-off scripts, scratch work, throwaway experiments
|
|
17
|
-
| `packages/` | Reusable packages, custom plugins, shared utilities, internal libs | **Yes** — every file is tracked and MUST be committed when edited (only `*/node_modules/` ignored inside) | Yes (committed and importable across agents) |
|
|
14
|
+
| Zone | Purpose | Tracked in git? | Reusable? |
|
|
15
|
+
| ------------ | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
|
|
16
|
+
| `workspace/` | One-off scripts, scratch work, throwaway experiments | **No** — entire dir is gitignored | No (the dir itself is invisible to git) |
|
|
17
|
+
| `packages/` | Reusable packages, custom local plugins, shared utilities, internal libs | **Yes** — every file is tracked and MUST be committed when edited (only `*/node_modules/` ignored inside) | Yes (committed and importable across agents) |
|
|
18
18
|
|
|
19
19
|
The two columns to internalize:
|
|
20
20
|
|
|
@@ -26,7 +26,7 @@ Anything you put in `packages/` MUST land in a commit — see `typeclaw-git`. Th
|
|
|
26
26
|
**Decision rule, top to bottom — stop at the first match:**
|
|
27
27
|
|
|
28
28
|
1. **Will another script or another part of the agent folder import this?** → `packages/<name>/`. Even if "another part" is just "tomorrow's me writing a sibling script", a reusable thing belongs here.
|
|
29
|
-
2. **Is this a custom typeclaw plugin
|
|
29
|
+
2. **Is this a custom local typeclaw plugin you are authoring?** → `packages/<plugin-name>/`. If you are adding an existing or published plugin, keep its npm package specifier in `typeclaw.json#plugins`; do not create or guess a `./packages/...` path.
|
|
30
30
|
3. **Will the user want to track this in git, see it in PRs, depend on it from a cron job?** → `packages/<name>/`.
|
|
31
31
|
4. **Is this throwaway** — a one-shot data transformation, a debug script, a scratch experiment that exists for one task and dies? → `workspace/`.
|
|
32
32
|
5. **Default if unsure** → `packages/<name>/`. Better to commit something reusable than to lose something useful in the gitignored void.
|
|
@@ -97,6 +97,8 @@ To depend on a workspace package from the **agent root** (e.g. so cron `exec` jo
|
|
|
97
97
|
|
|
98
98
|
## Custom typeclaw plugins live under `packages/`
|
|
99
99
|
|
|
100
|
+
This section is only for plugins you are **authoring locally** in the agent folder. If the user asks to add/install an existing or published plugin, use the plugin's npm package specifier in `typeclaw.json#plugins` (for example, `"typeclaw-gws-multi-account"`) and do **not** fabricate a `./packages/...` path.
|
|
101
|
+
|
|
100
102
|
If you are writing a typeclaw plugin (anything that uses `definePlugin` from `typeclaw/plugin`), the canonical home is `packages/<plugin-name>/`. The workflow:
|
|
101
103
|
|
|
102
104
|
1. **Author**: `packages/my-plugin/index.ts` exports `definePlugin({ ... })` as default.
|
|
@@ -115,6 +115,13 @@ Without `configSchema`, `ctx.config` is `never` and any reference is a type erro
|
|
|
115
115
|
|
|
116
116
|
The **derived name is the key** for the per-plugin config block at the top level of `typeclaw.json`. Two plugins with the same derived name are a boot error.
|
|
117
117
|
|
|
118
|
+
Use the entry format that matches the plugin's source:
|
|
119
|
+
|
|
120
|
+
- **Published npm plugin** → put the npm package specifier in `plugins[]`, e.g. `"typeclaw-gws-multi-account"` or `"typeclaw-plugin-standup-log@1.2.3"`. Do **not** invent a `./packages/...` path for a published package.
|
|
121
|
+
- **Local plugin you are authoring in this agent folder** → put its relative path in `plugins[]`, e.g. `"./packages/my-plugin"`. The path must exist and point at local plugin code.
|
|
122
|
+
|
|
123
|
+
If the user says to add/install an existing plugin by package name, preserve that package name. Only use `./packages/<name>` when you are creating or wiring a local workspace package that exists in this repo.
|
|
124
|
+
|
|
118
125
|
### Local path safety
|
|
119
126
|
|
|
120
127
|
Local plugin paths **must resolve inside `agentDir`**. Absolute paths (`/etc/...`) and parent-traversing paths (`../../foo`) are rejected with:
|
|
@@ -125,9 +132,11 @@ plugin path escapes agent directory: <entry> (resolved to <abs-path>)
|
|
|
125
132
|
|
|
126
133
|
This is why `./plugins/x.ts` works and `/Users/me/x.ts` does not.
|
|
127
134
|
|
|
128
|
-
### Recommended location: `packages/<plugin-name>/`
|
|
135
|
+
### Recommended location for new local plugins: `packages/<plugin-name>/`
|
|
136
|
+
|
|
137
|
+
This section is about plugins you are **authoring locally**. For a published npm plugin, keep the npm package specifier in `plugins[]`; do not create or guess a local path.
|
|
129
138
|
|
|
130
|
-
The agent folder is a **bun monorepo**, and `packages/` is its workspace root. **Custom plugins go there.** A `./packages/standup-log/` plugin is a real workspace package — bun installs its dependencies, the workspace symlink machinery makes it importable, and it lands in git like any other reusable code. Concretely:
|
|
139
|
+
The agent folder is a **bun monorepo**, and `packages/` is its workspace root. **Custom local plugins go there.** A `./packages/standup-log/` plugin is a real workspace package — bun installs its dependencies, the workspace symlink machinery makes it importable, and it lands in git like any other reusable code. Concretely:
|
|
131
140
|
|
|
132
141
|
```
|
|
133
142
|
packages/
|