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.
Files changed (82) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +43 -5
  3. package/src/agent/live-subagents.ts +5 -0
  4. package/src/agent/loop-guard.ts +112 -26
  5. package/src/agent/plugin-tools.ts +167 -50
  6. package/src/agent/session-origin.ts +3 -3
  7. package/src/agent/subagent-drain.ts +150 -0
  8. package/src/agent/subagents.ts +41 -3
  9. package/src/agent/system-prompt.ts +29 -4
  10. package/src/agent/tools/channel-send.ts +1 -1
  11. package/src/agent/tools/spawn-subagent.ts +34 -1
  12. package/src/agent/tools/subagent-output.ts +7 -3
  13. package/src/agent/tools/wikipedia.ts +1 -1
  14. package/src/bundled-plugins/bun-hygiene/README.md +12 -11
  15. package/src/bundled-plugins/bun-hygiene/policy.ts +8 -3
  16. package/src/bundled-plugins/explorer/explorer.ts +2 -0
  17. package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +94 -0
  18. package/src/bundled-plugins/github-cli-auth/effective-approval.ts +98 -0
  19. package/src/bundled-plugins/github-cli-auth/gh-review-inline-detect.ts +130 -0
  20. package/src/bundled-plugins/github-cli-auth/index.ts +27 -2
  21. package/src/bundled-plugins/github-cli-auth/review-recorder.ts +12 -4
  22. package/src/bundled-plugins/memory/memory-logger.ts +3 -3
  23. package/src/bundled-plugins/operator/operator.ts +2 -0
  24. package/src/bundled-plugins/planner/index.ts +11 -0
  25. package/src/bundled-plugins/planner/planner.ts +283 -0
  26. package/src/bundled-plugins/planner/skills/general.ts +65 -0
  27. package/src/bundled-plugins/planner/skills/project.ts +69 -0
  28. package/src/bundled-plugins/researcher/index.ts +11 -0
  29. package/src/bundled-plugins/researcher/researcher.ts +233 -0
  30. package/src/bundled-plugins/researcher/skills/general.ts +105 -0
  31. package/src/bundled-plugins/researcher/write-report.ts +107 -0
  32. package/src/bundled-plugins/reviewer/reviewer.ts +28 -9
  33. package/src/bundled-plugins/reviewer/skills/data-review.ts +77 -0
  34. package/src/bundled-plugins/reviewer/skills/doc-review.ts +79 -0
  35. package/src/bundled-plugins/reviewer/skills/plan-review.ts +64 -0
  36. package/src/bundled-plugins/reviewer/skills/security-audit.ts +70 -0
  37. package/src/bundled-plugins/reviewer/skills/writing-review.ts +63 -0
  38. package/src/bundled-plugins/scout/scout.ts +2 -0
  39. package/src/bundled-plugins/security/policies/prompt-injection.ts +8 -4
  40. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +3 -2
  41. package/src/channels/adapters/discord-bot.ts +38 -11
  42. package/src/channels/adapters/github/inbound.ts +68 -4
  43. package/src/channels/adapters/kakaotalk-classify.ts +2 -2
  44. package/src/channels/adapters/kakaotalk.ts +2 -2
  45. package/src/channels/adapters/slack-bot-classify.ts +1 -1
  46. package/src/channels/adapters/slack-bot.ts +3 -0
  47. package/src/channels/adapters/telegram-bot.ts +3 -0
  48. package/src/channels/engagement.ts +12 -7
  49. package/src/channels/github-review-claim.ts +15 -3
  50. package/src/channels/router.ts +85 -9
  51. package/src/channels/schema.ts +1 -1
  52. package/src/channels/types.ts +6 -0
  53. package/src/cli/init.ts +13 -2
  54. package/src/cli/ui.ts +64 -0
  55. package/src/config/config.ts +21 -15
  56. package/src/container/start.ts +5 -1
  57. package/src/init/dockerfile.ts +19 -56
  58. package/src/init/hatching.ts +1 -1
  59. package/src/init/index.ts +5 -1
  60. package/src/migrations/index.ts +35 -0
  61. package/src/migrations/secrets-v1-to-v2.ts +344 -0
  62. package/src/run/bundled-plugins.ts +4 -0
  63. package/src/run/index.ts +13 -0
  64. package/src/sandbox/availability.ts +12 -0
  65. package/src/sandbox/build.ts +12 -0
  66. package/src/sandbox/index.ts +1 -1
  67. package/src/sandbox/policy.ts +8 -0
  68. package/src/server/index.ts +24 -5
  69. package/src/shared/host-locale.ts +27 -0
  70. package/src/shared/protocol.ts +1 -1
  71. package/src/shared/wordmark.ts +19 -0
  72. package/src/skills/typeclaw-config/SKILL.md +32 -32
  73. package/src/skills/typeclaw-kaomoji/SKILL.md +3 -3
  74. package/src/skills/typeclaw-tunnels/SKILL.md +3 -1
  75. package/src/tui/banner.ts +19 -0
  76. package/src/tui/format.ts +34 -0
  77. package/src/tui/index.ts +121 -22
  78. package/src/tui/theme.ts +26 -1
  79. package/src/tunnels/providers/cloudflare-named.ts +15 -4
  80. package/src/tunnels/providers/cloudflare-quick.ts +15 -4
  81. package/src/tunnels/providers/cloudflared-binary.ts +11 -0
  82. package/typeclaw.schema.json +15 -7
@@ -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' | 'cjkFonts' | 'xvfb', AptFeature> = {
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 toggleAptArgs = collectToggleAptArgs(config)
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: true,
1514
- cloudflared: true,
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', 'cjkFonts', 'xvfb'] as const) {
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
 
@@ -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 is in Korean asking for 친근/귀엽/다정한 tone, 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.
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, { baseImageVersion: resolveBaseImageVersion(root) }),
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
+ }
@@ -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 ?? []) {
@@ -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,