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.
- package/package.json +1 -1
- package/src/agent/auth.ts +4 -2
- package/src/agent/index.ts +16 -28
- package/src/agent/model-fallback.ts +127 -0
- package/src/agent/tools/curl-impersonate.ts +300 -0
- package/src/agent/tools/ddg.ts +13 -88
- package/src/agent/tools/webfetch/fetch.ts +105 -2
- package/src/agent/tools/webfetch/tool.ts +4 -0
- package/src/bundled-plugins/agent-browser/shim.ts +47 -0
- package/src/bundled-plugins/backup/subagents.ts +2 -0
- package/src/bundled-plugins/memory/README.md +49 -12
- package/src/bundled-plugins/memory/citation-superset.ts +63 -0
- package/src/bundled-plugins/memory/dreaming.ts +105 -17
- package/src/bundled-plugins/memory/index.ts +2 -2
- package/src/bundled-plugins/memory/memory-logger.ts +45 -26
- package/src/bundled-plugins/memory/strength.ts +127 -0
- package/src/bundled-plugins/memory/topics.ts +75 -0
- package/src/bundled-plugins/security/index.ts +87 -43
- package/src/bundled-plugins/security/permissions.ts +36 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
- package/src/channels/adapters/github/index.ts +87 -3
- package/src/channels/router.ts +194 -28
- package/src/channels/types.ts +3 -1
- package/src/cli/channel.ts +2 -45
- package/src/cli/init.ts +148 -87
- package/src/cli/model.ts +12 -3
- package/src/cli/oauth-callbacks.ts +49 -0
- package/src/cli/provider.ts +3 -20
- package/src/cli/ui.ts +95 -0
- package/src/config/config.ts +59 -24
- package/src/config/models-mutation.ts +42 -8
- package/src/config/providers-mutation.ts +12 -8
- package/src/container/start.ts +18 -1
- package/src/cron/consumer.ts +129 -43
- package/src/init/dockerfile.ts +221 -3
- package/src/init/hatching.ts +2 -2
- package/src/init/index.ts +47 -3
- package/src/init/oauth-login.ts +17 -3
- package/src/permissions/builtins.ts +29 -7
- package/src/permissions/permissions.ts +24 -7
- package/src/plugin/define.ts +2 -0
- package/src/plugin/manager.ts +14 -0
- package/src/plugin/types.ts +6 -0
- package/src/run/index.ts +2 -1
- package/src/skills/typeclaw-memory/SKILL.md +25 -15
- package/src/skills/typeclaw-permissions/SKILL.md +35 -17
- package/src/tui/index.ts +35 -3
- package/src/usage/report.ts +15 -12
- package/typeclaw.schema.json +57 -25
package/src/init/dockerfile.ts
CHANGED
|
@@ -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 {
|
|
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
|
package/src/init/hatching.ts
CHANGED
|
@@ -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\`
|
|
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
|
-
|
|
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,
|
|
227
|
-
//
|
|
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
|
package/src/init/oauth-login.ts
CHANGED
|
@@ -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
|
|
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.
|
|
22
|
-
//
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
187
|
+
const permissions =
|
|
188
|
+
name === 'owner' ? expandOwnerWildcard(rawPerms, pluginPermissions, ownerWildcardExclusions) : rawPerms
|
|
172
189
|
return { name, match, permissions }
|
|
173
190
|
}
|
|
174
191
|
return {
|
package/src/plugin/define.ts
CHANGED
|
@@ -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
|
}
|
package/src/plugin/manager.ts
CHANGED
|
@@ -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 = [
|
package/src/plugin/types.ts
CHANGED
|
@@ -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: {
|