@tealbrick/kit 0.2.7 → 0.3.0-rc.10

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 (46) hide show
  1. package/BOOTSTRAP.md +17 -18
  2. package/BUZZ.md +146 -0
  3. package/NATIVE.md +83 -0
  4. package/README.md +265 -4
  5. package/RUNTIME.md +1 -1
  6. package/assets/buzz/buzz-backend-provider.py +99 -0
  7. package/assets/buzz/buzz-plist.py +22 -0
  8. package/assets/buzz/buzz-run.sh +39 -0
  9. package/assets/buzz/tb-provider-apply.py +97 -0
  10. package/dist/buzz.d.ts +63 -0
  11. package/dist/buzz.js +262 -0
  12. package/dist/cli.js +22 -4
  13. package/dist/index.d.ts +23 -14
  14. package/dist/index.js +4 -2
  15. package/dist/native-acp.d.ts +40 -0
  16. package/dist/native-acp.js +224 -0
  17. package/dist/native-app-capabilities.d.ts +33 -0
  18. package/dist/native-app-capabilities.js +37 -0
  19. package/dist/native-buzz.d.ts +161 -0
  20. package/dist/native-buzz.js +649 -0
  21. package/dist/native-child-guard.d.ts +1 -0
  22. package/dist/native-child-guard.js +22 -0
  23. package/dist/native-claude.d.ts +99 -0
  24. package/dist/native-claude.js +584 -0
  25. package/dist/native-enroll.d.ts +22 -0
  26. package/dist/native-enroll.js +85 -0
  27. package/dist/native-runtime-sync.d.ts +49 -0
  28. package/dist/native-runtime-sync.js +101 -0
  29. package/dist/native-selection.d.ts +27 -0
  30. package/dist/native-selection.js +87 -0
  31. package/dist/native-serve-config.d.ts +286 -0
  32. package/dist/native-serve-config.js +296 -0
  33. package/dist/native-serve.d.ts +76 -0
  34. package/dist/native-serve.js +145 -0
  35. package/dist/native-setup.d.ts +63 -0
  36. package/dist/native-setup.js +122 -0
  37. package/dist/native-standing-grants.d.ts +105 -0
  38. package/dist/native-standing-grants.js +226 -0
  39. package/dist/native.d.ts +42 -0
  40. package/dist/native.js +193 -0
  41. package/dist/onboarding.d.ts +9 -0
  42. package/dist/onboarding.js +9 -2
  43. package/dist/runtime-onboarding.js +3 -3
  44. package/dist/secrets.d.ts +2 -1
  45. package/dist/secrets.js +4 -2
  46. package/package.json +19 -10
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/python3
2
+ # tealbrick-buzz-provider: generated by `tealbrick buzz provider install`; safe to overwrite with --force.
3
+ """Buzz Desktop managed-agent provider: runs a Desktop-managed agent on a Mac host as buzz-acp + Teal Brick
4
+ kit `native acp` (the owner's kit profile, expert contracts and Portal grants stay the authority).
5
+ Protocol v1: one JSON request on stdin, one JSON response on stdout. The agent key travels only over
6
+ stdin -> ssh stdin -> host helper stdin; never argv, env or logs."""
7
+ import hashlib, json, os, re, subprocess, sys
8
+ PROVIDER_ID = '__TB_PROVIDER_ID__'; VERSION = '__TB_KIT_VERSION__'
9
+ # Test-only override (a fake ssh); Desktop never sets it. Real deployments always use the system ssh.
10
+ SSH = os.environ.get('TEALBRICK_BUZZ_PROVIDER_TEST_SSH') or '/usr/bin/ssh'
11
+ HELPER = 'agents/buzz/bin/tb-provider-apply.py' # relative to the remote HOME (ssh starts there)
12
+ HEX = re.compile(r'^[0-9a-f]{64}$')
13
+ # --- bech32 (NIP-19 nsec) + secp256k1 x-only pubkey, stdlib only ---------------------------------
14
+ CS = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
15
+ def nsec_hex(s):
16
+ s = s.strip().lower()
17
+ if HEX.match(s): return s
18
+ if not s.startswith('nsec1') or any(c not in CS for c in s[5:]): raise ValueError('private key is not nsec/hex')
19
+ data = [CS.index(c) for c in s[5:-6]]; acc = bits = 0; out = []
20
+ for v in data:
21
+ acc = (acc << 5) | v; bits += 5
22
+ while bits >= 8: bits -= 8; out.append((acc >> bits) & 0xff)
23
+ if len(out) != 32: raise ValueError('bad nsec length')
24
+ return bytes(out).hex()
25
+ P = 2**256 - 2**32 - 977; N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
26
+ G = (0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8)
27
+ def add(a, b):
28
+ if a is None: return b
29
+ if b is None: return a
30
+ if a[0] == b[0] and (a[1] + b[1]) % P == 0: return None
31
+ l = (3 * a[0] * a[0] * pow(2 * a[1], -1, P)) % P if a == b else ((b[1] - a[1]) * pow(b[0] - a[0], -1, P)) % P
32
+ x = (l * l - a[0] - b[0]) % P; return (x, (l * (a[0] - x) - a[1]) % P)
33
+ def pubkey(sk_hex):
34
+ k = int(sk_hex, 16)
35
+ if not 0 < k < N: raise ValueError('bad key')
36
+ r, q = None, G
37
+ while k:
38
+ if k & 1: r = add(r, q)
39
+ q = add(q, q); k >>= 1
40
+ return format(r[0], '064x')
41
+ # -------------------------------------------------------------------------------------------------
42
+ CONFIG_SCHEMA = {'type': 'object', 'required': ['owner', 'host'], 'additionalProperties': False, 'properties': {
43
+ 'owner': {'type': 'string', 'title': 'Teal Brick kit owner on the host',
44
+ 'description': 'Kit owner directory name under the host owners directory (set up with tealbrick native setup + native enroll), e.g. owner-a'},
45
+ 'host': {'type': 'string', 'title': 'SSH host',
46
+ 'description': 'SSH host alias or user@host of the agent host Mac (key-based, non-interactive), e.g. agent-host'}}}
47
+ def rules_toml():
48
+ # One rule: an agent wakes only when its own pubkey is p-tagged (an explicit @mention), by anyone.
49
+ # Desktop DMs p-tag the agent, so DMs still work; untagged channel and thread messages are ignored.
50
+ kinds = '[9, 40003, 46010, 40007]'
51
+ return (f'# Generated by buzz-backend-{PROVIDER_ID}: wake only on an explicit @mention (p-tag) from anyone.\n'
52
+ f'[[rules]]\nname = "mentions"\nchannels = "all"\nkinds = {kinds}\nrequire_mention = true\n')
53
+ def deploy(req):
54
+ agent = req.get('agent') or {}; cfg = req.get('provider_config') or {}
55
+ owner = str(cfg.get('owner') or '').strip().lower(); host = str(cfg.get('host') or '').strip()
56
+ if not re.fullmatch(r'[a-z0-9-]{2,32}', owner): raise ValueError('provider_config.owner is required (e.g. owner-a)')
57
+ # A leading letter/digit keeps the host from ever being parsed as an ssh option.
58
+ if not re.fullmatch(r'([A-Za-z0-9][A-Za-z0-9._-]{0,31}@)?[A-Za-z0-9][A-Za-z0-9._-]{0,63}', host):
59
+ raise ValueError('provider_config.host is required (an ssh alias or user@host, e.g. agent-host)')
60
+ sk = nsec_hex(agent.get('private_key_nsec') or ''); apub = pubkey(sk)
61
+ launch = agent.get('launch') or {}; owner_pub = (launch.get('owner_pubkey') or '').lower()
62
+ tag = agent.get('auth_tag')
63
+ if not HEX.match(owner_pub): raise ValueError('payload has no owner_pubkey')
64
+ if not tag: raise ValueError('agent has no NIP-OA auth_tag (recreate it in Desktop)')
65
+ t = json.loads(tag) if isinstance(tag, str) else tag
66
+ if not (isinstance(t, list) and len(t) == 4 and t[0] == 'auth' and t[1] == owner_pub): raise ValueError('auth_tag owner does not match the Desktop owner')
67
+ relay = (agent.get('relay_url') or '').rstrip('/')
68
+ m = re.fullmatch(r'wss?://([A-Za-z0-9-]+)[A-Za-z0-9.-]*(:\d+)?', relay)
69
+ if not m: raise ValueError('payload relay_url is not a ws(s) URL')
70
+ slug = m.group(1).lower()[:20] + '-' + hashlib.sha256(relay.encode()).hexdigest()[:4]
71
+ label = f'{owner}-{apub[:8]}-{slug}'
72
+ remote = {'label': label, 'owner': owner, 'relay_url': relay, 'agent_pubkey': apub, 'owner_pubkey': owner_pub,
73
+ 'private_key': sk, 'auth_tag': json.dumps(t), 'respond_to': agent.get('respond_to'),
74
+ 'respond_to_allowlist': agent.get('respond_to_allowlist') or [], 'name': agent.get('name'),
75
+ 'policy_env': launch.get('policy_env') or {}, 'rules_toml': rules_toml()}
76
+ p = subprocess.run([SSH, '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', '--', host, '/usr/bin/python3', HELPER],
77
+ input=json.dumps(remote), capture_output=True, text=True, timeout=180)
78
+ lines = [l for l in p.stdout.splitlines() if l.strip().startswith('{')]
79
+ try: res = json.loads(lines[-1]) if lines else None
80
+ except ValueError: res = None
81
+ if not isinstance(res, dict): res = {'ok': False, 'error': (p.stderr or 'no response from host')[-300:]}
82
+ if not res.get('ok'): raise RuntimeError(f"{host}: {res.get('error')}")
83
+ return {'ok': True, 'agent_id': f'{PROVIDER_ID}:{host}:{label}'}
84
+ def redact(text):
85
+ return re.sub(r'nsec1[0-9a-z]+|[0-9a-fA-F]{64}', '<redacted>', text)
86
+ def main():
87
+ try:
88
+ req = json.loads(sys.stdin.read() or '{}'); op = req.get('op')
89
+ if op == 'info':
90
+ res = {'ok': True, 'name': f'Teal Brick · {PROVIDER_ID}', 'version': VERSION, 'protocol_version': 1,
91
+ 'description': 'Runs the agent on a Mac host over ssh as buzz-acp + Teal Brick kit native acp for a kit owner; Portal grants and kit contracts stay the authority.',
92
+ 'config_schema': CONFIG_SCHEMA}
93
+ elif op == 'deploy': res = deploy(req)
94
+ else: raise ValueError(f'unsupported op {op!r}')
95
+ print(json.dumps(res)); return 0
96
+ except Exception as e:
97
+ msg = redact(f'{type(e).__name__}: {e}')[:400]
98
+ print(json.dumps({'ok': False, 'error': msg})); print(msg, file=sys.stderr); return 1
99
+ if __name__ == '__main__': sys.exit(main())
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env python3
2
+ # tealbrick-buzz-host: installed by `tealbrick buzz host setup`; rewritten on every setup run.
3
+ """Writes ~/Library/LaunchAgents/com.tealbrick.buzz-acp.<label>.plist for one provider-managed agent.
4
+ The unit runs ~/agents/buzz/bin/buzz-run.sh <label>; logs stay in the agent directory (0700)."""
5
+ import os, plistlib, re, sys
6
+ def main():
7
+ if len(sys.argv) != 2 or not re.fullmatch(r'[a-z0-9][a-z0-9-]{2,62}', sys.argv[1]):
8
+ print('usage: buzz-plist.py <label>', file=sys.stderr); return 2
9
+ label = sys.argv[1]; H = os.path.expanduser('~'); BUZZ = os.path.join(H, 'agents', 'buzz')
10
+ D = os.path.join(BUZZ, label); logs = os.path.join(D, 'logs')
11
+ os.makedirs(logs, mode=0o700, exist_ok=True); os.chmod(logs, 0o700)
12
+ agents = os.path.join(H, 'Library', 'LaunchAgents'); os.makedirs(agents, exist_ok=True)
13
+ unit = f'com.tealbrick.buzz-acp.{label}'
14
+ body = {'Label': unit, 'ProgramArguments': ['/bin/bash', os.path.join(BUZZ, 'bin', 'buzz-run.sh'), label],
15
+ 'WorkingDirectory': D, 'RunAtLoad': True, 'KeepAlive': {'SuccessfulExit': False},
16
+ 'ThrottleInterval': 30, 'ProcessType': 'Background', 'Umask': 63,
17
+ 'StandardOutPath': os.path.join(logs, 'buzz-acp.out.log'), 'StandardErrorPath': os.path.join(logs, 'buzz-acp.err.log')}
18
+ path = os.path.join(agents, unit + '.plist'); tmp = path + '.tmp'
19
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
20
+ with os.fdopen(fd, 'wb') as f: plistlib.dump(body, f)
21
+ os.chmod(tmp, 0o600); os.replace(tmp, path); print(path); return 0
22
+ if __name__ == '__main__': sys.exit(main())
@@ -0,0 +1,39 @@
1
+ #!/bin/bash
2
+ # tealbrick-buzz-host: installed by `tealbrick buzz host setup`; rewritten on every setup run.
3
+ # Runs one provider-managed agent: buzz-acp with Teal Brick kit `native acp` as its ACP agent.
4
+ # Usage (from its launchd unit): buzz-run.sh <label>
5
+ # Each agent gets an isolated environment (env -i): nothing from the login session leaks into the
6
+ # agent, and one agent's keys never reach another (block/buzz#3142). The key and auth tag are read
7
+ # from their files by the final shell and never appear in any process argv (only their paths do).
8
+ # A missing precondition logs one line and exits 0: the unit uses KeepAlive SuccessfulExit=false, so
9
+ # launchd does not restart-loop an agent that cannot start. Fix the cause, then
10
+ # `launchctl kickstart -k gui/$(id -u)/com.tealbrick.buzz-acp.<label>`.
11
+ set -euo pipefail
12
+ NODE=@@NODE@@
13
+ KIT_CLI=@@KIT_CLI@@
14
+ OWNERS_DIR=@@OWNERS_DIR@@
15
+ BUZZ=@@BUZZ_BIN@@
16
+ BUZZ_ACP=@@BUZZ_ACP_BIN@@
17
+ a=${1:-}
18
+ stop() { echo "buzz-run: ${a:-<no label>}: $1; not starting (exit 0, launchd will not restart it)" >&2; exit 0; }
19
+ [[ "$a" =~ ^[a-z0-9][a-z0-9-]{2,62}$ ]] || stop "bad or missing label"
20
+ D="$HOME/agents/buzz/$a"
21
+ [ -d "$D" ] || stop "agent directory $D is missing"
22
+ O=$(cat "$D/owner" 2>/dev/null || echo "$a")
23
+ [[ "$O" =~ ^[a-z0-9-]{2,32}$ ]] || stop "bad owner in $D/owner"
24
+ R="$OWNERS_DIR/$O"
25
+ [ -f "$R/.tealbrick/native-serve.json" ] || stop "kit owner '$O' is not set up under $OWNERS_DIR"
26
+ RELAY=$(cat "$D/relay-url" 2>/dev/null || cat "$HOME/agents/buzz/relay-url" 2>/dev/null || true)
27
+ [ -n "$RELAY" ] || stop "no relay URL ($D/relay-url)"
28
+ [ -s "$D/auth-tag.json" ] || stop "no auth tag ($D/auth-tag.json)"
29
+ [ -s "$D/nostr.key" ] || stop "no agent key ($D/nostr.key)"
30
+ exec /usr/bin/env -i HOME="$HOME" USER="${USER:-$(id -un)}" LOGNAME="${LOGNAME:-$(id -un)}" \
31
+ PATH="$(dirname "$NODE"):/usr/bin:/bin:/usr/sbin:/sbin" LANG=en_US.UTF-8 \
32
+ BUZZ_RELAY_URL="$RELAY" \
33
+ BUZZ_ACP_AGENT_COMMAND="$NODE" BUZZ_ACP_AGENT_ARGS="$KIT_CLI,native,acp,--root,$R,--buzz-cli,$BUZZ" \
34
+ BUZZ_ACP_RESPOND_TO=owner-only BUZZ_ACP_SESSION_POLICY=thread BUZZ_ACP_PERMISSION_MODE=default \
35
+ BUZZ_ACP_MULTIPLE_EVENT_HANDLING=queue BUZZ_ACP_IDLE_TIMEOUT=1800 BUZZ_ACP_NO_BASE_PROMPT=true \
36
+ BUZZ_ACP_NO_MEMORY=true BUZZ_ACP_CONFIG="$D/buzz-acp.toml" RUST_LOG=info \
37
+ $(grep -E "^BUZZ_ACP_(SUBSCRIBE|NO_MENTION_FILTER|CHANNELS|KINDS|RESPOND_TO|RESPOND_TO_ALLOWLIST|SESSION_POLICY|IDLE_TIMEOUT|MAX_TURN_DURATION|TURN_TIMEOUT|AGENTS|HEARTBEAT_INTERVAL|DEDUP|MULTIPLE_EVENT_HANDLING|RELAY_OBSERVER|LAZY_POOL)=[A-Za-z0-9,._:-]*$" "$D/acp.env" 2>/dev/null || true) \
38
+ /bin/bash -c 'set -eu; BUZZ_PRIVATE_KEY=$(cat "$1"); BUZZ_AUTH_TAG=$(cat "$2"); export BUZZ_PRIVATE_KEY BUZZ_AUTH_TAG; exec "$3"' \
39
+ buzz-run "$D/nostr.key" "$D/auth-tag.json" "$BUZZ_ACP"
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env python3
2
+ # tealbrick-buzz-host: installed by `tealbrick buzz host setup`; rewritten on every setup run.
3
+ """Host side of the Teal Brick Buzz Desktop provider. Reads ONE JSON request on stdin (it carries the agent
4
+ key; never argv/env), materialises ~/agents/buzz/<label>/, (re)loads its launchd unit and reports health
5
+ as one JSON line. Idempotent: a redeploy rewrites config and restarts the unit. Host paths come from
6
+ ~/agents/buzz/host.json (written by `tealbrick buzz host setup`)."""
7
+ import json, os, re, subprocess, sys, time
8
+ H = os.path.expanduser('~'); BUZZ = os.path.join(H, 'agents', 'buzz')
9
+ HEX = re.compile(r'^[0-9a-f]{64}$')
10
+ LAUNCHCTL = '/bin/launchctl'
11
+ def out(o, code=0): print(json.dumps(o)); sys.exit(code)
12
+ def redact(text): return re.sub(r'nsec1[0-9a-z]+|[0-9a-fA-F]{64}', '<redacted>', text)
13
+ def fail(msg): out({'ok': False, 'error': redact(msg)}, 1)
14
+ def write(path, text, mode=0o600):
15
+ tmp = path + '.tmp'; fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
16
+ with os.fdopen(fd, 'w') as f: f.write(text)
17
+ os.chmod(tmp, mode); os.replace(tmp, path)
18
+ SAFE = re.compile(r'^[A-Za-z0-9,._:-]*$')
19
+ ALLOWED_POLICY = {'BUZZ_ACP_SESSION_POLICY', 'BUZZ_ACP_IDLE_TIMEOUT', 'BUZZ_ACP_MAX_TURN_DURATION',
20
+ 'BUZZ_ACP_TURN_TIMEOUT', 'BUZZ_ACP_HEARTBEAT_INTERVAL', 'BUZZ_ACP_DEDUP',
21
+ 'BUZZ_ACP_MULTIPLE_EVENT_HANDLING', 'BUZZ_ACP_SUBSCRIBE', 'BUZZ_ACP_NO_MENTION_FILTER',
22
+ 'BUZZ_ACP_CHANNELS', 'BUZZ_ACP_KINDS', 'BUZZ_ACP_RELAY_OBSERVER', 'BUZZ_ACP_LAZY_POOL', 'BUZZ_ACP_AGENTS'}
23
+ def host_config():
24
+ try:
25
+ with open(os.path.join(BUZZ, 'host.json')) as f: cfg = json.load(f)
26
+ except FileNotFoundError: fail('host is not set up: run `tealbrick buzz host setup` on the host')
27
+ if not isinstance(cfg, dict) or not isinstance(cfg.get('ownersDir'), str) or not os.path.isabs(cfg['ownersDir']):
28
+ fail('host.json has no absolute ownersDir: re-run `tealbrick buzz host setup`')
29
+ return cfg
30
+ def main():
31
+ req = json.loads(sys.stdin.read())
32
+ if not isinstance(req, dict): fail('bad request')
33
+ label, owner, relay = str(req.get('label') or ''), str(req.get('owner') or ''), str(req.get('relay_url') or '')
34
+ if not re.fullmatch(r'[a-z0-9][a-z0-9-]{2,62}', label): fail('bad label')
35
+ if not re.fullmatch(r'[a-z0-9-]{2,32}', owner) or not label.startswith(owner + '-'): fail('bad owner')
36
+ owners_dir = host_config()['ownersDir']
37
+ if not os.path.isfile(os.path.join(owners_dir, owner, '.tealbrick', 'native-serve.json')):
38
+ fail(f'unknown or unenrolled kit owner {owner!r} on this host')
39
+ if not re.fullmatch(r'wss?://[A-Za-z0-9.-]+(:\d+)?/?', relay): fail('bad relay_url')
40
+ owner_pub = str(req.get('owner_pubkey') or ''); agent_pub = str(req.get('agent_pubkey') or '')
41
+ if not (HEX.match(owner_pub) and HEX.match(agent_pub)): fail('bad pubkeys')
42
+ try: tag = json.loads(req.get('auth_tag') or '')
43
+ except (TypeError, ValueError): tag = None
44
+ if not (isinstance(tag, list) and len(tag) == 4 and tag[0] == 'auth' and tag[1] == owner_pub and all(isinstance(x, str) for x in tag)):
45
+ fail('bad auth_tag')
46
+ desktop_rt = req.get('respond_to') or 'owner-only'
47
+ if desktop_rt not in ('owner-only', 'allowlist', 'anyone', 'nobody'): fail('bad respond_to')
48
+ # Fleet default (~/agents/buzz/fleet-defaults.json, e.g. {"respond_to": "anyone"}) replaces Desktop's
49
+ # *default* owner-only on a membership-only relay; a stricter explicit choice (nobody/allowlist) wins.
50
+ try:
51
+ with open(os.path.join(BUZZ, 'fleet-defaults.json')) as f: fleet = json.load(f)
52
+ except FileNotFoundError: fleet = {}
53
+ if not isinstance(fleet, dict): fleet = {}
54
+ rt = fleet.get('respond_to') if desktop_rt == 'owner-only' and fleet.get('respond_to') in ('anyone', 'owner-only') else desktop_rt
55
+ req['respond_to_effective'] = rt; req['respond_to_desktop'] = desktop_rt
56
+ allow = [p for p in (req.get('respond_to_allowlist') or []) if isinstance(p, str) and HEX.match(p)]
57
+ sk = str(req.get('private_key') or '')
58
+ if not (HEX.match(sk) or re.fullmatch(r'nsec1[0-9a-z]{58}', sk)): fail('bad private key')
59
+ rules = req.get('rules_toml')
60
+ if not isinstance(rules, str) or len(rules) > 4096: fail('bad rules_toml')
61
+ D = os.path.join(BUZZ, label); os.makedirs(os.path.join(D, 'logs'), mode=0o700, exist_ok=True); os.chmod(D, 0o700)
62
+ write(f'{D}/nostr.key', sk + '\n'); write(f'{D}/nostr.pub', agent_pub + '\n', 0o644)
63
+ write(f'{D}/auth-tag.json', json.dumps(tag)); write(f'{D}/owner', owner + '\n'); write(f'{D}/relay-url', relay + '\n')
64
+ env = {'BUZZ_ACP_SUBSCRIBE': 'config', 'BUZZ_ACP_RESPOND_TO': rt}
65
+ if rt == 'allowlist': env['BUZZ_ACP_RESPOND_TO_ALLOWLIST'] = ','.join(allow)
66
+ policy = req.get('policy_env') or {}
67
+ for k, v in (policy.items() if isinstance(policy, dict) else []):
68
+ if k in ALLOWED_POLICY and SAFE.match(str(v)) and k != 'BUZZ_ACP_SUBSCRIBE': env[k] = str(v)
69
+ if 'BUZZ_ACP_AGENTS' in env: # Desktop parallelism, capped: each slot is one kit native acp process
70
+ env['BUZZ_ACP_AGENTS'] = str(min(4, max(1, int(env['BUZZ_ACP_AGENTS']) if env['BUZZ_ACP_AGENTS'].isdigit() else 1)))
71
+ write(f'{D}/acp.env', ''.join(f'{k}={v}\n' for k, v in sorted(env.items())))
72
+ write(f'{D}/buzz-acp.toml', rules)
73
+ write(f'{D}/deploy.json', json.dumps({k: req.get(k) for k in ('label', 'owner', 'relay_url', 'agent_pubkey', 'owner_pubkey', 'respond_to_desktop', 'respond_to_effective', 'name')}, indent=1))
74
+ subprocess.run([sys.executable, os.path.join(BUZZ, 'bin', 'buzz-plist.py'), label], check=True, capture_output=True)
75
+ uid = os.getuid(); svc = f'gui/{uid}/com.tealbrick.buzz-acp.{label}'
76
+ plist = os.path.join(H, 'Library', 'LaunchAgents', f'com.tealbrick.buzz-acp.{label}.plist')
77
+ log = f'{D}/logs/buzz-acp.err.log'; start = os.path.getsize(log) if os.path.exists(log) else 0
78
+ loaded = subprocess.run([LAUNCHCTL, 'print', svc], capture_output=True).returncode == 0
79
+ if loaded: subprocess.run([LAUNCHCTL, 'kickstart', '-k', svc], check=True, capture_output=True)
80
+ else: subprocess.run([LAUNCHCTL, 'bootstrap', f'gui/{uid}', plist], check=True, capture_output=True)
81
+ ansi = re.compile(r'\x1b\[[0-9;]*m'); tail = ''
82
+ for _ in range(40):
83
+ time.sleep(1)
84
+ if not os.path.exists(log): continue
85
+ with open(log, errors='replace') as f: f.seek(start); tail = ansi.sub('', f.read())
86
+ if 'subscribed to membership notifications' in tail or 'subscribed to channel' in tail:
87
+ out({'ok': True, 'label': label, 'service': svc})
88
+ bad = re.search(r'(?i)(relay_membership_required|auth[^\n]*(fail|denied|invalid)|ERROR[^\n]*)', tail)
89
+ if bad:
90
+ subprocess.run([LAUNCHCTL, 'bootout', svc], capture_output=True) # no crash-looping unit on a failed deploy
91
+ fail('harness failed: ' + bad.group(0)[:300])
92
+ subprocess.run([LAUNCHCTL, 'bootout', svc], capture_output=True)
93
+ fail('harness did not subscribe within 40s')
94
+ if __name__ == '__main__':
95
+ try: main()
96
+ except SystemExit: raise
97
+ except Exception as e: fail(f'{type(e).__name__}: {str(e)[:200]}')
package/dist/buzz.d.ts ADDED
@@ -0,0 +1,63 @@
1
+ export declare const PROVIDER_MARKER = "tealbrick-buzz-provider";
2
+ export declare const buzzUsage = "Usage: tealbrick buzz provider install [--name NAME] [--bin-dir DIRECTORY] [--force]\n tealbrick buzz host setup [--owners-dir DIRECTORY] [--buzz-app PATH] [--node PATH] [--kit-cli PATH] [--respond-to-default owner-only|anyone]\n tealbrick buzz host remove <label>";
3
+ export declare function installProvider(options?: {
4
+ name?: string;
5
+ binDir?: string;
6
+ force?: boolean;
7
+ home?: string;
8
+ }): Promise<{
9
+ path: string;
10
+ name: string;
11
+ version: string;
12
+ updated: boolean;
13
+ discoverable: boolean;
14
+ python3: boolean;
15
+ }>;
16
+ export type HostSetupOptions = {
17
+ home?: string;
18
+ ownersDir?: string;
19
+ buzzApp?: string;
20
+ node?: string;
21
+ kitCli?: string;
22
+ respondToDefault?: 'owner-only' | 'anyone';
23
+ /** Installs @tealbrick/kit@version under prefix and returns the installed dist/cli.js (tests inject this). */
24
+ installKit?: (prefix: string, version: string) => Promise<string>;
25
+ };
26
+ export declare function setupBuzzHost(options?: HostSetupOptions): Promise<{
27
+ scripts: string[];
28
+ fleetDefaults: "owner-only" | "anyone" | null;
29
+ owners: string[];
30
+ warnings: string[];
31
+ version: number;
32
+ kitVersion: string;
33
+ node: string;
34
+ kitCli: string;
35
+ ownersDir: string;
36
+ buzzBin: string;
37
+ buzzAcpBin: string;
38
+ buzzAppVersion: string;
39
+ buzzSha256: {
40
+ buzz: string;
41
+ 'buzz-acp': string;
42
+ };
43
+ hostJson: string;
44
+ }>;
45
+ export type LaunchctlRun = (args: string[]) => Promise<number>;
46
+ /**
47
+ * Remove one provider-managed agent from this host (Buzz Desktop has no undeploy operation): boot out its
48
+ * launchd unit, delete the plist and move ~/agents/buzz/<label> to ~/agents/buzz/.removed/<label>-<ts>
49
+ * (0700; it still holds the agent key until you delete it).
50
+ */
51
+ export declare function removeBuzzAgent(label: string, options?: {
52
+ home?: string;
53
+ uid?: number;
54
+ launchctl?: LaunchctlRun;
55
+ now?: () => Date;
56
+ }): Promise<{
57
+ label: string;
58
+ service: string;
59
+ bootedOut: boolean;
60
+ plistRemoved: boolean;
61
+ movedTo: string | undefined;
62
+ }>;
63
+ export declare function buzzCommand(args: string[]): Promise<void>;
package/dist/buzz.js ADDED
@@ -0,0 +1,262 @@
1
+ import { chmod, copyFile, lstat, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
3
+ import { execFile } from 'node:child_process';
4
+ import { homedir } from 'node:os';
5
+ import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { promisify } from 'node:util';
8
+ /** Buzz Desktop managed-agent provider (client Mac) and its agent host (Mac running buzz-acp + kit native acp). */
9
+ const run = promisify(execFile);
10
+ const assets = fileURLToPath(new URL('../assets/buzz/', import.meta.url));
11
+ export const PROVIDER_MARKER = 'tealbrick-buzz-provider';
12
+ export const buzzUsage = `Usage: tealbrick buzz provider install [--name NAME] [--bin-dir DIRECTORY] [--force]
13
+ tealbrick buzz host setup [--owners-dir DIRECTORY] [--buzz-app PATH] [--node PATH] [--kit-cli PATH] [--respond-to-default owner-only|anyone]
14
+ tealbrick buzz host remove <label>`;
15
+ async function kitVersion() { return JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version; }
16
+ function flagsOf(args, values, switches) {
17
+ const flags = {};
18
+ for (let i = 0; i < args.length; i++) {
19
+ const name = /^--([a-z-]+)$/.exec(args[i])?.[1];
20
+ if (!name || flags[name] !== undefined)
21
+ throw Error('kit_buzz_invalid_arguments');
22
+ if (switches.includes(name)) {
23
+ flags[name] = true;
24
+ continue;
25
+ }
26
+ if (!values.includes(name) || !args[i + 1] || args[i + 1].startsWith('--'))
27
+ throw Error('kit_buzz_invalid_arguments');
28
+ flags[name] = args[++i];
29
+ }
30
+ return flags;
31
+ }
32
+ function expand(path, home) { return resolve(path === '~' ? home : path.startsWith('~/') ? join(home, path.slice(2)) : path); }
33
+ async function exists(path) { try {
34
+ await stat(path);
35
+ return true;
36
+ }
37
+ catch {
38
+ return false;
39
+ } }
40
+ async function writeAtomic(path, body, mode) {
41
+ const tmp = `${path}.${process.pid}.tmp`;
42
+ await writeFile(tmp, body, { mode });
43
+ await chmod(tmp, mode);
44
+ await rename(tmp, path);
45
+ }
46
+ /** Paths end up in a shell script and in buzz-acp's comma-separated BUZZ_ACP_AGENT_ARGS. */
47
+ function hostPath(name, path) {
48
+ if (!isAbsolute(path) || /[,\x00-\x1f\x7f]/.test(path))
49
+ throw Error(`kit_buzz_${name}_path_invalid`);
50
+ return path;
51
+ }
52
+ function shellQuote(value) { return `'${value.replace(/'/g, `'\\''`)}'`; }
53
+ async function python3Ok() { try {
54
+ await run('/usr/bin/python3', ['-c', 'import json, plistlib']);
55
+ return true;
56
+ }
57
+ catch {
58
+ return false;
59
+ } }
60
+ export async function installProvider(options = {}) {
61
+ const home = options.home ?? homedir(), name = options.name ?? 'tealbrick-mac';
62
+ if (!/^[a-z0-9-]{3,40}$/.test(name))
63
+ throw Error('kit_buzz_provider_name_invalid');
64
+ const binDir = expand(options.binDir ?? '~/.local/bin', home), path = join(binDir, `buzz-backend-${name}`);
65
+ let existing;
66
+ try {
67
+ existing = await readFile(path, 'utf8');
68
+ }
69
+ catch (error) {
70
+ if (error?.code !== 'ENOENT')
71
+ throw error;
72
+ }
73
+ if (existing !== undefined && !existing.split('\n', 3).some(line => line.includes(PROVIDER_MARKER)) && !options.force)
74
+ throw Error('kit_buzz_provider_foreign_file');
75
+ const version = await kitVersion();
76
+ const body = (await readFile(join(assets, 'buzz-backend-provider.py'), 'utf8')).replaceAll('__TB_PROVIDER_ID__', name).replaceAll('__TB_KIT_VERSION__', version);
77
+ await mkdir(binDir, { recursive: true });
78
+ await writeAtomic(path, body, 0o755);
79
+ const onPath = (process.env.PATH ?? '').split(delimiter).some(entry => entry && resolve(entry) === binDir);
80
+ return { path, name, version, updated: existing !== undefined, discoverable: onPath || binDir === join(home, '.local', 'bin'), python3: await python3Ok() };
81
+ }
82
+ async function npmInstallKit(prefix, version) {
83
+ const local = join(dirname(process.execPath), 'npm');
84
+ const npm = await exists(local) ? local : 'npm';
85
+ try {
86
+ await run(npm, ['install', '--prefix', prefix, '--no-audit', '--no-fund', '--save-exact', `@tealbrick/kit@${version}`], { maxBuffer: 16 * 1024 * 1024 });
87
+ }
88
+ catch {
89
+ throw Error('kit_buzz_kit_install_failed');
90
+ }
91
+ return join(prefix, 'node_modules', '@tealbrick', 'kit', 'dist', 'cli.js');
92
+ }
93
+ async function bundleVersion(app) {
94
+ const plist = join(app, 'Contents', 'Info.plist');
95
+ try {
96
+ const { stdout } = await run('/usr/bin/plutil', ['-extract', 'CFBundleShortVersionString', 'raw', '-o', '-', plist]);
97
+ if (stdout.trim())
98
+ return stdout.trim();
99
+ }
100
+ catch { }
101
+ try {
102
+ const text = await readFile(plist, 'utf8');
103
+ return /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/.exec(text)?.[1] ?? 'unknown';
104
+ }
105
+ catch {
106
+ return 'unknown';
107
+ }
108
+ }
109
+ async function sha256(path) { return createHash('sha256').update(await readFile(path)).digest('hex'); }
110
+ export async function setupBuzzHost(options = {}) {
111
+ const home = options.home ?? homedir(), version = await kitVersion();
112
+ if (options.respondToDefault !== undefined && !['owner-only', 'anyone'].includes(options.respondToDefault))
113
+ throw Error('kit_buzz_respond_to_default_invalid');
114
+ const ownersDir = hostPath('owners_dir', expand(options.ownersDir ?? '~/agents/tealbrick-connections', home));
115
+ const app = expand(options.buzzApp ?? '/Applications/Buzz.app', home);
116
+ const node = hostPath('node', expand(options.node ?? process.execPath, home));
117
+ if (!(await stat(node).catch(() => undefined))?.isFile())
118
+ throw Error('kit_buzz_node_missing');
119
+ const sources = { buzz: join(app, 'Contents', 'MacOS', 'buzz'), 'buzz-acp': join(app, 'Contents', 'MacOS', 'buzz-acp') };
120
+ for (const source of Object.values(sources))
121
+ if (!(await stat(source).catch(() => undefined))?.isFile())
122
+ throw Error('kit_buzz_app_binaries_missing');
123
+ const root = join(home, 'agents', 'buzz'), bin = join(root, 'bin'), runtime = join(root, 'runtime');
124
+ for (const dir of [root, bin, runtime]) {
125
+ await mkdir(dir, { recursive: true, mode: 0o700 });
126
+ await chmod(dir, 0o700);
127
+ }
128
+ const buzzBin = hostPath('buzz_bin', join(bin, 'buzz')), buzzAcpBin = hostPath('buzz_acp_bin', join(bin, 'buzz-acp'));
129
+ // Copy then rename, so a running buzz-acp keeps its inode and a Buzz.app update never changes agents silently.
130
+ for (const [source, target] of [[sources.buzz, buzzBin], [sources['buzz-acp'], buzzAcpBin]]) {
131
+ const tmp = `${target}.${process.pid}.tmp`;
132
+ await copyFile(source, tmp);
133
+ await chmod(tmp, 0o755);
134
+ await rename(tmp, target);
135
+ }
136
+ const kitCli = hostPath('kit_cli', options.kitCli ? expand(options.kitCli, home) : await (options.installKit ?? npmInstallKit)(runtime, version));
137
+ if (!(await stat(kitCli).catch(() => undefined))?.isFile())
138
+ throw Error('kit_buzz_kit_cli_missing');
139
+ const host = { version: 1, kitVersion: version, node, kitCli, ownersDir, buzzBin, buzzAcpBin, buzzAppVersion: await bundleVersion(app),
140
+ buzzSha256: { buzz: await sha256(buzzBin), 'buzz-acp': await sha256(buzzAcpBin) } };
141
+ await writeAtomic(join(root, 'host.json'), JSON.stringify(host, null, 1) + '\n', 0o600);
142
+ const values = { NODE: node, KIT_CLI: kitCli, OWNERS_DIR: ownersDir, BUZZ_BIN: buzzBin, BUZZ_ACP_BIN: buzzAcpBin };
143
+ const runner = (await readFile(join(assets, 'buzz-run.sh'), 'utf8')).replace(/@@([A-Z_]+)@@/g, (_, key) => { if (!Object.hasOwn(values, key))
144
+ throw Error('kit_buzz_asset_placeholder_unknown'); return shellQuote(values[key]); });
145
+ await writeAtomic(join(bin, 'buzz-run.sh'), runner, 0o700);
146
+ for (const script of ['buzz-plist.py', 'tb-provider-apply.py'])
147
+ await writeAtomic(join(bin, script), await readFile(join(assets, script)), 0o700);
148
+ if (options.respondToDefault)
149
+ await writeAtomic(join(root, 'fleet-defaults.json'), JSON.stringify({ respond_to: options.respondToDefault }) + '\n', 0o600);
150
+ const owners = [];
151
+ try {
152
+ for (const entry of await readdir(ownersDir, { withFileTypes: true }))
153
+ if (entry.isDirectory() && /^[a-z0-9-]{2,32}$/.test(entry.name) && await exists(join(ownersDir, entry.name, '.tealbrick', 'native-serve.json')))
154
+ owners.push(entry.name);
155
+ }
156
+ catch { }
157
+ const warnings = [];
158
+ if (!await python3Ok())
159
+ warnings.push('/usr/bin/python3 is not usable; install the Xcode Command Line Tools (xcode-select --install).');
160
+ if (!owners.length)
161
+ warnings.push(`No enrolled kit owners under ${ownersDir}; run \`tealbrick native setup\` and \`tealbrick native enroll\` in ${ownersDir}/<owner>.`);
162
+ return { hostJson: join(root, 'host.json'), ...host, scripts: ['buzz-run.sh', 'buzz-plist.py', 'tb-provider-apply.py'].map(name => join(bin, name)),
163
+ fleetDefaults: options.respondToDefault ?? null, owners, warnings };
164
+ }
165
+ const launchctl = args => run('/bin/launchctl', args).then(() => 0, (e) => typeof e?.code === 'number' ? e.code : 1);
166
+ const AGENT_LABEL = /^[a-z0-9][a-z0-9-]{2,62}$/;
167
+ /** Names under ~/agents/buzz that are host files, never agents. */
168
+ const RESERVED_LABELS = ['bin', 'runtime', 'logs'];
169
+ /**
170
+ * Remove one provider-managed agent from this host (Buzz Desktop has no undeploy operation): boot out its
171
+ * launchd unit, delete the plist and move ~/agents/buzz/<label> to ~/agents/buzz/.removed/<label>-<ts>
172
+ * (0700; it still holds the agent key until you delete it).
173
+ */
174
+ export async function removeBuzzAgent(label, options = {}) {
175
+ if (!AGENT_LABEL.test(label) || RESERVED_LABELS.includes(label))
176
+ throw Error('kit_buzz_label_invalid');
177
+ const home = options.home ?? homedir(), uid = options.uid ?? process.getuid?.() ?? 0, unit = `com.tealbrick.buzz-acp.${label}`, service = `gui/${uid}/${unit}`;
178
+ const root = join(home, 'agents', 'buzz'), dir = join(root, label), plist = join(home, 'Library', 'LaunchAgents', `${unit}.plist`);
179
+ const exec = options.launchctl ?? launchctl;
180
+ const loaded = await exec(['print', service]) === 0;
181
+ if (loaded && await exec(['bootout', service]) !== 0)
182
+ throw Error('kit_buzz_bootout_failed');
183
+ const plistStat = await lstat(plist).catch(() => undefined);
184
+ if (plistStat) {
185
+ if (!plistStat.isFile() || plistStat.isSymbolicLink())
186
+ throw Error('kit_buzz_plist_unsafe');
187
+ await unlink(plist);
188
+ }
189
+ const dirStat = await lstat(dir).catch(() => undefined);
190
+ let movedTo;
191
+ if (dirStat) {
192
+ if (!dirStat.isDirectory() || dirStat.isSymbolicLink())
193
+ throw Error('kit_buzz_agent_dir_unsafe');
194
+ const removed = join(root, '.removed');
195
+ await mkdir(removed, { recursive: true, mode: 0o700 });
196
+ const removedStat = await lstat(removed);
197
+ if (!removedStat.isDirectory() || removedStat.isSymbolicLink())
198
+ throw Error('kit_buzz_removed_dir_unsafe');
199
+ await chmod(removed, 0o700);
200
+ movedTo = join(removed, `${label}-${(options.now?.() ?? new Date()).toISOString().replace(/[:.]/g, '-')}`);
201
+ await rename(dir, movedTo);
202
+ await chmod(movedTo, 0o700);
203
+ }
204
+ if (!loaded && !plistStat && !dirStat)
205
+ throw Error('kit_buzz_agent_not_found');
206
+ return { label, service, bootedOut: loaded, plistRemoved: !!plistStat, movedTo };
207
+ }
208
+ export async function buzzCommand(args) {
209
+ const [group, action, ...rest] = args;
210
+ if (!group || group === 'help' || group === '--help' || group === '-h') {
211
+ console.log(buzzUsage);
212
+ return;
213
+ }
214
+ if (group === 'provider' && action === 'install') {
215
+ const flags = flagsOf(rest, ['name', 'bin-dir'], ['force']);
216
+ const result = await installProvider({ ...(typeof flags.name === 'string' ? { name: flags.name } : {}), ...(typeof flags['bin-dir'] === 'string' ? { binDir: flags['bin-dir'] } : {}), force: flags.force === true });
217
+ console.log(`${result.updated ? 'Updated' : 'Installed'} Buzz Desktop provider ${result.name} (kit ${result.version}):\n ${result.path}`);
218
+ if (!result.discoverable)
219
+ console.log(`Warning: ${dirname(result.path)} is not on PATH; Buzz Desktop looks in ~/.local/bin and on PATH.`);
220
+ if (!result.python3)
221
+ console.log('Warning: /usr/bin/python3 is not usable; install the Xcode Command Line Tools (xcode-select --install).');
222
+ console.log(`Next, in Buzz Desktop:
223
+ 1. Restart Buzz Desktop so it finds the new provider.
224
+ 2. Open Agents > New agent.
225
+ 3. Set "Run on" to ${result.name} (Teal Brick · ${result.name}).
226
+ 4. Enter owner (the kit owner directory on the host) and host (the ssh alias or user@host of the agent host).
227
+ 5. Create the agent. Desktop deploys it over ssh; the host must have run \`tealbrick buzz host setup\`.`);
228
+ return;
229
+ }
230
+ if (group === 'host' && action === 'setup') {
231
+ const flags = flagsOf(rest, ['owners-dir', 'buzz-app', 'node', 'kit-cli', 'respond-to-default'], []);
232
+ const str = (name) => typeof flags[name] === 'string' ? flags[name] : undefined;
233
+ const respond = str('respond-to-default');
234
+ if (respond !== undefined && respond !== 'owner-only' && respond !== 'anyone')
235
+ throw Error('kit_buzz_respond_to_default_invalid');
236
+ const result = await setupBuzzHost({ ownersDir: str('owners-dir'), buzzApp: str('buzz-app'), node: str('node'), kitCli: str('kit-cli'), respondToDefault: respond });
237
+ console.log(`Buzz agent host ready (kit ${result.kitVersion}, Buzz ${result.buzzAppVersion}).
238
+ host.json: ${result.hostJson}
239
+ node: ${result.node}
240
+ kit cli: ${result.kitCli}
241
+ owners dir: ${result.ownersDir}${result.owners.length ? ` (enrolled: ${result.owners.join(', ')})` : ''}
242
+ buzz: ${result.buzzBin}
243
+ buzz-acp: ${result.buzzAcpBin}
244
+ fleet respond_to default: ${result.fleetDefaults ?? '(none; Desktop choice applies)'}`);
245
+ for (const warning of result.warnings)
246
+ console.log(`Warning: ${warning}`);
247
+ return;
248
+ }
249
+ if (group === 'host' && action === 'remove') {
250
+ if (rest.length !== 1)
251
+ throw Error('kit_buzz_invalid_arguments');
252
+ const result = await removeBuzzAgent(rest[0]);
253
+ console.log(`Removed Buzz agent ${result.label}:
254
+ launchd unit: ${result.bootedOut ? `booted out (${result.service})` : 'was not loaded'}
255
+ plist: ${result.plistRemoved ? 'deleted' : 'not present'}
256
+ agent dir: ${result.movedTo ? `moved to ${result.movedTo} (still holds the agent key; delete it when no longer needed)` : 'not present'}
257
+ Also delete the agent in Buzz Desktop so it is not deployed again.`);
258
+ return;
259
+ }
260
+ console.error(buzzUsage);
261
+ process.exitCode = 2;
262
+ }