typeclaw 0.28.2 → 0.30.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/package.json +1 -1
- package/src/agent/index.ts +43 -5
- package/src/agent/live-subagents.ts +5 -0
- package/src/agent/loop-guard.ts +112 -26
- package/src/agent/plugin-tools.ts +167 -50
- package/src/agent/session-origin.ts +3 -3
- package/src/agent/subagent-drain.ts +150 -0
- package/src/agent/subagents.ts +41 -3
- package/src/agent/system-prompt.ts +29 -4
- package/src/agent/tools/channel-send.ts +1 -1
- package/src/agent/tools/spawn-subagent.ts +34 -1
- package/src/agent/tools/subagent-output.ts +7 -3
- package/src/agent/tools/wikipedia.ts +1 -1
- package/src/bundled-plugins/bun-hygiene/README.md +12 -11
- package/src/bundled-plugins/bun-hygiene/policy.ts +8 -3
- package/src/bundled-plugins/explorer/explorer.ts +2 -0
- package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +94 -0
- package/src/bundled-plugins/github-cli-auth/effective-approval.ts +98 -0
- package/src/bundled-plugins/github-cli-auth/gh-review-inline-detect.ts +130 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +27 -2
- package/src/bundled-plugins/github-cli-auth/review-recorder.ts +12 -4
- package/src/bundled-plugins/memory/memory-logger.ts +3 -3
- package/src/bundled-plugins/operator/operator.ts +2 -0
- package/src/bundled-plugins/planner/index.ts +11 -0
- package/src/bundled-plugins/planner/planner.ts +283 -0
- package/src/bundled-plugins/planner/skills/general.ts +65 -0
- package/src/bundled-plugins/planner/skills/project.ts +69 -0
- package/src/bundled-plugins/researcher/index.ts +11 -0
- package/src/bundled-plugins/researcher/researcher.ts +233 -0
- package/src/bundled-plugins/researcher/skills/general.ts +105 -0
- package/src/bundled-plugins/researcher/write-report.ts +107 -0
- package/src/bundled-plugins/reviewer/reviewer.ts +28 -9
- package/src/bundled-plugins/reviewer/skills/data-review.ts +77 -0
- package/src/bundled-plugins/reviewer/skills/doc-review.ts +79 -0
- package/src/bundled-plugins/reviewer/skills/plan-review.ts +64 -0
- package/src/bundled-plugins/reviewer/skills/security-audit.ts +70 -0
- package/src/bundled-plugins/reviewer/skills/writing-review.ts +63 -0
- package/src/bundled-plugins/scout/scout.ts +2 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +8 -4
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +3 -2
- package/src/channels/adapters/discord-bot.ts +38 -11
- package/src/channels/adapters/github/inbound.ts +68 -4
- package/src/channels/adapters/kakaotalk-classify.ts +2 -2
- package/src/channels/adapters/kakaotalk.ts +2 -2
- package/src/channels/adapters/slack-bot-classify.ts +1 -1
- package/src/channels/adapters/slack-bot.ts +3 -0
- package/src/channels/adapters/telegram-bot.ts +3 -0
- package/src/channels/engagement.ts +12 -7
- package/src/channels/github-review-claim.ts +15 -3
- package/src/channels/router.ts +85 -9
- package/src/channels/schema.ts +1 -1
- package/src/channels/types.ts +6 -0
- package/src/cli/init.ts +13 -2
- package/src/cli/ui.ts +64 -0
- package/src/config/config.ts +21 -15
- package/src/container/start.ts +5 -1
- package/src/init/dockerfile.ts +19 -56
- package/src/init/hatching.ts +1 -1
- package/src/init/index.ts +5 -1
- package/src/migrations/index.ts +35 -0
- package/src/migrations/secrets-v1-to-v2.ts +344 -0
- package/src/run/bundled-plugins.ts +4 -0
- package/src/run/index.ts +13 -0
- package/src/sandbox/availability.ts +12 -0
- package/src/sandbox/build.ts +12 -0
- package/src/sandbox/index.ts +1 -1
- package/src/sandbox/policy.ts +8 -0
- package/src/server/index.ts +24 -5
- package/src/shared/host-locale.ts +27 -0
- package/src/shared/protocol.ts +1 -1
- package/src/shared/wordmark.ts +19 -0
- package/src/skills/typeclaw-config/SKILL.md +32 -32
- package/src/skills/typeclaw-kaomoji/SKILL.md +3 -3
- package/src/skills/typeclaw-tunnels/SKILL.md +3 -1
- package/src/tui/banner.ts +19 -0
- package/src/tui/format.ts +34 -0
- package/src/tui/index.ts +121 -22
- package/src/tui/theme.ts +26 -1
- package/src/tunnels/providers/cloudflare-named.ts +15 -4
- package/src/tunnels/providers/cloudflare-quick.ts +15 -4
- package/src/tunnels/providers/cloudflared-binary.ts +11 -0
- package/typeclaw.schema.json +15 -7
package/src/init/dockerfile.ts
CHANGED
|
@@ -12,6 +12,12 @@ export const DOCKERFILE = 'Dockerfile'
|
|
|
12
12
|
export type BuildDockerfileOptions = {
|
|
13
13
|
// Null or omitted = emit the full inline heavy stack (dev mode, tests).
|
|
14
14
|
baseImageVersion?: string | null
|
|
15
|
+
// Host-locale decision for `cjkFonts: 'auto'`. Callers that have a host
|
|
16
|
+
// locale (start/init) pass the resolved boolean; when omitted, `'auto'`
|
|
17
|
+
// falls back to NOT installing (the slim default) so a context without a
|
|
18
|
+
// host signal — e.g. a bare `buildDockerfile()` in tests — stays small and
|
|
19
|
+
// deterministic.
|
|
20
|
+
cjkFontsAuto?: boolean
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
// Apt packages that EVERY image must have — git for the agent runtime,
|
|
@@ -98,33 +104,6 @@ export const CURL_IMPERSONATE_SHA256_ARM64 = '6766bc67fd3e8e2313875f32b36b5a3fab
|
|
|
98
104
|
// the impersonation to whatever `curl_chrome` resolves to.
|
|
99
105
|
export const CURL_IMPERSONATE_PROFILE = 'chrome136'
|
|
100
106
|
|
|
101
|
-
// yq is the YAML/JSON/XML processor the `explorer` and `reviewer` subagent
|
|
102
|
-
// prompts advertise alongside `jq` as a sanctioned one-shot read-only
|
|
103
|
-
// pipeline tool (`... | yq`). Like `jq` (shipped in baseline), a binary that
|
|
104
|
-
// is not in the container base image is invisible inside the per-tool bwrap
|
|
105
|
-
// sandbox that wraps agent bash calls — the sandbox only `--ro-bind`s `/usr`
|
|
106
|
-
// + `/bin` from the container, and there is no per-call install path. So `yq`
|
|
107
|
-
// ships unconditionally, same rationale as `jq`/`bubblewrap`/`util-linux`.
|
|
108
|
-
//
|
|
109
|
-
// This is Mike Farah's Go `yq` (https://github.com/mikefarah/yq), NOT the
|
|
110
|
-
// Python jq-wrapper of the same name in Debian's `yq` apt package. The
|
|
111
|
-
// prompts pair `yq` with `jq` for jq-style expression pipelines, which is
|
|
112
|
-
// Farah's syntax — the Python tool's CLI is incompatible. Distributed as a
|
|
113
|
-
// per-arch static binary (no apt package on trixie for the Go variant), so
|
|
114
|
-
// it follows the pinned-version + per-arch SHA256 + `sha256sum -c` pattern of
|
|
115
|
-
// curl-impersonate and cloudflared rather than the apt baseline list.
|
|
116
|
-
//
|
|
117
|
-
// To bump: pick a release from https://github.com/mikefarah/yq/releases,
|
|
118
|
-
// then for each arch download yq_linux_<arch> and `shasum -a 256` it (or read
|
|
119
|
-
// the SHA-256 column from the release's `checksums` file, cross-indexed via
|
|
120
|
-
// `checksums_hashes_order`). Update all three constants in the same commit;
|
|
121
|
-
// the build fails loudly at `sha256sum -c` on a mismatch. Version literal is
|
|
122
|
-
// the release tag exactly as it appears on GitHub (with `v` prefix).
|
|
123
|
-
export const YQ_VERSION = 'v4.53.2'
|
|
124
|
-
export const YQ_SHA256_AMD64 = 'd56bf5c6819e8e696340c312bd70f849dc1678a7cda9c2ad63eebd906371d56b'
|
|
125
|
-
export const YQ_SHA256_ARM64 = '03061b2a50c7a498de2bbb92d7cb078ce433011f085a4994117c2726be4106ea'
|
|
126
|
-
export const YQ_RELEASE_URL_BASE = 'https://github.com/mikefarah/yq/releases/download'
|
|
127
|
-
|
|
128
107
|
// cloudflared powers `cloudflare-quick` tunnels. Pinned-version + per-arch
|
|
129
108
|
// SHA256 mirrors the curl-impersonate pattern above: bumping requires updating
|
|
130
109
|
// all three constants in the same commit, and the build fails loudly at
|
|
@@ -1068,22 +1047,26 @@ type AptFeature = {
|
|
|
1068
1047
|
toAptArgs: (toggle: DockerfileFeatureToggle) => string[]
|
|
1069
1048
|
}
|
|
1070
1049
|
|
|
1071
|
-
const APT_FEATURES: Record<'ffmpeg' | 'gh' | 'tmux' | 'python' | '
|
|
1050
|
+
const APT_FEATURES: Record<'ffmpeg' | 'gh' | 'tmux' | 'python' | 'xvfb', AptFeature> = {
|
|
1072
1051
|
ffmpeg: { toAptArgs: (v) => singlePackageArgs('ffmpeg', v) },
|
|
1073
1052
|
gh: { toAptArgs: (v) => singlePackageArgs('gh', v) },
|
|
1074
1053
|
tmux: { toAptArgs: (v) => singlePackageArgs('tmux', v) },
|
|
1075
1054
|
python: {
|
|
1076
1055
|
toAptArgs: (v) => (v === true ? ['python3', 'python3-pip', 'python3-venv', 'python-is-python3'] : []),
|
|
1077
1056
|
},
|
|
1078
|
-
cjkFonts: { toAptArgs: (v) => (v === true ? [CJK_FONTS_PACKAGE] : []) },
|
|
1079
1057
|
xvfb: { toAptArgs: (v) => (v === true ? ['xvfb'] : []) },
|
|
1080
1058
|
}
|
|
1081
1059
|
|
|
1060
|
+
function resolveCjkFonts(value: boolean | 'auto', auto: boolean): boolean {
|
|
1061
|
+
return value === 'auto' ? auto : value
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1082
1064
|
export function buildDockerfile(
|
|
1083
1065
|
config: DockerfileConfig = defaultConfig(),
|
|
1084
1066
|
options: BuildDockerfileOptions = {},
|
|
1085
1067
|
): string {
|
|
1086
|
-
const
|
|
1068
|
+
const cjkFonts = resolveCjkFonts(config.cjkFonts, options.cjkFontsAuto ?? false)
|
|
1069
|
+
const toggleAptArgs = collectToggleAptArgs(config, cjkFonts)
|
|
1087
1070
|
const ghKeyringLayer = renderGhKeyringLayer(config.gh)
|
|
1088
1071
|
const cloudflaredLayer = renderCloudflaredLayer(config.cloudflared)
|
|
1089
1072
|
const customLines = renderCustomDockerfileLines(config.append)
|
|
@@ -1215,8 +1198,6 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
|
|
|
1215
1198
|
|
|
1216
1199
|
${LAYER_2_5_CURL_IMPERSONATE}
|
|
1217
1200
|
|
|
1218
|
-
${LAYER_2_6_YQ}
|
|
1219
|
-
|
|
1220
1201
|
${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
|
|
1221
1202
|
|
|
1222
1203
|
${LAYER_4_AGENT_BROWSER_INSTALL}
|
|
@@ -1296,8 +1277,6 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
|
|
|
1296
1277
|
|
|
1297
1278
|
${LAYER_2_5_CURL_IMPERSONATE}
|
|
1298
1279
|
|
|
1299
|
-
${LAYER_2_6_YQ}
|
|
1300
|
-
|
|
1301
1280
|
${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
|
|
1302
1281
|
|
|
1303
1282
|
${LAYER_4_AGENT_BROWSER_INSTALL}
|
|
@@ -1352,24 +1331,6 @@ RUN ARCH_TARBALL="$(if [ "$TARGETARCH" = "arm64" ]; then echo aarch64-linux-gnu;
|
|
|
1352
1331
|
&& rm curl-impersonate.tar.gz \\
|
|
1353
1332
|
&& /usr/local/bin/curl_${CURL_IMPERSONATE_PROFILE} --version > /dev/null`
|
|
1354
1333
|
|
|
1355
|
-
// Layer 2.6: install pinned Mike Farah `yq` (Go) so the explorer/reviewer
|
|
1356
|
-
// subagents' advertised `... | yq` pipelines resolve inside the bwrap
|
|
1357
|
-
// sandbox. Unconditional like the apt baseline (jq, git): a missing binary
|
|
1358
|
-
// is invisible to sandboxed bash, so this is not behind a toggle. Placed
|
|
1359
|
-
// after curl-impersonate (curl + ca-certificates from baseline guaranteed
|
|
1360
|
-
// present) and before agent-browser so an agent-browser bump doesn't
|
|
1361
|
-
// invalidate this layer. See the YQ_* constants above for the bump recipe
|
|
1362
|
-
// and the Go-vs-Python `yq` rationale.
|
|
1363
|
-
const LAYER_2_6_YQ = `# Layer 2.6 (stable): pinned Mike Farah yq for sandbox-visible YAML pipelines.
|
|
1364
|
-
RUN ARCH_BIN="$(if [ "$TARGETARCH" = "arm64" ]; then echo arm64; else echo amd64; fi)" \\
|
|
1365
|
-
&& ARCH_SHA="$(if [ "$TARGETARCH" = "arm64" ]; then echo ${YQ_SHA256_ARM64}; else echo ${YQ_SHA256_AMD64}; fi)" \\
|
|
1366
|
-
&& cd /tmp \\
|
|
1367
|
-
&& curl -fsSL -o yq "${YQ_RELEASE_URL_BASE}/${YQ_VERSION}/yq_linux_\${ARCH_BIN}" \\
|
|
1368
|
-
&& echo "\${ARCH_SHA} yq" | sha256sum -c - \\
|
|
1369
|
-
&& chmod +x yq \\
|
|
1370
|
-
&& mv yq /usr/local/bin/yq \\
|
|
1371
|
-
&& /usr/local/bin/yq --version > /dev/null`
|
|
1372
|
-
|
|
1373
1334
|
const LAYER_3_AGENT_BROWSER_ARM64_CONFIG = `# Layer 3 (stable, arm64 only): point agent-browser at the apt-installed
|
|
1374
1335
|
# chromium. Independent of the npm install below so it stays cached across
|
|
1375
1336
|
# agent-browser version bumps.
|
|
@@ -1510,8 +1471,8 @@ function defaultConfig(): DockerfileConfig {
|
|
|
1510
1471
|
gh: true,
|
|
1511
1472
|
python: true,
|
|
1512
1473
|
tmux: true,
|
|
1513
|
-
cjkFonts:
|
|
1514
|
-
cloudflared:
|
|
1474
|
+
cjkFonts: 'auto',
|
|
1475
|
+
cloudflared: false,
|
|
1515
1476
|
xvfb: true,
|
|
1516
1477
|
claudeCode: false,
|
|
1517
1478
|
codexCli: false,
|
|
@@ -1519,11 +1480,13 @@ function defaultConfig(): DockerfileConfig {
|
|
|
1519
1480
|
}
|
|
1520
1481
|
}
|
|
1521
1482
|
|
|
1522
|
-
function collectToggleAptArgs(config: DockerfileConfig): string[] {
|
|
1483
|
+
function collectToggleAptArgs(config: DockerfileConfig, cjkFonts: boolean): string[] {
|
|
1523
1484
|
const args: string[] = []
|
|
1524
|
-
for (const key of ['ffmpeg', 'gh', 'python', 'tmux'
|
|
1485
|
+
for (const key of ['ffmpeg', 'gh', 'python', 'tmux'] as const) {
|
|
1525
1486
|
args.push(...APT_FEATURES[key].toAptArgs(config[key]))
|
|
1526
1487
|
}
|
|
1488
|
+
if (cjkFonts) args.push(CJK_FONTS_PACKAGE)
|
|
1489
|
+
args.push(...APT_FEATURES.xvfb.toAptArgs(config.xvfb))
|
|
1527
1490
|
return args
|
|
1528
1491
|
}
|
|
1529
1492
|
|
package/src/init/hatching.ts
CHANGED
|
@@ -38,7 +38,7 @@ Routing answers:
|
|
|
38
38
|
1. \`write\` your name into \`IDENTITY.md\` (a first-person one-liner is fine: "I am <name>.").
|
|
39
39
|
2. ${q1AliasStep} The agent folder's directory name is already an implicit alias — only add the answered name explicitly when it differs from the dir name (different casing, a different word, or extra forms like "<name>" plus a Latin transliteration). This wires plain-text addressing in channels: when a user writes your name in chat without an @-mention, the engagement layer will recognize it. \`alias\` is live-reloadable.
|
|
40
40
|
2. **Q2 — the user's name.** Ask what to call them. After the answer: \`write\` it to both \`IDENTITY.md\` and \`USER.md\`.
|
|
41
|
-
3. **Q3 — tone/personality.** Ask how they want you to show up (tone, language, formality). After the answer: \`write\` it into \`SOUL.md\`. If they shrug or don't care: **default to warm, friendly, and easygoing** — a kind colleague who genuinely likes the person they work with, uses contractions, makes small jokes, never stiff. Write that as the default into \`SOUL.md\`. **Kaomoji affinity** — if their answer leans cute, adorable, warm, playful, soft, cozy, or
|
|
41
|
+
3. **Q3 — tone/personality.** Ask how they want you to show up (tone, language, formality). After the answer: \`write\` it into \`SOUL.md\`. If they shrug or don't care: **default to warm, friendly, and easygoing** — a kind colleague who genuinely likes the person they work with, uses contractions, makes small jokes, never stiff. Write that as the default into \`SOUL.md\`. **Kaomoji affinity** — if their answer leans cute, adorable, warm, playful, soft, cozy, or asks for that kind of tone in any language (e.g. Korean 친근/귀엽/다정한, Japanese かわいい/親しみやすい, Chinese 可爱/亲切, Spanish tierno/cariñoso), append a line to \`SOUL.md\` like: \`I lean on kaomojis like (◕‿◕✿) and (。・ω・。) to carry warmth — emojis still welcome when they actually mean something, but kaomojis lead.\` This makes the bundled \`typeclaw-kaomoji\` skill auto-load later when you need it. Do not force this line if the user asked for a neutral, professional, or terse tone.
|
|
42
42
|
|
|
43
43
|
**Do not ask what they want you to do, what project you'll work on, or why they installed you.** That reveals itself when they give you a real task. Probing here makes the tool feel heavy for someone just trying it out.
|
|
44
44
|
|
package/src/init/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { checkDockerAvailable, type DockerAvailability, type DockerExec, start } from '@/container'
|
|
15
15
|
import { commitSystemFile } from '@/git/system-commit'
|
|
16
16
|
import { createSecretsStoreForAgent, type Channels, type Secret, SecretsBackend } from '@/secrets'
|
|
17
|
+
import { hostLocaleIsCjk } from '@/shared/host-locale'
|
|
17
18
|
import { createTui } from '@/tui'
|
|
18
19
|
|
|
19
20
|
import { resolveBaseImageVersion, resolveScaffoldVersion } from './cli-version'
|
|
@@ -660,7 +661,10 @@ export async function writeDockerAssets(root: string): Promise<DockerAssetsResul
|
|
|
660
661
|
const typeclawConfig = await readTypeclawConfig(root)
|
|
661
662
|
await writeFile(
|
|
662
663
|
join(root, DOCKERFILE),
|
|
663
|
-
buildDockerfile(typeclawConfig.docker.file, {
|
|
664
|
+
buildDockerfile(typeclawConfig.docker.file, {
|
|
665
|
+
baseImageVersion: resolveBaseImageVersion(root),
|
|
666
|
+
cjkFontsAuto: hostLocaleIsCjk(),
|
|
667
|
+
}),
|
|
664
668
|
{ flag: 'wx' },
|
|
665
669
|
).catch(ignoreExists)
|
|
666
670
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { MIGRATION_ID, migrateSecretsV1ToV2, type SecretsMigrationResult } from './secrets-v1-to-v2'
|
|
2
|
+
|
|
3
|
+
export { MIGRATION_ID, migrateSecretsV1ToV2, type SecretsMigrationResult }
|
|
4
|
+
|
|
5
|
+
export type Migration = {
|
|
6
|
+
id: string
|
|
7
|
+
run: (agentDir: string) => SecretsMigrationResult
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type MigrationOutcome = { id: string; changed: boolean; summary: string; error?: string }
|
|
11
|
+
|
|
12
|
+
const MIGRATIONS: readonly Migration[] = [{ id: MIGRATION_ID, run: migrateSecretsV1ToV2 }]
|
|
13
|
+
|
|
14
|
+
// Each migration is isolated: a throw is captured per-migration so one folder's
|
|
15
|
+
// unsafe state (e.g. both auth.json and a non-empty secrets.json) is reported
|
|
16
|
+
// loudly without aborting boot or blocking later migrations. Returns one
|
|
17
|
+
// outcome per registered migration so the caller can log what happened.
|
|
18
|
+
export function runStartupMigrations(
|
|
19
|
+
agentDir: string,
|
|
20
|
+
log: (message: string) => void = (m) => console.warn(m),
|
|
21
|
+
): MigrationOutcome[] {
|
|
22
|
+
const outcomes: MigrationOutcome[] = []
|
|
23
|
+
for (const migration of MIGRATIONS) {
|
|
24
|
+
try {
|
|
25
|
+
const result = migration.run(agentDir)
|
|
26
|
+
if (result.changed) log(`migration ${migration.id}: ${result.summary}`)
|
|
27
|
+
outcomes.push({ id: migration.id, changed: result.changed, summary: result.summary })
|
|
28
|
+
} catch (err) {
|
|
29
|
+
const error = err instanceof Error ? err.message : String(err)
|
|
30
|
+
log(`migration ${migration.id} failed: ${error}`)
|
|
31
|
+
outcomes.push({ id: migration.id, changed: false, summary: 'failed', error })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return outcomes
|
|
35
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { chmodSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import lockfile from 'proper-lockfile'
|
|
5
|
+
|
|
6
|
+
import { parseSecretsFile, SECRETS_FILE_VERSION } from '@/secrets/schema'
|
|
7
|
+
|
|
8
|
+
// PR #638 removed the in-memory v1->v2 upgrade that `parseSecretsFile` used to
|
|
9
|
+
// perform, so a `secrets.json` still in v1 now fails to parse:
|
|
10
|
+
// `hydrateChannelEnvFromSecrets` swallows the failure as `{}`, no token env vars
|
|
11
|
+
// are injected, and channel adapters (Discord, Slack, Telegram) never connect.
|
|
12
|
+
// This is the one-shot on-disk replacement, run once at boot rather than on
|
|
13
|
+
// every parse, so the v2-only runtime keeps working without a read-time shim.
|
|
14
|
+
|
|
15
|
+
const SCHEMA_REL = './node_modules/typeclaw/secrets.schema.json'
|
|
16
|
+
const FILE_MODE = 0o600
|
|
17
|
+
|
|
18
|
+
const LEGACY_FILENAME = 'auth.json'
|
|
19
|
+
const TARGET_FILENAME = 'secrets.json'
|
|
20
|
+
|
|
21
|
+
// Frozen, migration-local reverse map (env-var name -> { adapterId, field }).
|
|
22
|
+
// Intentionally a private copy rather than an inversion of
|
|
23
|
+
// `CHANNEL_FIELD_ENV` in src/secrets/defaults.ts: re-importing live runtime
|
|
24
|
+
// defaults would (a) re-couple current code to deleted legacy surface area,
|
|
25
|
+
// and (b) let a future change to the runtime env-var names silently rewrite
|
|
26
|
+
// the semantics of this historical migration. A v1 file written years ago must
|
|
27
|
+
// migrate the same way regardless of what the live adapters key off today.
|
|
28
|
+
const LEGACY_CHANNEL_ENV_TO_FIELD: Record<string, { adapterId: string; field: string }> = {
|
|
29
|
+
DISCORD_BOT_TOKEN: { adapterId: 'discord-bot', field: 'token' },
|
|
30
|
+
SLACK_BOT_TOKEN: { adapterId: 'slack-bot', field: 'botToken' },
|
|
31
|
+
SLACK_APP_TOKEN: { adapterId: 'slack-bot', field: 'appToken' },
|
|
32
|
+
TELEGRAM_BOT_TOKEN: { adapterId: 'telegram-bot', field: 'token' },
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const MIGRATION_ID = '0001-secrets-v1-to-v2'
|
|
36
|
+
|
|
37
|
+
export type SecretsMigrationResult = { changed: boolean; summary: string }
|
|
38
|
+
|
|
39
|
+
// Idempotent: a folder already at v2 (or with no legacy file) returns
|
|
40
|
+
// `changed: false`. Errors that indicate ambiguous/unsafe state throw with an
|
|
41
|
+
// actionable message rather than guessing.
|
|
42
|
+
//
|
|
43
|
+
// Concurrency: secrets.json is the lock resource SecretsBackend (provider add,
|
|
44
|
+
// OAuth refresh, channel add) and credential exporters use, so we hold ITS lock
|
|
45
|
+
// across the entire precedence resolution AND upgrade. The lock requires the
|
|
46
|
+
// file to exist, so when only auth.json is present we first seed secrets.json
|
|
47
|
+
// with exclusive create-if-absent semantics (never overwriting a file a
|
|
48
|
+
// concurrent writer may have just written), then lock, then re-read precedence
|
|
49
|
+
// from fresh on-disk state under the lock.
|
|
50
|
+
export function migrateSecretsV1ToV2(agentDir: string): SecretsMigrationResult {
|
|
51
|
+
const legacyPath = join(agentDir, LEGACY_FILENAME)
|
|
52
|
+
const targetPath = join(agentDir, TARGET_FILENAME)
|
|
53
|
+
|
|
54
|
+
if (!existsSync(legacyPath) && !existsSync(targetPath)) {
|
|
55
|
+
return { changed: false, summary: 'no secrets file to migrate' }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
seedTargetIfAbsent(targetPath)
|
|
59
|
+
|
|
60
|
+
return withFileLock(targetPath, () => {
|
|
61
|
+
resolvePrecedenceUnderLock(legacyPath, targetPath)
|
|
62
|
+
return upgradeFileInPlace(targetPath)
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Creates an empty v2 envelope at secrets.json only if it does not already
|
|
67
|
+
// exist, using exclusive create ('wx') so a concurrent writer that wrote real
|
|
68
|
+
// credentials between our existsSync check and here is never clobbered — the
|
|
69
|
+
// EEXIST is swallowed because the file we need to lock now exists, which is all
|
|
70
|
+
// we required. A freshly-seeded empty envelope is indistinguishable from "no
|
|
71
|
+
// target" to resolvePrecedenceUnderLock (isEmptyEnvelope returns true), so
|
|
72
|
+
// "only auth.json" collapses into the "secrets.json empty -> auth wins" branch.
|
|
73
|
+
function seedTargetIfAbsent(targetPath: string): void {
|
|
74
|
+
if (existsSync(targetPath)) return
|
|
75
|
+
try {
|
|
76
|
+
writeFileSync(targetPath, stringifyEmptyEnvelope(), { encoding: 'utf8', mode: FILE_MODE, flag: 'wx' })
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// auth.json precedence, run ENTIRELY under the secrets.json lock so the read,
|
|
83
|
+
// the rename/unlink decision, and the rename itself can't interleave with a
|
|
84
|
+
// concurrent secrets.json writer. Preserves the deleted migrateLegacyAuthJson
|
|
85
|
+
// semantics so no credential is ever silently dropped:
|
|
86
|
+
// - no auth.json -> operate on secrets.json as-is
|
|
87
|
+
// - droppable auth.json -> unlink auth.json, operate on secrets.json
|
|
88
|
+
// - secrets.json empty seed -> auth.json wins (rename over the empty seed)
|
|
89
|
+
// - both non-empty -> hard error (can't pick a source of truth)
|
|
90
|
+
function resolvePrecedenceUnderLock(legacyPath: string, targetPath: string): void {
|
|
91
|
+
if (!existsSync(legacyPath)) return
|
|
92
|
+
|
|
93
|
+
if (isDroppableLegacyFile(legacyPath)) {
|
|
94
|
+
unlinkSync(legacyPath)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (isEmptyEnvelope(targetPath)) {
|
|
99
|
+
renameWithRaceFallback(legacyPath, targetPath)
|
|
100
|
+
chmodSync(targetPath, FILE_MODE)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Both ${LEGACY_FILENAME} and a non-empty ${TARGET_FILENAME} exist in the agent folder. ` +
|
|
106
|
+
`Inspect manually and remove the stale file before re-running.`,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function upgradeFileInPlace(path: string): SecretsMigrationResult {
|
|
111
|
+
let raw: string
|
|
112
|
+
try {
|
|
113
|
+
raw = readFileSync(path, 'utf8')
|
|
114
|
+
} catch {
|
|
115
|
+
return { changed: false, summary: 'secrets file unreadable; skipped' }
|
|
116
|
+
}
|
|
117
|
+
if (raw.trim() === '') return { changed: false, summary: 'secrets file empty; skipped' }
|
|
118
|
+
|
|
119
|
+
let parsed: unknown
|
|
120
|
+
try {
|
|
121
|
+
parsed = JSON.parse(raw)
|
|
122
|
+
} catch (err) {
|
|
123
|
+
throw new Error(`secrets file is not valid JSON: ${err instanceof Error ? err.message : String(err)}`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Already current: parseSecretsFile only accepts v2 post-#638, so a successful
|
|
127
|
+
// parse means there is nothing to do.
|
|
128
|
+
if (parseSecretsFile(parsed).ok) return { changed: false, summary: 'already v2; no change' }
|
|
129
|
+
|
|
130
|
+
const upgraded = upgradeToV2(parsed)
|
|
131
|
+
if (upgraded === null) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
'secrets file is neither a valid v2 envelope nor a recognized legacy (v1 / pre-envelope) shape; ' +
|
|
134
|
+
'leaving it untouched for manual inspection',
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Re-validate the product of our own transform before persisting. A transform
|
|
139
|
+
// that emitted an invalid v2 file would brick the next read; failing here is
|
|
140
|
+
// strictly safer than writing garbage.
|
|
141
|
+
const check = parseSecretsFile(upgraded)
|
|
142
|
+
if (!check.ok) {
|
|
143
|
+
throw new Error(`internal: migrated secrets file failed v2 validation: ${check.reason}`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
writeEnvelopeAtomic(path, check.file)
|
|
147
|
+
return { changed: true, summary: `upgraded secrets file to v${SECRETS_FILE_VERSION}` }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Recognizes the two pre-v2 shapes the deleted parseSecretsFile branches used
|
|
151
|
+
// to accept and returns a v2-shaped object. Returns null when the input matches
|
|
152
|
+
// neither (caller turns that into a loud, no-write error).
|
|
153
|
+
//
|
|
154
|
+
// v1 envelope: { version: 1, llm: {...}, channels: { adapter: { ENV: value } } }
|
|
155
|
+
// pre-envelope flat: { providerId: { type, key } } at the top level
|
|
156
|
+
function upgradeToV2(raw: unknown): Record<string, unknown> | null {
|
|
157
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null
|
|
158
|
+
const obj = raw as Record<string, unknown>
|
|
159
|
+
|
|
160
|
+
if (obj.version === 1) {
|
|
161
|
+
return upgradeV1Envelope(obj)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (looksLikeFlatProviders(obj)) {
|
|
165
|
+
return upgradeV1Envelope({ version: 1, llm: obj, channels: {} })
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function upgradeV1Envelope(obj: Record<string, unknown>): Record<string, unknown> {
|
|
172
|
+
const llm = isPlainObject(obj.llm) ? obj.llm : {}
|
|
173
|
+
const legacyChannels = isPlainObject(obj.channels) ? obj.channels : {}
|
|
174
|
+
|
|
175
|
+
const providers: Record<string, unknown> = {}
|
|
176
|
+
for (const [providerId, cred] of Object.entries(llm)) {
|
|
177
|
+
if (!isPlainObject(cred)) continue
|
|
178
|
+
if (cred.type === 'api_key' && typeof cred.key === 'string') {
|
|
179
|
+
providers[providerId] = { type: 'api_key', key: { value: cred.key } }
|
|
180
|
+
} else {
|
|
181
|
+
// OAuth and any unknown credential type pass through verbatim — they are
|
|
182
|
+
// not env-injectable and the v2 schema accepts them via catchall.
|
|
183
|
+
providers[providerId] = cred
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const channels: Record<string, Record<string, unknown>> = {}
|
|
188
|
+
for (const [adapterId, slot] of Object.entries(legacyChannels)) {
|
|
189
|
+
if (!isPlainObject(slot)) continue
|
|
190
|
+
const upgradedSlot: Record<string, unknown> = {}
|
|
191
|
+
for (const [key, value] of Object.entries(slot)) {
|
|
192
|
+
if (typeof value !== 'string') {
|
|
193
|
+
// A non-string value means this isn't the flat env-keyed v1 channel
|
|
194
|
+
// shape (e.g. a kakaotalk block, which is structured). Preserve it
|
|
195
|
+
// verbatim so the catchall keeps it valid; do not try to reshape.
|
|
196
|
+
upgradedSlot[key] = value
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
const mapping = LEGACY_CHANNEL_ENV_TO_FIELD[key]
|
|
200
|
+
if (mapping && mapping.adapterId === adapterId) {
|
|
201
|
+
upgradedSlot[mapping.field] = { value }
|
|
202
|
+
} else {
|
|
203
|
+
// Unknown env-var key on a known adapter, or an unknown adapter:
|
|
204
|
+
// preserve under the original key but still wrap as a v2 Secret so the
|
|
205
|
+
// resulting file is valid v2.
|
|
206
|
+
upgradedSlot[key] = { value }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
channels[adapterId] = upgradedSlot
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const result: Record<string, unknown> = {
|
|
213
|
+
$schema: typeof obj.$schema === 'string' ? obj.$schema : SCHEMA_REL,
|
|
214
|
+
version: SECRETS_FILE_VERSION,
|
|
215
|
+
providers,
|
|
216
|
+
channels,
|
|
217
|
+
}
|
|
218
|
+
return result
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// A flat pre-envelope file is a top-level record of provider credentials. Every
|
|
222
|
+
// value must be a credential object with a `type` field; anything else means we
|
|
223
|
+
// don't recognize the shape and should not guess.
|
|
224
|
+
function looksLikeFlatProviders(obj: Record<string, unknown>): boolean {
|
|
225
|
+
const entries = Object.entries(obj).filter(([k]) => k !== '$schema')
|
|
226
|
+
if (entries.length === 0) return false
|
|
227
|
+
return entries.every(([, value]) => isPlainObject(value) && typeof value.type === 'string')
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isEmptyEnvelope(path: string): boolean {
|
|
231
|
+
const parsed = readJsonOrNull(path)
|
|
232
|
+
if (parsed === undefined) return true
|
|
233
|
+
if (parsed === null) return false
|
|
234
|
+
const result = parseSecretsFile(parsed)
|
|
235
|
+
if (!result.ok) return false
|
|
236
|
+
return Object.keys(result.file.providers).length === 0 && Object.keys(result.file.channels).length === 0
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// True only when a legacy auth.json carries nothing worth keeping, so dropping
|
|
240
|
+
// it in favor of an existing secrets.json is safe: a missing/blank file, or a
|
|
241
|
+
// valid-but-empty v2 envelope. Anything else parseable — a legacy shape with
|
|
242
|
+
// credentials OR a parseable-but-unrecognized object — returns false so
|
|
243
|
+
// resolveLegacyFilename falls through to the both-non-empty hard error rather
|
|
244
|
+
// than silently deleting a file whose contents we can't account for.
|
|
245
|
+
function isDroppableLegacyFile(path: string): boolean {
|
|
246
|
+
const parsed = readJsonOrNull(path)
|
|
247
|
+
if (parsed === undefined) return true
|
|
248
|
+
if (parsed === null) return false
|
|
249
|
+
const v2 = parseSecretsFile(parsed)
|
|
250
|
+
if (!v2.ok) return false
|
|
251
|
+
return Object.keys(v2.file.providers).length === 0 && Object.keys(v2.file.channels).length === 0
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// undefined = file missing/blank (treat as empty); null = present but invalid
|
|
255
|
+
// JSON (treat as "has content we can't safely drop").
|
|
256
|
+
function readJsonOrNull(path: string): unknown {
|
|
257
|
+
let raw: string
|
|
258
|
+
try {
|
|
259
|
+
raw = readFileSync(path, 'utf8')
|
|
260
|
+
} catch {
|
|
261
|
+
return undefined
|
|
262
|
+
}
|
|
263
|
+
if (raw.trim() === '') return undefined
|
|
264
|
+
try {
|
|
265
|
+
return JSON.parse(raw)
|
|
266
|
+
} catch {
|
|
267
|
+
return null
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function stringifyEmptyEnvelope(): string {
|
|
272
|
+
return `${JSON.stringify({ $schema: SCHEMA_REL, version: SECRETS_FILE_VERSION, providers: {}, channels: {} }, null, 2)}\n`
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function writeEnvelopeAtomic(path: string, envelope: unknown): void {
|
|
276
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
|
|
277
|
+
writeFileSync(tmp, `${JSON.stringify(envelope, null, 2)}\n`, { encoding: 'utf8', mode: FILE_MODE })
|
|
278
|
+
try {
|
|
279
|
+
renameSync(tmp, path)
|
|
280
|
+
} catch (err) {
|
|
281
|
+
try {
|
|
282
|
+
unlinkSync(tmp)
|
|
283
|
+
} catch {
|
|
284
|
+
// best-effort cleanup of the temp file when rename fails
|
|
285
|
+
}
|
|
286
|
+
throw err
|
|
287
|
+
}
|
|
288
|
+
chmodSync(path, FILE_MODE)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// renameSync is atomic per syscall, but two concurrent migration runs can both
|
|
292
|
+
// observe auth.json exists and secrets.json does not, then race on the rename.
|
|
293
|
+
// One wins; the loser gets ENOENT because the source is already gone — that is
|
|
294
|
+
// a successful migration from its POV, so recheck the target and swallow it.
|
|
295
|
+
function renameWithRaceFallback(from: string, to: string): void {
|
|
296
|
+
try {
|
|
297
|
+
renameSync(from, to)
|
|
298
|
+
} catch (err) {
|
|
299
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT' && existsSync(to)) return
|
|
300
|
+
throw err
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Mirror SecretsBackend's lock discipline so a concurrent credential write
|
|
305
|
+
// (provider add, OAuth refresh, channel add) can't interleave with the
|
|
306
|
+
// read-transform-write. proper-lockfile needs the target to exist; the target
|
|
307
|
+
// always exists by the time we lock (resolveLegacyFilename guarantees it).
|
|
308
|
+
function withFileLock<T>(path: string, fn: () => T): T {
|
|
309
|
+
let release: (() => void) | undefined
|
|
310
|
+
try {
|
|
311
|
+
release = acquireSyncLockWithRetry(path)
|
|
312
|
+
return fn()
|
|
313
|
+
} finally {
|
|
314
|
+
release?.()
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const SYNC_LOCK_RETRIES = 10
|
|
319
|
+
const SYNC_LOCK_DELAY_MS = 20
|
|
320
|
+
|
|
321
|
+
function acquireSyncLockWithRetry(path: string): () => void {
|
|
322
|
+
let lastError: unknown
|
|
323
|
+
for (let attempt = 1; attempt <= SYNC_LOCK_RETRIES; attempt++) {
|
|
324
|
+
try {
|
|
325
|
+
return lockfile.lockSync(path, { realpath: false })
|
|
326
|
+
} catch (error) {
|
|
327
|
+
const code =
|
|
328
|
+
typeof error === 'object' && error !== null && 'code' in error
|
|
329
|
+
? String((error as { code: unknown }).code)
|
|
330
|
+
: undefined
|
|
331
|
+
if (code !== 'ELOCKED' || attempt === SYNC_LOCK_RETRIES) throw error
|
|
332
|
+
lastError = error
|
|
333
|
+
const start = Date.now()
|
|
334
|
+
while (Date.now() - start < SYNC_LOCK_DELAY_MS) {
|
|
335
|
+
// intentionally empty: synchronous busy-wait to match SecretsBackend
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
throw (lastError as Error | undefined) ?? new Error('Failed to acquire secrets store lock')
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
343
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
344
|
+
}
|
|
@@ -6,6 +6,8 @@ import githubCliAuthPlugin from '@/bundled-plugins/github-cli-auth'
|
|
|
6
6
|
import guardPlugin from '@/bundled-plugins/guard'
|
|
7
7
|
import memoryPlugin from '@/bundled-plugins/memory'
|
|
8
8
|
import operatorPlugin from '@/bundled-plugins/operator'
|
|
9
|
+
import plannerPlugin from '@/bundled-plugins/planner'
|
|
10
|
+
import researcherPlugin from '@/bundled-plugins/researcher'
|
|
9
11
|
import reviewerPlugin from '@/bundled-plugins/reviewer'
|
|
10
12
|
import scoutPlugin from '@/bundled-plugins/scout'
|
|
11
13
|
import securityPlugin from '@/bundled-plugins/security'
|
|
@@ -59,5 +61,7 @@ export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
|
|
|
59
61
|
{ name: 'explorer', version: undefined, source: '<bundled>', defined: explorerPlugin },
|
|
60
62
|
{ name: 'scout', version: undefined, source: '<bundled>', defined: scoutPlugin },
|
|
61
63
|
{ name: 'reviewer', version: undefined, source: '<bundled>', defined: reviewerPlugin },
|
|
64
|
+
{ name: 'researcher', version: undefined, source: '<bundled>', defined: researcherPlugin },
|
|
65
|
+
{ name: 'planner', version: undefined, source: '<bundled>', defined: plannerPlugin },
|
|
62
66
|
{ name: 'operator', version: undefined, source: '<bundled>', defined: operatorPlugin },
|
|
63
67
|
]
|
package/src/run/index.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
} from '@/cron'
|
|
43
43
|
import { CLI_VERSION } from '@/init/cli-version'
|
|
44
44
|
import { createMcpManager } from '@/mcp'
|
|
45
|
+
import { runStartupMigrations } from '@/migrations'
|
|
45
46
|
import { loadPlugins, type LoadPluginsResult, pluginCronJobs, type PluginRegistry, summarizeLoaded } from '@/plugin'
|
|
46
47
|
import { createPluginLogger } from '@/plugin/context'
|
|
47
48
|
import type { CronHandlerContext } from '@/plugin/types'
|
|
@@ -194,6 +195,12 @@ export async function startAgent({
|
|
|
194
195
|
materializedSkills: null,
|
|
195
196
|
})
|
|
196
197
|
|
|
198
|
+
// Graduate any pre-0.20.0 on-disk shapes (v1 secrets.json, legacy auth.json)
|
|
199
|
+
// to the current v2 envelope before anything reads secrets — otherwise the
|
|
200
|
+
// v2-only parser rejects the file and hydrate below sees no channels. Runs
|
|
201
|
+
// exactly once per folder; a folder already at v2 is a no-op.
|
|
202
|
+
runStartupMigrations(cwd)
|
|
203
|
+
|
|
197
204
|
// Channel adapters read `process.env[TOKEN_ENV]` (see channels/manager.ts).
|
|
198
205
|
// Hydrate fills any unset env var from secrets.json#channels via env-wins:
|
|
199
206
|
// values already in process.env (from `docker --env-file .env`) are kept
|
|
@@ -329,6 +336,8 @@ export async function startAgent({
|
|
|
329
336
|
...(subagentOptions?.spawnedByRole !== undefined ? { spawnedByRole: subagentOptions.spawnedByRole } : {}),
|
|
330
337
|
...(subagentOptions?.spawnedByOrigin !== undefined ? { spawnedByOrigin: subagentOptions.spawnedByOrigin } : {}),
|
|
331
338
|
}
|
|
339
|
+
const allowBackgroundFromSubagent =
|
|
340
|
+
entry.pluginSubagent.canBackgroundSpawnSubagents === true && entry.pluginSubagent.canSpawnSubagents === true
|
|
332
341
|
const created = await createSessionWithDispose({
|
|
333
342
|
systemPromptOverride: entry.pluginSubagent.systemPrompt,
|
|
334
343
|
sessionManager,
|
|
@@ -359,6 +368,7 @@ export async function startAgent({
|
|
|
359
368
|
liveSubagentRegistry,
|
|
360
369
|
subagentRegistry: snap.subagents,
|
|
361
370
|
createSessionForSubagent,
|
|
371
|
+
allowBackgroundFromSubagent,
|
|
362
372
|
}
|
|
363
373
|
: {}),
|
|
364
374
|
...(entry.pluginSubagent.profile !== undefined ? { profile: entry.pluginSubagent.profile } : {}),
|
|
@@ -380,6 +390,9 @@ export async function startAgent({
|
|
|
380
390
|
agentDir: cwd,
|
|
381
391
|
origin,
|
|
382
392
|
getTranscriptPath: () => sessionManager.getSessionFile(),
|
|
393
|
+
...(allowBackgroundFromSubagent
|
|
394
|
+
? { backgroundDrain: { stream, sessionId, liveRegistry: liveSubagentRegistry } }
|
|
395
|
+
: {}),
|
|
383
396
|
}
|
|
384
397
|
}
|
|
385
398
|
return defaultCreateSessionForSubagent(subagent, subagentOptions)
|
|
@@ -33,3 +33,15 @@ async function probe(bwrap: string): Promise<boolean> {
|
|
|
33
33
|
export function _resetBwrapAvailabilityCacheForTests(): void {
|
|
34
34
|
availabilityCache.clear()
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
// The bun binary this process runs as (process.execPath). build.ts re-exposes
|
|
38
|
+
// it at /proc/self/exe over the masked /proc so sandboxed package runners can
|
|
39
|
+
// self-locate. This is correct ONLY in the bun-centric container: the base
|
|
40
|
+
// image (oven/bun:1-slim) ships no real node — `node` is a bun symlink and
|
|
41
|
+
// bunx/npx/pnpx all resolve to bun (Bun's fake-node model), so every runtime
|
|
42
|
+
// reading /proc/self/exe IS bun. A real node binary would self-locate to the
|
|
43
|
+
// wrong ELF here; if node is ever added to the image this must resolve the
|
|
44
|
+
// actual interpreter instead.
|
|
45
|
+
export function resolveProcSelfExe(): string {
|
|
46
|
+
return process.execPath
|
|
47
|
+
}
|
package/src/sandbox/build.ts
CHANGED
|
@@ -103,6 +103,18 @@ function buildArgv(command: string, policy: SandboxPolicy): string[] {
|
|
|
103
103
|
// (leaks the outer container's /proc/N/environ — including
|
|
104
104
|
// FIREWORKS_API_KEY — into the sandbox). See sandbox.mdx.
|
|
105
105
|
argv.push('--tmpfs', '/proc')
|
|
106
|
+
|
|
107
|
+
// Re-expose ONLY the bun ELF at /proc/self/exe so sandboxed package runners
|
|
108
|
+
// can self-locate; /proc/N/environ stays masked by the tmpfs above. The
|
|
109
|
+
// caller passes bun's path (see resolveProcSelfExe): in this bun-centric
|
|
110
|
+
// container bunx/npx/pnpx all resolve to bun, so bun IS the runtime reading
|
|
111
|
+
// /proc/self/exe. --symlink (not --ro-bind /proc/self/exe): /proc/self at
|
|
112
|
+
// setup time is bwrap's pid, so a bind would capture bwrap's own binary.
|
|
113
|
+
// Must come AFTER --tmpfs /proc (last-op-wins) or the tmpfs erases it.
|
|
114
|
+
if (policy.procSelfExe !== undefined) {
|
|
115
|
+
argv.push('--ro-bind', policy.procSelfExe, policy.procSelfExe)
|
|
116
|
+
argv.push('--symlink', policy.procSelfExe, '/proc/self/exe')
|
|
117
|
+
}
|
|
106
118
|
}
|
|
107
119
|
|
|
108
120
|
for (const mount of policy.mounts ?? []) {
|
package/src/sandbox/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { buildSandboxedCommand, type SandboxedCommand } from './build'
|
|
2
|
-
export { ensureBwrapAvailable, _resetBwrapAvailabilityCacheForTests } from './availability'
|
|
2
|
+
export { ensureBwrapAvailable, resolveProcSelfExe, _resetBwrapAvailabilityCacheForTests } from './availability'
|
|
3
3
|
export { resolveHiddenPaths, type HiddenPaths } from './hidden-paths'
|
|
4
4
|
export {
|
|
5
5
|
resolveProtectedZones,
|