typeclaw 0.4.0 → 0.5.1

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 (56) hide show
  1. package/package.json +1 -1
  2. package/src/agent/auth.ts +4 -2
  3. package/src/agent/index.ts +16 -28
  4. package/src/agent/model-fallback.ts +127 -0
  5. package/src/agent/tools/curl-impersonate.ts +300 -0
  6. package/src/agent/tools/ddg.ts +13 -88
  7. package/src/agent/tools/webfetch/fetch.ts +105 -2
  8. package/src/agent/tools/webfetch/tool.ts +4 -0
  9. package/src/bundled-plugins/agent-browser/shim.ts +47 -0
  10. package/src/bundled-plugins/backup/subagents.ts +2 -0
  11. package/src/bundled-plugins/memory/README.md +49 -12
  12. package/src/bundled-plugins/memory/citation-superset.ts +63 -0
  13. package/src/bundled-plugins/memory/dreaming.ts +105 -17
  14. package/src/bundled-plugins/memory/index.ts +2 -2
  15. package/src/bundled-plugins/memory/memory-logger.ts +45 -26
  16. package/src/bundled-plugins/memory/strength.ts +127 -0
  17. package/src/bundled-plugins/memory/topics.ts +75 -0
  18. package/src/bundled-plugins/security/index.ts +87 -43
  19. package/src/bundled-plugins/security/permissions.ts +36 -0
  20. package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
  21. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
  22. package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
  23. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
  24. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
  25. package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
  26. package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
  27. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
  28. package/src/channels/adapters/github/index.ts +87 -3
  29. package/src/channels/router.ts +194 -28
  30. package/src/channels/types.ts +3 -1
  31. package/src/cli/channel.ts +2 -45
  32. package/src/cli/init.ts +148 -87
  33. package/src/cli/model.ts +12 -3
  34. package/src/cli/oauth-callbacks.ts +49 -0
  35. package/src/cli/provider.ts +3 -20
  36. package/src/cli/ui.ts +95 -0
  37. package/src/config/config.ts +59 -24
  38. package/src/config/models-mutation.ts +42 -8
  39. package/src/config/providers-mutation.ts +12 -8
  40. package/src/container/start.ts +18 -1
  41. package/src/cron/consumer.ts +129 -43
  42. package/src/init/dockerfile.ts +221 -3
  43. package/src/init/hatching.ts +2 -2
  44. package/src/init/index.ts +47 -3
  45. package/src/init/oauth-login.ts +17 -3
  46. package/src/permissions/builtins.ts +29 -7
  47. package/src/permissions/permissions.ts +24 -7
  48. package/src/plugin/define.ts +2 -0
  49. package/src/plugin/manager.ts +14 -0
  50. package/src/plugin/types.ts +6 -0
  51. package/src/run/index.ts +2 -1
  52. package/src/skills/typeclaw-memory/SKILL.md +25 -15
  53. package/src/skills/typeclaw-permissions/SKILL.md +35 -17
  54. package/src/tui/index.ts +35 -3
  55. package/src/usage/report.ts +15 -12
  56. package/typeclaw.schema.json +57 -25
@@ -27,6 +27,12 @@ export type BuildDockerfileOptions = {
27
27
  // `util-linux` carries `setpriv`, which the shim uses to drop CAP_NET_ADMIN
28
28
  // from the bounding set before exec'ing the agent. Listed first in the
29
29
  // apt-get install line so the package set is self-documenting at a glance.
30
+ //
31
+ // xvfb is intentionally NOT in baseline — it's a toggle (`xvfb: true` by
32
+ // default, opt-out via `docker.file.xvfb: false`) because the shim
33
+ // self-heals: it spawns Xvfb (and exports DISPLAY) if the binary is on
34
+ // PATH, and execs the agent directly otherwise. See APT_FEATURES.xvfb
35
+ // below and `buildEntrypointShim`.
30
36
  const BASELINE_APT_PACKAGES = ['git', 'ca-certificates', 'curl', 'gnupg', 'iptables', 'util-linux'] as const
31
37
 
32
38
  // curl-impersonate is the only currently-working way to query DuckDuckGo from
@@ -219,7 +225,96 @@ export function buildEntrypointShim(): string {
219
225
  # Source: src/init/dockerfile.ts \`buildEntrypointShim()\`.
220
226
  set -eu
221
227
 
228
+ # start_xvfb launches Xvfb in the background under a stripped capability
229
+ # bounding set so headed Chrome (agent-browser --headed, Playwright
230
+ # headful) has a real X11 display to connect to. Headless containers
231
+ # have no display server; Chrome --headless / --headless=new is
232
+ # fingerprinted by modern bot detection (Akamai / Cloudflare BM)
233
+ # regardless of UA spoof, so real headed Chrome under a virtual
234
+ # framebuffer is the only path to a passing sensor score from a
235
+ # server-side container.
236
+ #
237
+ # Two correctness invariants this function enforces:
238
+ #
239
+ # 1. Xvfb never holds CAP_NET_ADMIN. The shim runs as PID 1 with the
240
+ # container's full capability set (including NET_ADMIN when
241
+ # network.blockInternal=true). If we backgrounded Xvfb naked, it
242
+ # would inherit NET_ADMIN and keep it for the container's lifetime
243
+ # — defeating the capability-drop contract that setpriv applies to
244
+ # the agent process. Routing Xvfb through the same setpriv invocation
245
+ # we use for the agent strips NET_ADMIN before Xvfb's first exec.
246
+ # On the off-path (blockInternal=false) the bounding-set drop is a
247
+ # no-op (NET_ADMIN was never granted), but the call is harmless.
248
+ #
249
+ # 2. Xvfb startup failure is loud, not silent. \`Xvfb ... >/dev/null &\`
250
+ # under \`set -e\` does not fail the script if Xvfb exits immediately
251
+ # (missing library, port conflict, malformed args). Without the
252
+ # explicit liveness probe below, the shim would then export DISPLAY
253
+ # and exec bun, agent-browser launches would die with "cannot open
254
+ # display", and the operator would chase a phantom bug. We capture
255
+ # $! and \`kill -0\` it on every poll iteration so an early exit
256
+ # becomes a clear stderr line and a non-zero shim exit.
257
+ #
258
+ # We DO NOT use \`xvfb-run\`. xvfb-run hangs forever when it runs as
259
+ # PID 1 inside a container: its SIGUSR1-based ready handshake races
260
+ # and stalls because PID 1 ignores signals without explicit handlers,
261
+ # so the \`trap : USR1 ; wait || :\` dance never wakes up. Observed in
262
+ # practice: container alive, Xvfb running, PID 1 stuck in
263
+ # \`rt_sigsuspend\`, no agent process ever spawns, \`docker logs\` empty.
264
+ # Documented industry workarounds are tini-as-PID-1 or direct Xvfb
265
+ # spawn; we pick the latter (no new dep).
266
+ #
267
+ # Xvfb args:
268
+ # :99 fixed display number. Filesystem
269
+ # (/tmp/.X11-unix/X99) and abstract
270
+ # (\\0/tmp/.X11-unix/X99) sockets are both
271
+ # network-namespace-scoped, so :99 is safe
272
+ # across all Compose'd containers.
273
+ # -screen 0 1920x1080x24 desktop viewport agent-browser advertises;
274
+ # mismatched geometry is itself a fingerprint
275
+ # signal.
276
+ # -ac disable host-based X access control so
277
+ # Chrome connects without XAUTHORITY plumbing.
278
+ # +extension RANDR expose the RandR extension; Chrome queries
279
+ # it for screen geometry, and without it
280
+ # \`screen.*\` values come back inconsistent.
281
+ # -nolisten tcp refuse TCP connections (Unix socket only).
282
+ # Defense-in-depth — we are in a netns with
283
+ # no inbound exposure anyway.
284
+ start_xvfb() {
285
+ if ! command -v Xvfb >/dev/null 2>&1; then
286
+ return 0
287
+ fi
288
+ setpriv --bounding-set -net_admin --inh-caps -net_admin --ambient-caps -net_admin \\
289
+ -- Xvfb :99 -screen 0 1920x1080x24 -ac +extension RANDR -nolisten tcp \\
290
+ >/dev/null 2>&1 &
291
+ xvfb_pid=$!
292
+ export DISPLAY=:99
293
+ # Poll the socket every 10ms up to ~3s. Xvfb cold start is typically
294
+ # ~20-50ms on a modern host; 3s covers slow Docker Desktop VMs,
295
+ # Rosetta/QEMU emulation, and loaded CI runners. We also \`kill -0\`
296
+ # the pid each iteration so an Xvfb that died immediately surfaces
297
+ # as a clear error instead of a 3-second hang followed by silent
298
+ # "cannot open display" downstream.
299
+ i=0
300
+ while [ $i -lt 300 ]; do
301
+ if [ -S /tmp/.X11-unix/X99 ]; then
302
+ unset i xvfb_pid
303
+ return 0
304
+ fi
305
+ if ! kill -0 "$xvfb_pid" 2>/dev/null; then
306
+ echo "typeclaw-entrypoint: Xvfb exited immediately; cannot start headed display (docker.file.xvfb=true)" >&2
307
+ exit 1
308
+ fi
309
+ sleep 0.01
310
+ i=$((i + 1))
311
+ done
312
+ echo "typeclaw-entrypoint: Xvfb did not create /tmp/.X11-unix/X99 within 3s; refusing to continue (docker.file.xvfb=true)" >&2
313
+ exit 1
314
+ }
315
+
222
316
  if [ "\${TYPECLAW_NETWORK_BLOCK_INTERNAL:-0}" != "1" ]; then
317
+ start_xvfb
223
318
  exec bun run typeclaw "$@"
224
319
  fi
225
320
 
@@ -264,6 +359,7 @@ ip6tables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
264
359
  ip6tables -A OUTPUT -o lo -j ACCEPT
265
360
  ${ipv6Rules.join('\n')}
266
361
 
362
+ start_xvfb
267
363
  exec setpriv --bounding-set -net_admin --inh-caps -net_admin --ambient-caps -net_admin -- bun run typeclaw "$@"
268
364
  `
269
365
  }
@@ -337,7 +433,7 @@ type AptFeature = {
337
433
  toAptArgs: (toggle: DockerfileFeatureToggle) => string[]
338
434
  }
339
435
 
340
- const APT_FEATURES: Record<'ffmpeg' | 'gh' | 'tmux' | 'python' | 'cjkFonts', AptFeature> = {
436
+ const APT_FEATURES: Record<'ffmpeg' | 'gh' | 'tmux' | 'python' | 'cjkFonts' | 'xvfb', AptFeature> = {
341
437
  ffmpeg: { toAptArgs: (v) => singlePackageArgs('ffmpeg', v) },
342
438
  gh: { toAptArgs: (v) => singlePackageArgs('gh', v) },
343
439
  tmux: { toAptArgs: (v) => singlePackageArgs('tmux', v) },
@@ -345,6 +441,7 @@ const APT_FEATURES: Record<'ffmpeg' | 'gh' | 'tmux' | 'python' | 'cjkFonts', Apt
345
441
  toAptArgs: (v) => (v === true ? ['python3', 'python3-pip', 'python3-venv', 'python-is-python3'] : []),
346
442
  },
347
443
  cjkFonts: { toAptArgs: (v) => (v === true ? [CJK_FONTS_PACKAGE] : []) },
444
+ xvfb: { toAptArgs: (v) => (v === true ? ['xvfb'] : []) },
348
445
  }
349
446
 
350
447
  export function buildDockerfile(
@@ -464,6 +561,8 @@ ${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
464
561
 
465
562
  ${LAYER_4_AGENT_BROWSER_INSTALL}
466
563
 
564
+ ${LAYER_4_5_AGENT_BROWSER_HEADED_WRAPPER}
565
+
467
566
  ${LAYER_5_CHROME_FOR_TESTING}
468
567
 
469
568
  ${cloudflaredLayer}${renderEntrypointShimLayer()}
@@ -541,6 +640,8 @@ ${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
541
640
 
542
641
  ${LAYER_4_AGENT_BROWSER_INSTALL}
543
642
 
643
+ ${LAYER_4_5_AGENT_BROWSER_HEADED_WRAPPER}
644
+
544
645
  ${LAYER_5_CHROME_FOR_TESTING}
545
646
 
546
647
  ${renderEntrypointShimLayer()}
@@ -602,6 +703,114 @@ const LAYER_4_AGENT_BROWSER_INSTALL = `# Layer 4 (volatile): install agent-brows
602
703
  RUN --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \\
603
704
  bun install -g agent-browser`
604
705
 
706
+ // Layer 4.5: shim the agent-browser binary with a wrapper that calls
707
+ // \`agent-browser close\` before \`open\`/\`goto\`/\`navigate\` when headed
708
+ // mode is requested. Works around vercel-labs/agent-browser issue #1083
709
+ // ("headed silently ignored on existing session"): when a daemon is
710
+ // already running with a headless browser, subsequent commands with
711
+ // --headed / AGENT_BROWSER_HEADED reuse the existing headless browser
712
+ // regardless of the requested mode. Three upstream fix PRs (#660, #370,
713
+ // #387) have been open and unmerged for months as of 2026-05, so we
714
+ // patch this locally rather than block on upstream.
715
+ //
716
+ // Allowlist, not denylist. The wrapper only pre-closes on the three
717
+ // commands that explicitly start a new browsing session (\`open\`,
718
+ // \`goto\`, \`navigate\`). Every other agent-browser subcommand — \`click\`,
719
+ // \`snapshot\`, \`chat\`, \`connect\`, \`batch\`, \`tab\`, \`record\`, \`trace\`,
720
+ // \`stream\`, \`cookies\`, \`network\`, ... — passes through untouched.
721
+ // Rationale: those subcommands may operate on the live browser/page
722
+ // state (cookies, in-progress recording, attached external CDP, etc.),
723
+ // and a pre-close from us would silently destroy it. The user-reported
724
+ // scenario for #1083 (\"\`agent-browser open <url> --headed\` after a
725
+ // previous headless invocation\") is fully covered because the
726
+ // follow-up commands inherit the now-headed browser the \`open\`
727
+ // pre-close forced. An earlier draft used a deny-list approach that
728
+ // pre-closed on every non-skip subcommand under headed env; oracle
729
+ // self-review flagged the state-destruction risk for stateful commands,
730
+ // and the allowlist fix is the resulting narrower contract.
731
+ //
732
+ // Truthy contract mirrors upstream's \`env_var_is_truthy\`
733
+ // (cli/src/flags.rs:183): any non-empty value EXCEPT case-insensitive
734
+ // "0" / "false" / "no" counts as truthy. So
735
+ // \`AGENT_BROWSER_HEADED=yes\`, \`=y\`, \`=on\`, \`=anything-non-falsy\` all
736
+ // trigger the workaround — matching what upstream's CLI parser would
737
+ // see — instead of the original narrower 1|true match that left the
738
+ // bug present for legitimate truthy values.
739
+ //
740
+ // Re-entrancy is defended at two layers. (1) The pre-close path is
741
+ // \`open\`/\`goto\`/\`navigate\` only, and the close subcommand isn't in the
742
+ // allowlist, so the pre-close never recurses through the wrapper into
743
+ // another pre-close. (2) \`_TYPECLAW_AGENT_BROWSER_HEADED_HANDLED=1\` is
744
+ // set on the env passed to both the pre-close and the final exec; if a
745
+ // future subcommand we don't recognize shells out to \`agent-browser\` as
746
+ // a subprocess while headed env is still set, the child sees the guard
747
+ // and bypasses straight to .real without recursing.
748
+ const LAYER_4_5_AGENT_BROWSER_HEADED_WRAPPER = `# Layer 4.5 (cheap): wrap agent-browser to work around upstream issue
749
+ # #1083 (--headed / AGENT_BROWSER_HEADED ignored on existing session).
750
+ # See src/init/dockerfile.ts for the full rationale.
751
+ RUN mv /usr/local/bin/agent-browser /usr/local/bin/agent-browser.real \\
752
+ && cat > /usr/local/bin/agent-browser <<'TYPECLAW_AGENT_BROWSER_WRAPPER_EOF' \\
753
+ && chmod +x /usr/local/bin/agent-browser
754
+ #!/bin/sh
755
+ # typeclaw wrapper for agent-browser — see src/init/dockerfile.ts.
756
+ set -e
757
+ real="\${TYPECLAW_AGENT_BROWSER_REAL:-/usr/local/bin/agent-browser.real}"
758
+ # Re-entrancy guard: if the wrapper invoked us, skip straight to the real
759
+ # binary. Prevents infinite recursion if a subcommand shells out to
760
+ # agent-browser while AGENT_BROWSER_HEADED is still set.
761
+ if [ "\${_TYPECLAW_AGENT_BROWSER_HEADED_HANDLED:-}" = "1" ]; then
762
+ exec "$real" "$@"
763
+ fi
764
+ # Pre-close is only needed when the caller is requesting headed mode.
765
+ # Match upstream's env_var_is_truthy contract (cli/src/flags.rs:183):
766
+ # truthy = any non-empty value except case-insensitive "0", "false", "no".
767
+ # Argv triggers: bare --headed, --headed=true, --headed=1. (A bare
768
+ # --headed followed by a separate "false" argument is upstream-supported
769
+ # to FORCE headless; the wrapper still pre-closes on the --headed match
770
+ # and the real binary launches headless — wasted close, correct end
771
+ # state. The narrower argv match keeps the wrapper from triggering on
772
+ # unrelated --headed-prefixed flags that may exist in future upstream
773
+ # versions.)
774
+ headed=0
775
+ val=\${AGENT_BROWSER_HEADED:-}
776
+ lower=$(printf '%s' "$val" | tr '[:upper:]' '[:lower:]')
777
+ case "$lower" in
778
+ ''|'0'|'false'|'no') ;;
779
+ *) headed=1 ;;
780
+ esac
781
+ for arg in "$@"; do
782
+ case "$arg" in
783
+ --headed|--headed=true|--headed=1) headed=1; break ;;
784
+ esac
785
+ done
786
+ if [ "$headed" != "1" ]; then
787
+ exec "$real" "$@"
788
+ fi
789
+ # Allowlist of commands where pre-close is safe and necessary. Only
790
+ # user-visible "start a new browsing session" verbs go here. Everything
791
+ # else (click, snapshot, chat, connect, batch, tab, record, trace,
792
+ # stream, cookies, ...) may depend on live browser/page state and must
793
+ # not be pre-closed by us.
794
+ first=""
795
+ for arg in "$@"; do
796
+ case "$arg" in
797
+ -*) continue ;;
798
+ *) first="$arg"; break ;;
799
+ esac
800
+ done
801
+ case "$first" in
802
+ open|goto|navigate) ;;
803
+ *) exec "$real" "$@" ;;
804
+ esac
805
+ # Best-effort pre-close. If the daemon is already gone, the real binary
806
+ # prints "No active sessions" and exits 0 — safe to call unconditionally.
807
+ # We discard its output so it never pollutes the caller's stdout/stderr,
808
+ # and we tolerate failures (network blip, stale socket) by falling
809
+ # through to the real command anyway.
810
+ _TYPECLAW_AGENT_BROWSER_HEADED_HANDLED=1 "$real" close >/dev/null 2>&1 || true
811
+ exec env _TYPECLAW_AGENT_BROWSER_HEADED_HANDLED=1 "$real" "$@"
812
+ TYPECLAW_AGENT_BROWSER_WRAPPER_EOF`
813
+
605
814
  // Layer 5: download the pinned Chrome for Testing build into
606
815
  // ~/.agent-browser/browsers/. NO cache mount on that path because the
607
816
  // runtime needs the binary in the image. System shared libraries are
@@ -616,12 +825,21 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
616
825
  fi`
617
826
 
618
827
  function defaultConfig(): DockerfileConfig {
619
- return { ffmpeg: false, gh: true, python: true, tmux: true, cjkFonts: true, cloudflared: true, append: [] }
828
+ return {
829
+ ffmpeg: false,
830
+ gh: true,
831
+ python: true,
832
+ tmux: true,
833
+ cjkFonts: true,
834
+ cloudflared: true,
835
+ xvfb: true,
836
+ append: [],
837
+ }
620
838
  }
621
839
 
622
840
  function collectToggleAptArgs(config: DockerfileConfig): string[] {
623
841
  const args: string[] = []
624
- for (const key of ['ffmpeg', 'gh', 'python', 'tmux', 'cjkFonts'] as const) {
842
+ for (const key of ['ffmpeg', 'gh', 'python', 'tmux', 'cjkFonts', 'xvfb'] as const) {
625
843
  args.push(...APT_FEATURES[key].toAptArgs(config[key]))
626
844
  }
627
845
  return args
@@ -45,9 +45,9 @@ Do these in order. Do **not** ask further questions.
45
45
  2. Write one short paragraph in \`MEMORY.md\` marking this moment: the date, how you came to be, what you and the user agreed on.
46
46
  3. Configure local git identity with \`bash\`: \`git config user.name "<your name>"\` and \`git config user.email "<reasonable placeholder>@typeclaw.local"\` (unless the user provided an email).
47
47
  4. Stage and commit **only the files you authored** with commit message \`Hatched 🐣\`. This is the hatching-specific commit message — it overrides the normal version-control style guidance for this one commit.
48
- 5. Send **one final short message** — two sentences at most — telling the user hatching is complete and they can \`/quit\` the TUI. Do not ask further questions. Do not offer more work. The container keeps running once they quit; keeping the TUI open here wastes time.
48
+ 5. Send **one final short message** — two sentences at most — telling the user hatching is complete and they can leave the TUI with \`/quit\` (or Ctrl+C). Do not ask further questions. Do not offer more work. The container keeps running once they quit; keeping the TUI open here wastes time.
49
49
 
50
- After that final message, stop. If the user keeps talking, answer briefly and remind them they can \`/quit\` whenever they are ready.
50
+ After that final message, stop. If the user keeps talking, answer briefly and remind them they can \`/quit\` (or Ctrl+C) whenever they are ready.
51
51
 
52
52
  This is the only time you will receive these instructions. After the \`Hatched 🐣\` commit, your identity takes over and you run as yourself.`
53
53
 
package/src/init/index.ts CHANGED
@@ -37,6 +37,14 @@ const CONFIG_FILE = 'typeclaw.json'
37
37
  const CRON_FILE = 'cron.json'
38
38
  const PACKAGE_FILE = 'package.json'
39
39
 
40
+ // Seeded into `typeclaw.json#roles.member.match[]` whenever a chat adapter
41
+ // (slack-bot, discord-bot, telegram-bot, kakaotalk) is wired. The "*" rule
42
+ // matches every channel session on every platform, so the built-in `member`
43
+ // role (which already carries `channel.respond`) covers any inbound the
44
+ // router sees. Without this, freshly-hatched agents silently drop every
45
+ // chat message — see scaffold() and ensureDefaultChatMemberMatch() below.
46
+ const DEFAULT_CHAT_MEMBER_MATCH_RULE = '*'
47
+
40
48
  const MARKDOWN_FILES = ['AGENTS.md', 'IDENTITY.md', 'SOUL.md', 'USER.md'] as const
41
49
 
42
50
  // `packages/` is a bun workspace root (see `workspaces` in buildPackageJson).
@@ -121,7 +129,18 @@ export type KakaotalkAuthRunner = (options: { cwd: string }) => Promise<Kakaotal
121
129
  // API-key provider". Optional model defaults to DEFAULT_MODEL_REF, which is
122
130
  // an OpenAI api-key provider — so test fixtures that omit both fields keep
123
131
  // working under the api-key path.
124
- export type LLMAuth = { kind: 'api-key'; apiKey: string } | { kind: 'oauth'; runLogin: OAuthLoginRunner }
132
+ //
133
+ // `oauth-completed` is the CLI wizard's signal that the browser login already
134
+ // happened up-front (right after the user picked the auth method) and the
135
+ // resulting credentials are already in `secrets.json`. `runInit` then skips
136
+ // the `oauth-login` step but still treats this as an OAuth provider (no API
137
+ // key written, etc.). The wizard runs OAuth eagerly so the browser opens the
138
+ // moment the user picks "OAuth (browser login)" instead of waiting until the
139
+ // end of the wizard — see `collectWizardInputs` in `src/cli/init.ts`.
140
+ export type LLMAuth =
141
+ | { kind: 'api-key'; apiKey: string }
142
+ | { kind: 'oauth'; runLogin: OAuthLoginRunner }
143
+ | { kind: 'oauth-completed' }
125
144
 
126
145
  export type InitOptions = {
127
146
  cwd: string
@@ -223,8 +242,8 @@ export async function runInit({
223
242
  // Same trap as kakaotalk-auth: scaffold-then-fail-auth would leave
224
243
  // typeclaw.json without working credentials and the runtime would silently
225
244
  // refuse to boot. The login itself doesn't need the agent folder to exist
226
- // — pi-ai's OAuth helper just needs a writable path for secrets.json, which
227
- // we create on demand inside scaffold().
245
+ // — pi-ai's OAuth helper just needs a writable path for secrets.json, and
246
+ // the `mkdir` below creates it on demand before the login runs.
228
247
  if (resolvedAuth.kind === 'oauth') {
229
248
  emit({ step: 'oauth-login', phase: 'start' })
230
249
  await mkdir(cwd, { recursive: true })
@@ -532,6 +551,11 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
532
551
  if (options.withTelegram) channels['telegram-bot'] = {}
533
552
  if (options.withKakaotalk) channels.kakaotalk = {}
534
553
  if (Object.keys(channels).length > 0) config.channels = channels
554
+ // See DEFAULT_CHAT_MEMBER_MATCH_RULE for why this is here. GitHub is wired
555
+ // separately (writeGithubChannelForInit) and seeds per-repo member.match
556
+ // entries instead of the wildcard, so a github-only init stays scoped to
557
+ // the repos the operator opted in to.
558
+ if (Object.keys(channels).length > 0) config.roles = { member: { match: [DEFAULT_CHAT_MEMBER_MATCH_RULE] } }
535
559
  await writeFile(join(root, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
536
560
 
537
561
  const cron = {
@@ -954,6 +978,8 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
954
978
  if (options.channel === 'github') {
955
979
  await appendGithubMatchRules(options.cwd, options.repos)
956
980
  await maybeInstallGithubWebhooks(options, emit)
981
+ } else {
982
+ await ensureDefaultChatMemberMatch(options.cwd)
957
983
  }
958
984
 
959
985
  // Commit the typeclaw.json change so the agent folder isn't silently
@@ -1198,6 +1224,24 @@ async function appendGithubMatchRules(cwd: string, repos: readonly string[]): Pr
1198
1224
  await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
1199
1225
  }
1200
1226
 
1227
+ // Chat-adapter counterpart of appendGithubMatchRules. See
1228
+ // DEFAULT_CHAT_MEMBER_MATCH_RULE for the rationale. Set-union semantics: re-
1229
+ // running `typeclaw channel add` for additional chat adapters is a no-op on
1230
+ // the match list, and any pre-existing rules the operator hand-authored
1231
+ // (e.g. owner-claim's per-author entry on `owner`) are left intact.
1232
+ async function ensureDefaultChatMemberMatch(cwd: string): Promise<void> {
1233
+ const path = join(cwd, CONFIG_FILE)
1234
+ const parsed = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
1235
+ const roles = isObjectRecord(parsed.roles) ? { ...parsed.roles } : {}
1236
+ const member = isObjectRecord(roles.member) ? { ...roles.member } : {}
1237
+ const existing = Array.isArray(member.match) ? member.match.filter((v): v is string => typeof v === 'string') : []
1238
+ if (existing.includes(DEFAULT_CHAT_MEMBER_MATCH_RULE)) return
1239
+ member.match = [...existing, DEFAULT_CHAT_MEMBER_MATCH_RULE]
1240
+ roles.member = member
1241
+ parsed.roles = roles
1242
+ await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
1243
+ }
1244
+
1201
1245
  // Writes per-adapter field values into `secrets.json#channels.<adapter>`.
1202
1246
  // Refuses to overwrite existing fields: if the user already has e.g.
1203
1247
  // `botToken` recorded (from a prior `channel add` whose follow-up steps
@@ -14,16 +14,29 @@ export type OAuthLoginResult = { ok: true } | { ok: false; reason: string }
14
14
  export type OAuthLoginRunner = (options: { cwd: string; model: KnownModelRef }) => Promise<OAuthLoginResult>
15
15
 
16
16
  // Wrap pi-ai's OAuth callbacks so the CLI doesn't have to know about the
17
- // upstream callback shape. The CLI only sees three lifecycle events:
17
+ // upstream callback shape. The CLI sees four lifecycle events:
18
18
  // (1) onAuth(url) — print the URL the user must visit
19
19
  // (2) onProgress(message) — show waiting/finalizing status
20
20
  // (3) onPrompt(prompt) — ask the user for a manual code if the browser flow
21
- // can't reach the local callback server. Most users won't see this; it
22
- // fires when they paste the post-redirect URL by hand.
21
+ // can't reach the local callback server. Fires only after the local
22
+ // server gave up (bind error -> waitForCode resolves null).
23
+ // (4) onManualCodeInput() — concurrent paste input that RACES the local
24
+ // callback server. Required for cross-device flows: pi-ai's openai-codex
25
+ // OAuth hardcodes redirect_uri=http://localhost:1455/auth/callback, which
26
+ // resolves to the *browser's* machine. When the user runs `typeclaw init`
27
+ // over SSH or on a remote dev box and completes login on a different
28
+ // laptop, the browser callback never reaches the CLI's local server and
29
+ // waitForCode() hangs forever — so onPrompt would never fire either.
30
+ // onManualCodeInput is the upstream-supported escape hatch: it shows a
31
+ // paste field IMMEDIATELY alongside the URL, and whichever path lands a
32
+ // code first wins. parseAuthorizationInput on the upstream side accepts
33
+ // the full redirect URL, the bare `code=...&state=...` query string, or
34
+ // just the code value.
23
35
  export type OAuthCallbacks = {
24
36
  onAuth: (url: string, instructions?: string) => void
25
37
  onProgress?: (message: string) => void
26
38
  onPrompt: (message: string, placeholder?: string) => Promise<string | null>
39
+ onManualCodeInput?: () => Promise<string>
27
40
  }
28
41
 
29
42
  // Default runner: real OAuth flow against pi-ai. Tests inject a stub to skip
@@ -50,6 +63,7 @@ export function makeOAuthLoginRunner(callbacks: OAuthCallbacks): OAuthLoginRunne
50
63
  }
51
64
  return value
52
65
  },
66
+ onManualCodeInput: callbacks.onManualCodeInput,
53
67
  })
54
68
  return { ok: true }
55
69
  } catch (error) {
@@ -25,6 +25,21 @@ export type BuiltinRoleSpec = {
25
25
  readonly permissions: readonly string[]
26
26
  }
27
27
 
28
+ // Owner carries low + medium tier strings explicitly AND the wildcard
29
+ // sentinel. The sentinel expands to plugin-contributed `security.bypass.*`
30
+ // strings minus the security plugin's `ownerWildcardExclusions` (today:
31
+ // `security.bypass.high` plus high-tier per-guard strings). Net effect:
32
+ // owner auto-bypasses every low- and medium-tier guard, and high-tier
33
+ // guards require per-call ack from owner too (the audience-leak rule —
34
+ // owner-in-public-channel must not silently post credentials).
35
+ //
36
+ // Trusted carries only `security.bypass.low`. Trusted does NOT carry the
37
+ // pre-PR per-guard grants (`bypassSecretExfilBash`, `bypassGitExfil`):
38
+ // those guards are medium/high under the audience-leak axis and per-guard
39
+ // grants would re-introduce exactly the bypass holes the tier system
40
+ // exists to prevent. Operators who want the pre-PR ergonomics can add the
41
+ // per-guard strings explicitly to `roles.trusted.permissions[]` in
42
+ // typeclaw.json — that path stays alive forever.
28
43
  export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> = {
29
44
  owner: {
30
45
  match: [{ kind: 'tui' }],
@@ -32,17 +47,14 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
32
47
  CORE_PERMISSIONS.channelRespond,
33
48
  CORE_PERMISSIONS.cronSchedule,
34
49
  CORE_PERMISSIONS.cronModify,
50
+ 'security.bypass.low',
51
+ 'security.bypass.medium',
35
52
  OWNER_SECURITY_WILDCARD,
36
53
  ],
37
54
  },
38
55
  trusted: {
39
56
  match: [],
40
- permissions: [
41
- CORE_PERMISSIONS.channelRespond,
42
- CORE_PERMISSIONS.cronSchedule,
43
- 'security.bypass.secretExfilBash',
44
- 'security.bypass.gitExfil',
45
- ],
57
+ permissions: [CORE_PERMISSIONS.channelRespond, CORE_PERMISSIONS.cronSchedule, 'security.bypass.low'],
46
58
  },
47
59
  member: {
48
60
  match: [],
@@ -54,11 +66,21 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
54
66
  },
55
67
  }
56
68
 
69
+ // Expands the owner wildcard sentinel against plugin-contributed
70
+ // `security.bypass.*` strings. `wildcardExclusions` is an optional set of
71
+ // permission strings the sentinel must NOT expand to — used by the
72
+ // bundled security plugin to exclude `security.bypass.high` AND the
73
+ // per-guard strings for high-tier guards, so the wildcard does not
74
+ // auto-grant audience-leak bypass to owner. Explicit operator grants of
75
+ // those strings in `roles.owner.permissions[]` still take effect (they
76
+ // flow through the non-sentinel branch).
57
77
  export function expandOwnerWildcard(
58
78
  ownerPermissions: readonly string[],
59
79
  pluginContributed: readonly string[],
80
+ wildcardExclusions: readonly string[] = [],
60
81
  ): readonly string[] {
61
- const bypass = pluginContributed.filter((p) => p.startsWith('security.bypass.'))
82
+ const excludeSet = new Set(wildcardExclusions)
83
+ const bypass = pluginContributed.filter((p) => p.startsWith('security.bypass.') && !excludeSet.has(p))
62
84
  const out: string[] = []
63
85
  for (const p of ownerPermissions) {
64
86
  if (p === OWNER_SECURITY_WILDCARD) {
@@ -38,6 +38,12 @@ type ResolvedRole = {
38
38
  export type CreatePermissionServiceOptions = {
39
39
  roles?: RolesConfig
40
40
  pluginPermissions?: readonly string[]
41
+ // Permission strings that the owner wildcard sentinel must NOT
42
+ // auto-expand to. Today populated from the bundled security plugin's
43
+ // high-tier list so audience-leak guards do not get auto-granted to
44
+ // owner. Generic by design — any future plugin could contribute
45
+ // exclusions through the plugin manager. See expandOwnerWildcard.
46
+ ownerWildcardExclusions?: readonly string[]
41
47
  }
42
48
 
43
49
  // Returns warnings for user-declared `permissions[]` strings that aren't
@@ -97,7 +103,8 @@ function levenshtein(a: string, b: string): number {
97
103
 
98
104
  export function createPermissionService(opts: CreatePermissionServiceOptions = {}): PermissionService {
99
105
  const pluginPermissions = opts.pluginPermissions ?? []
100
- let resolved = buildRoleTable(opts.roles ?? {}, pluginPermissions)
106
+ const ownerWildcardExclusions = opts.ownerWildcardExclusions ?? []
107
+ let resolved = buildRoleTable(opts.roles ?? {}, pluginPermissions, ownerWildcardExclusions)
101
108
  let byName = new Map(resolved.map((r) => [r.name, r]))
102
109
 
103
110
  function resolveRole(origin: SessionOrigin | undefined): string {
@@ -139,36 +146,46 @@ export function createPermissionService(opts: CreatePermissionServiceOptions = {
139
146
  return { role: name, permissions: role?.permissions ?? [] }
140
147
  },
141
148
  replaceRoles(roles) {
142
- resolved = buildRoleTable(roles ?? {}, pluginPermissions)
149
+ resolved = buildRoleTable(roles ?? {}, pluginPermissions, ownerWildcardExclusions)
143
150
  byName = new Map(resolved.map((r) => [r.name, r]))
144
151
  },
145
152
  }
146
153
  }
147
154
 
148
- function buildRoleTable(roles: RolesConfig, pluginPermissions: readonly string[]): ResolvedRole[] {
155
+ function buildRoleTable(
156
+ roles: RolesConfig,
157
+ pluginPermissions: readonly string[],
158
+ ownerWildcardExclusions: readonly string[],
159
+ ): ResolvedRole[] {
149
160
  const out: ResolvedRole[] = []
150
161
  const seen = new Set<string>()
151
162
 
152
163
  for (const name of Object.keys(roles)) {
153
164
  if (seen.has(name)) continue
154
165
  seen.add(name)
155
- out.push(resolveOne(name, roles[name], pluginPermissions))
166
+ out.push(resolveOne(name, roles[name], pluginPermissions, ownerWildcardExclusions))
156
167
  }
157
168
 
158
169
  for (const name of BUILTIN_ROLE_NAMES) {
159
170
  if (seen.has(name)) continue
160
- out.push(resolveOne(name, undefined, pluginPermissions))
171
+ out.push(resolveOne(name, undefined, pluginPermissions, ownerWildcardExclusions))
161
172
  }
162
173
 
163
174
  return out
164
175
  }
165
176
 
166
- function resolveOne(name: string, user: RoleConfig | undefined, pluginPermissions: readonly string[]): ResolvedRole {
177
+ function resolveOne(
178
+ name: string,
179
+ user: RoleConfig | undefined,
180
+ pluginPermissions: readonly string[],
181
+ ownerWildcardExclusions: readonly string[],
182
+ ): ResolvedRole {
167
183
  if (isBuiltinRoleName(name)) {
168
184
  const builtin = BUILTIN_ROLES[name]
169
185
  const match = [...builtin.match, ...(user?.match ?? [])]
170
186
  const rawPerms = user?.permissions !== undefined ? user.permissions : [...builtin.permissions]
171
- const permissions = name === 'owner' ? expandOwnerWildcard(rawPerms, pluginPermissions) : rawPerms
187
+ const permissions =
188
+ name === 'owner' ? expandOwnerWildcard(rawPerms, pluginPermissions, ownerWildcardExclusions) : rawPerms
172
189
  return { name, match, permissions }
173
190
  }
174
191
  return {
@@ -18,11 +18,13 @@ type DefinePluginSpec<S extends z.ZodType<unknown> | undefined> =
18
18
  ? {
19
19
  configSchema: S
20
20
  permissions?: readonly string[]
21
+ ownerWildcardExclusions?: readonly string[]
21
22
  commands?: Record<string, PluginCommand>
22
23
  plugin: (ctx: PluginContext<T>) => Promise<PluginExports>
23
24
  }
24
25
  : {
25
26
  permissions?: readonly string[]
27
+ ownerWildcardExclusions?: readonly string[]
26
28
  commands?: Record<string, PluginCommand>
27
29
  plugin: (ctx: PluginContext<unknown>) => Promise<PluginExports>
28
30
  }
@@ -56,9 +56,11 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
56
56
  ]
57
57
 
58
58
  const declaredPermissions = collectDeclaredPermissions(allPlugins)
59
+ const ownerWildcardExclusions = collectOwnerWildcardExclusions(allPlugins)
59
60
  const permissions = createPermissionService({
60
61
  ...(opts.roles !== undefined ? { roles: opts.roles } : {}),
61
62
  pluginPermissions: declaredPermissions,
63
+ ownerWildcardExclusions,
62
64
  })
63
65
 
64
66
  // Non-fatal: surface user-declared `permissions[]` strings that aren't in
@@ -158,6 +160,18 @@ function collectDeclaredPermissions(
158
160
  return out
159
161
  }
160
162
 
163
+ function collectOwnerWildcardExclusions(
164
+ plugins: readonly { entry: string; resolved: ResolvedPlugin }[],
165
+ ): readonly string[] {
166
+ const out: string[] = []
167
+ for (const { resolved } of plugins) {
168
+ for (const perm of resolved.defined.ownerWildcardExclusions ?? []) {
169
+ if (!out.includes(perm)) out.push(perm)
170
+ }
171
+ }
172
+ return out
173
+ }
174
+
161
175
  export function summarizeLoaded(loaded: LoadPluginsResult['loadedPlugins'], registry: PluginRegistry): string {
162
176
  const head = loaded.map((p) => (p.version !== undefined ? `${p.name} v${p.version}` : p.name)).join(', ')
163
177
  const counts = [
@@ -318,6 +318,12 @@ export type PluginFixResult = {
318
318
  export type DefinedPlugin<TConfig = never> = {
319
319
  readonly configSchema?: z.ZodType<TConfig>
320
320
  readonly permissions?: readonly string[]
321
+ // Permission strings the owner wildcard sentinel MUST NOT auto-expand
322
+ // to. Used by the bundled security plugin to keep audience-leak
323
+ // (high-tier) bypasses off the owner role unless an operator grants
324
+ // them explicitly in roles.owner.permissions[]. Generic by design so
325
+ // any future plugin can carve specific permissions out of the wildcard.
326
+ readonly ownerWildcardExclusions?: readonly string[]
321
327
  // Declared by-value (not built inside the factory) so the host-stage CLI
322
328
  // can dispatch commands without booting plugin runtime state.
323
329
  readonly commands?: Record<string, PluginCommand>
package/src/run/index.ts CHANGED
@@ -314,7 +314,7 @@ export async function startAgent({
314
314
  }
315
315
  await job.handler(ctx)
316
316
  },
317
- createSessionForCron: async (job) => {
317
+ createSessionForCron: async (job, refOverride) => {
318
318
  const snap = pluginRuntime.get()
319
319
  const sessionManager = SessionManager.create(cwd, sessionFactory.sessionDir())
320
320
  const sessionId = sessionManager.getSessionId()
@@ -336,6 +336,7 @@ export async function startAgent({
336
336
  channelRouter: channelManager.router,
337
337
  origin: cronOrigin,
338
338
  permissions: pluginsLoaded.permissions,
339
+ ...(refOverride !== undefined ? { refOverride } : {}),
339
340
  ...(snap.hasAnyPluginContent
340
341
  ? {
341
342
  plugins: {