@staix/agent-hub 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +4 -4
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +13 -10
- package/docs/agent-notes/bus.md +1 -1
- package/docs/agent-notes/daemon.md +11 -4
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +6 -1
- package/docs/agent-notes/tests.md +8 -4
- package/docs/dogfood.md +13 -4
- package/docs/memory.md +1 -1
- package/docs/operations.md +98 -28
- package/docs/quickstart.md +13 -5
- package/docs/sdlc.md +12 -0
- package/docs/security.md +24 -15
- package/docs/smoke.md +3 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +101 -8
- package/docs/verification/2026-10-25-remote-docker-transport.md +10 -0
- package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
- package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
- package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
- package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
- package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
- package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
- package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
- package/docs/verification/2026-10-27-0.19.0.md +86 -0
- package/docs/verification/2026-10-27-0.20.0.md +75 -0
- package/docs/verification/claude-permission-transport-native.py +187 -0
- package/docs/verification/claude-shell-wrapper-host.ts +20 -0
- package/docs/verification/claude-shell-wrapper-native.py +145 -0
- package/docs/verification/pi-140-model-free.ts +57 -0
- package/docs/verification/pi-140-native.mjs +21 -0
- package/docs/verified.json +25 -17
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +373 -72
- package/src/adapters/acp.ts +8 -0
- package/src/adapters/agy/entry.ts +1 -1
- package/src/adapters/channel-peer.ts +7 -5
- package/src/adapters/claude-channel.ts +7 -3
- package/src/adapters/claude-implementer.ts +6 -3
- package/src/adapters/codex-appserver.ts +11 -1
- package/src/adapters/compat.ts +32 -18
- package/src/adapters/conformance/enrollment.ts +4 -4
- package/src/adapters/conformance/factories.ts +2 -1
- package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
- package/src/adapters/conformance/kit.ts +10 -0
- package/src/adapters/definitions/builtin-metadata.ts +2 -2
- package/src/adapters/definitions/native-factories.ts +7 -2
- package/src/adapters/definitions/reviewer-variants.ts +2 -2
- package/src/adapters/local-worker.ts +2 -0
- package/src/adapters/pi.ts +46 -3
- package/src/adapters/qwen-dual.ts +25 -0
- package/src/adapters/registry-types.ts +6 -2
- package/src/adapters/registry.ts +1 -0
- package/src/adapters/reviewer-print.ts +1 -0
- package/src/cli/agents.ts +69 -7
- package/src/cli/claude-shell-wrapper.ts +54 -0
- package/src/cli/main.ts +30 -24
- package/src/cli/output.ts +4 -0
- package/src/hub/agent-drift.ts +61 -0
- package/src/hub/board.ts +14 -2
- package/src/hub/check-runner.ts +148 -18
- package/src/hub/ci-watch.ts +140 -0
- package/src/hub/claude-launch-proof.ts +67 -2
- package/src/hub/claude-shell-wrapper.ts +349 -0
- package/src/hub/config-apply.ts +2 -2
- package/src/hub/config-edit.ts +2 -2
- package/src/hub/config-tier.ts +2 -2
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/daemon.ts +282 -113
- package/src/hub/host-executable.ts +20 -1
- package/src/hub/instances.ts +18 -2
- package/src/hub/lifecycle.ts +1 -1
- package/src/hub/operational-authority.ts +7 -0
- package/src/hub/operational-view.ts +6 -10
- package/src/hub/peers.ts +34 -0
- package/src/hub/read-only-shell-proof.ts +7 -6
- package/src/hub/review-scope.ts +13 -0
- package/src/hub/sdlc-metrics.ts +6 -4
- package/src/hub/settings.ts +1 -0
- package/src/hub/task-shell-command.ts +85 -0
- package/src/hub/task-shell-default.ts +79 -0
- package/src/hub/task-shell-proof.ts +171 -0
- package/src/hub/task-shell-sandbox.ts +61 -0
- package/src/hub/tasks.ts +274 -16
- package/src/local/deny.ts +112 -11
- package/src/local/sandbox.ts +70 -29
- package/src/local/shell-environment.ts +10 -0
- package/src/ui/index.html +1 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# 0.20.0 release verification
|
|
2
|
+
|
|
3
|
+
This record covers release preparation and source review. It claims no installation, no running-hub upgrade to 0.20.0 and no live TUI launch.
|
|
4
|
+
|
|
5
|
+
## Candidate
|
|
6
|
+
|
|
7
|
+
Version 0.20.0, prepared from main 8403b84d plus the agent-mem pin commit b72a4c24 (14 commits since v0.19.0, 3 of them `feat`; bump rule: minor). The release commits carry the `@staix/agent-mem` 0.11.0 pin, the version, the CHANGELOG section, the bundle (plugin 0.20.0, built with the pinned Bun 1.4.2 after a frozen install), the document corrections and the documentation stamps. `scripts/release-prepare.ts` (#893) wrote the version files and this report's skeleton.
|
|
8
|
+
|
|
9
|
+
What changed (see `CHANGELOG.md` for the full list):
|
|
10
|
+
- A bounded CI watcher per exact PR head with one `ci_completed` notice to the owner and the reviewer, and a held premature review re-offered on green (#785: #886).
|
|
11
|
+
- Opt-in, machine-local `tasks.auto_pull`: a freed planner slot pulls the top backlog card explicitly reserved for that peer (#785: #889).
|
|
12
|
+
- Release preparation as an agent task and a guarded one-shot tag that needs the person's consent (#787: #893).
|
|
13
|
+
- The local-worker and Pi sandbox adds only new loose objects to the shared git object store (#864: #881).
|
|
14
|
+
- Peer ports replace native type checks in the daemon (#625 slices 2b to 2e: #882, #887, #890, #891); behavior unchanged.
|
|
15
|
+
- `@staix/agent-mem` 0.11.0; the mawk progress filter (#883: #884); the quiet branch binding (#880: #885); the default-branch probe isolation (#863: #875); hang-watch test margins (#877); the accepted external module-instance contract (#754: #892, spec only).
|
|
16
|
+
|
|
17
|
+
## Reviews
|
|
18
|
+
|
|
19
|
+
Every PR below merged with exact-head CI green (ubuntu, macOS and seeded guards), no unresolved review thread and no pending change request; the conductor read each before merging. (#880 and #883 in the commit subjects are issues, fixed by #885 and #884.)
|
|
20
|
+
|
|
21
|
+
| PR | Task | Merge | Review |
|
|
22
|
+
| --- | --- | --- | --- |
|
|
23
|
+
| #875 (#863) | | 8122373b | merged earlier on 2026-10-27, exact-head CI green |
|
|
24
|
+
| #877 | | 8a48175a | merged earlier on 2026-10-27, exact-head CI green |
|
|
25
|
+
| #881 (#864) | 522 | d089ebaa | the person approved PR #881 at 0f70acb1 (codex read-only review addressed); the board approval was recorded by the person afterwards |
|
|
26
|
+
| #882 (#625 2b) | 348 | 8b2579e5 | claude, approved at the merged head |
|
|
27
|
+
| #884 (#883) | | 0afa6bdd | the person approved; codex read-only review (OCR delegation), no findings |
|
|
28
|
+
| #885 (#880) | | db50fddd | the person approved; codex read-only review (OCR delegation), no findings; backlog card 528 closed as superseded by it |
|
|
29
|
+
| #886 (#785 P1b) | 458 | 0682dd4c | claude approved at the board-bound 5b525c2e; the PR head a2d1f73d added only a real-daemon test and a host-only seam, reviewed, and the merge is pinned to it |
|
|
30
|
+
| #887 (#625 2c) | 349 | 201cc80a | claude, approved at the merged head |
|
|
31
|
+
| #889 (#785 P1c) | 459 | c2a8b121 | claude, after two change requests (admission recheck before the board write; seed rot on an anchored line), approved at the merged head |
|
|
32
|
+
| #890 (#625 2e) | 350 | fdc3f469 | claude, approved at the merged head |
|
|
33
|
+
| #891 (#625 2d) | 351 | 7a599197 | claude, approved at the merged head |
|
|
34
|
+
| #892 (#754 contract) | 287 | 9205ce15 | claude approved at the board-bound f181f51e; the PR head 6970d9a3 only clarified reviewer independence, reviewed, and the merge is pinned to it; the person decided O1 to O4 on #754 |
|
|
35
|
+
| #893 (#787 P3b) | 463 | 8403b84d | claude, after one change request (the CLI test reached the real origin), approved at the merged head |
|
|
36
|
+
|
|
37
|
+
## Native acceptance receipts
|
|
38
|
+
|
|
39
|
+
Receipts added under `docs/verification/` since v0.19.0: none.
|
|
40
|
+
|
|
41
|
+
This list is read from the repository, never written by hand. Native acceptance and the person's release consent are separate records: consent is `ahub sdlc authorize-release 0.20.0`, person only, and is not recorded in this file. The tag run refuses without it.
|
|
42
|
+
|
|
43
|
+
## Document review
|
|
44
|
+
|
|
45
|
+
Thirteen documents were stale against the 0.19.0 stamps (bc8a7f25). Each was read against the commits under its `docs/verified.json` paths and restamped at a9e2970d with `bun scripts/release-prepare.ts restamp ... --reviewed`.
|
|
46
|
+
|
|
47
|
+
| Document | Result | What changed |
|
|
48
|
+
| --- | --- | --- |
|
|
49
|
+
| `README.md` | corrected | release report link to 0.20.0; Status line written by release-prepare |
|
|
50
|
+
| `docs/quickstart.md` | corrected | install pin `v0.20.0`; new "Upgrading to 0.20.0" section (amem 0.11.0 for every shared home, CI watcher, `tasks.auto_pull`, object-store sandbox, quiet branch binding) |
|
|
51
|
+
| `docs/operations.md` | corrected | version and protocol-16 range to 0.20.0; `tasks.auto_pull` machine-local wording and settings row; CI watcher scope (not private, no restore after restart, 12-character head, console line for a person, no re-offer after a failure) |
|
|
52
|
+
| `docs/security.md` | corrected | `tasks.auto_pull` in the machine-local list and the ordinary dashboard-session rules |
|
|
53
|
+
| `docs/agent-notes/adapters.md` | corrected | new rule for the delivery, lifecycle and session ports (#625 2b-2c) |
|
|
54
|
+
| `docs/agent-notes/daemon.md` | corrected | dispatch through ports, predicates only at ratchet rows; claude-launch-proof.ts counted by the ratchet; `peer_stop` admission by `hubOwned` and `form` |
|
|
55
|
+
| `docs/agent-notes/bus.md` | corrected | delivery-only retry refusal keyed on `PeerAdapter.deliveryOnly` |
|
|
56
|
+
| `docs/agent-notes/tasks.md` | corrected | quiet branch-binding rule (#880); `ci-watch.ts` in scope; a background clause removed |
|
|
57
|
+
| `docs/agent-notes/tests.md` | corrected | release-script tests run against a scratch root (#893); the release scripts in scope |
|
|
58
|
+
| `docs/agent-notes/README.md`, `benchmarks.md`, `budget.md`, `local-worker.md` | accurate | checked against #881, #882, #886, #887, #889, #890 and #891 |
|
|
59
|
+
|
|
60
|
+
Also corrected, outside the stamped set: the `@staix/agent-mem` pin text in `docs/memory.md`; `AGENTS.md` area lists (`src/hub/ci-watch.ts` and the release scripts); `docs/verified.json` source paths for adapters, tasks and tests; the CHANGELOG `tasks.auto_pull` wording. `ahub help` matches `src/cli/main.ts` and `src/cli/launch.ts`: `src/cli/` and the adapter definitions are unchanged since v0.19.0, and no changed file adds a command or flag. Note for the next CooperBench collection: #881 changed `profile()` in `src/local/sandbox.ts`, a study condition (`docs/agent-notes/benchmarks.md`).
|
|
61
|
+
|
|
62
|
+
## Checks
|
|
63
|
+
|
|
64
|
+
- `node scripts/check-docs.mjs --require-fresh`: `docs: OK (0 stale documents)`.
|
|
65
|
+
- `bun x tsc --noEmit`: clean. `bun scripts/build.mjs --check`: fresh. `node scripts/check-package.mjs`: `package: OK (575 files, @staix/agent-hub@0.20.0)`.
|
|
66
|
+
- Full `scripts/check.sh` on Linux (remote_dev verify, mawk container): see the release PR.
|
|
67
|
+
- The release PR's exact-head CI run: see the release PR.
|
|
68
|
+
|
|
69
|
+
## Known gaps, not blocking
|
|
70
|
+
|
|
71
|
+
- No running hub was upgraded to 0.20.0 and no live TUI was launched; the installed hub on this machine is still 0.18.0.
|
|
72
|
+
- No new native acceptance receipt was recorded for this release.
|
|
73
|
+
- The global `amem` CLI on this machine is not yet 0.11.0; upgrade it with the hub (every amem sharing a memory home must be 0.11.0 or later).
|
|
74
|
+
- `test/memory-code-head-684.test.ts` (#367 cached HEAD proof) still flakes under local load (#519, assigned); CI is the gate.
|
|
75
|
+
- External module-kind instances (#754 implementation, PR #894) are not in this release.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Model-free native receipt of the headless Claude permission transport (#843 AC5, task #507).
|
|
2
|
+
# Repeats the case table of 2026-10-23-headless-claude-permissions.md on each installed binary given.
|
|
3
|
+
# Usage: python3 claude-permission-transport-native.py OUTPUT --claude /absolute/claude [--claude ...]
|
|
4
|
+
# No account, no provider, no model: a loopback server answers with a fixed Write tool call, then a completion.
|
|
5
|
+
import argparse, hashlib, http.server, json, os, pathlib, signal, subprocess, threading, time
|
|
6
|
+
|
|
7
|
+
parser = argparse.ArgumentParser()
|
|
8
|
+
parser.add_argument('output')
|
|
9
|
+
parser.add_argument('--claude', action='append', required=True)
|
|
10
|
+
args = parser.parse_args()
|
|
11
|
+
ROOT = pathlib.Path(args.output).resolve()
|
|
12
|
+
ROOT.mkdir(mode=0o700, parents=False, exist_ok=False)
|
|
13
|
+
|
|
14
|
+
# Fixture stdio MCP server. It records the call first, then behaves as the case says.
|
|
15
|
+
MCP = r'''import sys, json, os, time
|
|
16
|
+
case = os.environ['CASE']
|
|
17
|
+
for line in sys.stdin:
|
|
18
|
+
try: q = json.loads(line)
|
|
19
|
+
except Exception: continue
|
|
20
|
+
method, rid = q.get('method'), q.get('id')
|
|
21
|
+
if rid is None: continue
|
|
22
|
+
if method == 'initialize':
|
|
23
|
+
r = {'protocolVersion': '2024-11-05', 'capabilities': {'tools': {}}, 'serverInfo': {'name': 'fixture', 'version': '1'}}
|
|
24
|
+
elif method == 'tools/list':
|
|
25
|
+
tools = [] if case == 'missing-tool' else [{'name': 'approve', 'description': 'Person approval fixture', 'inputSchema': {'type': 'object', 'properties': {'tool_name': {'type': 'string'}, 'input': {'type': 'object'}, 'tool_use_id': {'type': 'string'}}, 'required': ['tool_name', 'input']}}]
|
|
26
|
+
r = {'tools': tools}
|
|
27
|
+
elif method == 'tools/call':
|
|
28
|
+
args = q.get('params', {}).get('arguments', {})
|
|
29
|
+
with open(os.environ['RECEIPT'], 'a') as f: print(json.dumps({'arguments': args}), file=f)
|
|
30
|
+
if case == 'pending': time.sleep(120)
|
|
31
|
+
if case == 'disconnect': sys.exit(1)
|
|
32
|
+
if case == 'mcp-error':
|
|
33
|
+
print(json.dumps({'jsonrpc': '2.0', 'id': rid, 'error': {'code': -32000, 'message': 'fixture hub unavailable'}}), flush=True); continue
|
|
34
|
+
if case == 'deadline': time.sleep(.1)
|
|
35
|
+
if case in ('deny', 'hook-deny', 'deadline'):
|
|
36
|
+
value = {'behavior': 'deny', 'message': 'no person answered' if case == 'deadline' else 'fixture denied'}
|
|
37
|
+
else:
|
|
38
|
+
value = {'behavior': 'allow', 'updatedInput': args.get('input', {})}
|
|
39
|
+
r = {'content': [{'type': 'text', 'text': json.dumps(value)}]}
|
|
40
|
+
else:
|
|
41
|
+
r = {}
|
|
42
|
+
print(json.dumps({'jsonrpc': '2.0', 'id': rid, 'result': r}), flush=True)
|
|
43
|
+
'''
|
|
44
|
+
(ROOT / 'mcp.py').write_text(MCP)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
48
|
+
def log_message(self, *a): pass
|
|
49
|
+
|
|
50
|
+
def do_POST(self):
|
|
51
|
+
q = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
|
|
52
|
+
state = self.server.state
|
|
53
|
+
state['requests'] += 1
|
|
54
|
+
if 'count_tokens' in self.path:
|
|
55
|
+
self.send_response(200); self.end_headers(); self.wfile.write(json.dumps({'input_tokens': 100}).encode()); return
|
|
56
|
+
if state.get('tools_file') and not os.path.exists(state['tools_file']):
|
|
57
|
+
with open(state['tools_file'], 'w') as f: json.dump(q.get('tools', []), f)
|
|
58
|
+
msgs = q.get('messages', [])
|
|
59
|
+
results = sum(1 for m in msgs if isinstance(m.get('content'), list) for x in m['content'] if isinstance(x, dict) and x.get('type') == 'tool_result')
|
|
60
|
+
done = results >= 1
|
|
61
|
+
content = [{'type': 'text', 'text': 'Fixture finished.'}] if done else [{'type': 'tool_use', 'id': 'toolu_probe_0', 'name': 'Write', 'input': {'file_path': state['target'], 'content': 'probe'}}]
|
|
62
|
+
result = {'id': 'msg_probe_%d' % results, 'type': 'message', 'role': 'assistant', 'model': 'claude-sonnet-4-5', 'content': content, 'stop_reason': 'end_turn' if done else 'tool_use', 'stop_sequence': None, 'usage': {'input_tokens': 100, 'output_tokens': 10}}
|
|
63
|
+
if not q.get('stream'):
|
|
64
|
+
self.send_response(200); self.send_header('Content-Type', 'application/json'); self.end_headers(); self.wfile.write(json.dumps(result).encode()); return
|
|
65
|
+
self.send_response(200); self.send_header('Content-Type', 'text/event-stream'); self.end_headers()
|
|
66
|
+
start = dict(result); start.update(content=[], stop_reason=None, usage={'input_tokens': 100, 'output_tokens': 0})
|
|
67
|
+
events = [('message_start', {'type': 'message_start', 'message': start})]
|
|
68
|
+
for i, c in enumerate(content):
|
|
69
|
+
block = dict(c)
|
|
70
|
+
if c['type'] == 'tool_use': block['input'] = {}
|
|
71
|
+
else: block['text'] = ''
|
|
72
|
+
delta = {'type': 'input_json_delta', 'partial_json': json.dumps(c['input'])} if c['type'] == 'tool_use' else {'type': 'text_delta', 'text': c['text']}
|
|
73
|
+
events.extend([('content_block_start', {'type': 'content_block_start', 'index': i, 'content_block': block}), ('content_block_delta', {'type': 'content_block_delta', 'index': i, 'delta': delta}), ('content_block_stop', {'type': 'content_block_stop', 'index': i})])
|
|
74
|
+
events.extend([('message_delta', {'type': 'message_delta', 'delta': {'stop_reason': result['stop_reason'], 'stop_sequence': None}, 'usage': {'output_tokens': 10}}), ('message_stop', {'type': 'message_stop'})])
|
|
75
|
+
try:
|
|
76
|
+
for e, v in events:
|
|
77
|
+
self.wfile.write(('event: ' + e + '\ndata: ' + json.dumps(v) + '\n\n').encode()); self.wfile.flush()
|
|
78
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def sha(file):
|
|
83
|
+
digest = hashlib.sha256()
|
|
84
|
+
with file.open('rb') as stream:
|
|
85
|
+
for block in iter(lambda: stream.read(1024 * 1024), b''):
|
|
86
|
+
digest.update(block)
|
|
87
|
+
return digest.hexdigest()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def empty_group(pid):
|
|
91
|
+
try:
|
|
92
|
+
table = subprocess.run(['/bin/ps', '-axo', 'pgid=,stat='], env={'PATH': '/usr/bin:/bin', 'LC_ALL': 'C', 'TZ': 'UTC'}, capture_output=True, text=True, timeout=3)
|
|
93
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
94
|
+
return False
|
|
95
|
+
if table.returncode or len(table.stdout) > 1024 * 1024: return False
|
|
96
|
+
rows = [line.split() for line in table.stdout.splitlines() if line.strip()]
|
|
97
|
+
return bool(rows) and all(len(r) == 2 for r in rows) and not any(r[0] == str(pid) and not r[1].startswith('Z') for r in rows)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def rows(file):
|
|
101
|
+
return [json.loads(line) for line in file.read_text().splitlines() if line] if file.exists() else []
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
HOOK_FAIL = {'hooks': {'PreToolUse': [{'matcher': 'Write', 'hooks': [{'type': 'command', 'command': 'exit 1'}]}]}}
|
|
105
|
+
# case -> (settings, expected native exit, mcp calls, file written, tool_result is_error, external stop expected)
|
|
106
|
+
CASES = {
|
|
107
|
+
'allow': ({}, 0, 1, True, False, False),
|
|
108
|
+
'deny': ({}, 0, 1, False, True, False),
|
|
109
|
+
'missing-tool': ({}, 1, 0, False, None, False),
|
|
110
|
+
'hook-deny': (HOOK_FAIL, 0, 1, False, True, False),
|
|
111
|
+
'hook-allow': (HOOK_FAIL, 0, 1, True, False, False),
|
|
112
|
+
'mcp-error': ({}, 0, 1, False, True, False),
|
|
113
|
+
'disconnect': ({}, 0, 1, False, True, False),
|
|
114
|
+
'pending': ({}, None, 1, False, None, True),
|
|
115
|
+
'deadline': ({}, 0, 1, False, True, False),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
results = []
|
|
119
|
+
for index, raw in enumerate(args.claude):
|
|
120
|
+
binary = pathlib.Path(raw).resolve()
|
|
121
|
+
digest = sha(binary)
|
|
122
|
+
# The index keeps two binaries with the same basename or the same version in separate directories.
|
|
123
|
+
ident = ROOT / ('identity-%d-%s' % (index, binary.name)); ident.mkdir()
|
|
124
|
+
v = subprocess.run([str(binary), '--version'], env={'HOME': str(ident), 'CLAUDE_CONFIG_DIR': str(ident / 'config'), 'PATH': '/usr/bin:/bin'}, capture_output=True, text=True, timeout=10)
|
|
125
|
+
assert v.returncode == 0, v
|
|
126
|
+
version = v.stdout.strip().split()[0]
|
|
127
|
+
for case, (settings, want_exit, want_calls, want_file, want_error, want_stop) in CASES.items():
|
|
128
|
+
d = ROOT / ('%d-%s-%s' % (index, version, case)); d.mkdir()
|
|
129
|
+
home = d / 'home'; home.mkdir(); work = d / 'work'; work.mkdir(); work = work.resolve()
|
|
130
|
+
target = work / 'probe.txt'
|
|
131
|
+
out = {'version': version, 'binarySha256': digest, 'case': case}
|
|
132
|
+
srv = native = None
|
|
133
|
+
try:
|
|
134
|
+
srv = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
|
135
|
+
srv.state = {'requests': 0, 'target': str(target), 'tools_file': str(d / 'tools.json')}
|
|
136
|
+
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
137
|
+
(d / 'mcp.json').write_text(json.dumps({'mcpServers': {'fixture': {'command': '/usr/bin/python3', 'args': [str(ROOT / 'mcp.py')], 'env': {'CASE': case, 'RECEIPT': str(d / 'mcp.jsonl')}}}}))
|
|
138
|
+
(d / 'settings.json').write_text(json.dumps(settings))
|
|
139
|
+
env = {'HOME': str(home), 'CLAUDE_CONFIG_DIR': str(home / 'config'), 'PATH': '/usr/bin:/bin', 'ANTHROPIC_BASE_URL': 'http://127.0.0.1:%d' % srv.server_port, 'ANTHROPIC_API_KEY': 'synthetic-probe-not-a-credential', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC': '1', 'DISABLE_AUTOUPDATER': '1'}
|
|
140
|
+
argv = [str(binary), '-p', '--output-format', 'stream-json', '--verbose', '--strict-mcp-config', '--mcp-config', str(d / 'mcp.json'), '--setting-sources', '', '--settings', str(d / 'settings.json'), '--permission-mode', 'default', '--permission-prompts', 'host', '--permission-prompt-tool', 'mcp__fixture__approve', '--model', 'claude-sonnet-4-5', 'Run the fixture write.']
|
|
141
|
+
started = time.monotonic()
|
|
142
|
+
native = subprocess.Popen(argv, cwd=work, env=env, stdout=open(d / 'stdout', 'w'), stderr=open(d / 'stderr', 'w'), start_new_session=True)
|
|
143
|
+
stopped_externally = False
|
|
144
|
+
try:
|
|
145
|
+
native.wait(timeout=25)
|
|
146
|
+
except subprocess.TimeoutExpired:
|
|
147
|
+
stopped_externally = True
|
|
148
|
+
os.killpg(native.pid, signal.SIGKILL); native.wait(timeout=5)
|
|
149
|
+
elapsed = round(time.monotonic() - started, 3)
|
|
150
|
+
calls = rows(d / 'mcp.jsonl')
|
|
151
|
+
frames = rows(d / 'stdout')
|
|
152
|
+
tool_results = [b for f in frames if f.get('type') == 'user' for b in f.get('message', {}).get('content', []) if isinstance(b, dict) and b.get('type') == 'tool_result']
|
|
153
|
+
final = [f for f in frames if f.get('type') == 'result']
|
|
154
|
+
written = target.exists() and target.read_text() == 'probe'
|
|
155
|
+
out.update(nativeExit=None if stopped_externally else native.returncode, stoppedExternally=stopped_externally, mcpCalls=len(calls), fileWritten=written, toolResultErrors=[b.get('is_error') for b in tool_results], resultFrame=[{'is_error': f.get('is_error'), 'denials': len(f.get('permission_denials', []))} for f in final], elapsedSeconds=elapsed, requests=srv.state['requests'])
|
|
156
|
+
assert stopped_externally == want_stop, ('external stop', stopped_externally)
|
|
157
|
+
if want_exit is not None: assert native.returncode == want_exit, ('exit', native.returncode, (d / 'stderr').read_text()[-400:])
|
|
158
|
+
assert len(calls) == want_calls, ('mcp calls', calls)
|
|
159
|
+
if want_calls:
|
|
160
|
+
call = calls[0]['arguments']
|
|
161
|
+
assert call['tool_name'] == 'Write' and call['input'] == {'file_path': str(target), 'content': 'probe'} and call['tool_use_id'] == 'toolu_probe_0', call
|
|
162
|
+
assert written == want_file, ('file written', written)
|
|
163
|
+
# A successful native tool_result carries no is_error key at all; only a failed one sets it to true.
|
|
164
|
+
if want_error is not None: assert [b.get('is_error') is True for b in tool_results] == [want_error], tool_results
|
|
165
|
+
if case in ('deny', 'hook-deny', 'deadline'): assert final and final[0].get('permission_denials') and len(final[0]['permission_denials']) == 1, final
|
|
166
|
+
out['passed'] = True
|
|
167
|
+
except Exception as error:
|
|
168
|
+
out.update(passed=False, error=repr(error))
|
|
169
|
+
finally:
|
|
170
|
+
if native is not None:
|
|
171
|
+
if native.poll() is None:
|
|
172
|
+
try: os.killpg(native.pid, signal.SIGKILL)
|
|
173
|
+
except ProcessLookupError: pass
|
|
174
|
+
native.wait(timeout=5)
|
|
175
|
+
end = time.monotonic() + 1
|
|
176
|
+
while not empty_group(native.pid) and time.monotonic() < end: time.sleep(.02)
|
|
177
|
+
out['nativeGroupEmpty'] = empty_group(native.pid)
|
|
178
|
+
if not out['nativeGroupEmpty']: out.update(passed=False, cleanup='native group not established empty')
|
|
179
|
+
if srv: srv.shutdown()
|
|
180
|
+
out['binaryUnchanged'] = sha(binary) == digest
|
|
181
|
+
if not out['binaryUnchanged']: out.update(passed=False, error='native binary changed during probe')
|
|
182
|
+
results.append(out)
|
|
183
|
+
print(json.dumps(out), flush=True)
|
|
184
|
+
receipt = {'cases': results, 'certified': all(r['passed'] for r in results), 'account': False, 'inference': False}
|
|
185
|
+
(ROOT / 'receipt.json').write_text(json.dumps(receipt, indent=2))
|
|
186
|
+
print(json.dumps({'receipt': str(ROOT / 'receipt.json'), 'certified': receipt['certified']}), flush=True)
|
|
187
|
+
raise SystemExit(0 if receipt['certified'] else 1)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Model-free native receipt fixture. Run by claude-shell-wrapper-native.py, never by production startup.
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { createHostShellWrapper } from "../../src/hub/claude-shell-wrapper.ts";
|
|
4
|
+
import { hostShellEnvironment } from "../../src/local/shell-environment.ts";
|
|
5
|
+
import { realPath } from "../../src/hub/project.ts";
|
|
6
|
+
|
|
7
|
+
const fixture = JSON.parse(readFileSync(process.argv[2]!, "utf8")) as { root: string; executable: string };
|
|
8
|
+
const root = realPath(fixture.root), executable = realPath(fixture.executable), signal = new AbortController();
|
|
9
|
+
const wrapper = await createHostShellWrapper();
|
|
10
|
+
const command = wrapper.issue({ peer: "claude-fixture", task: 1, generation: "native-fixture:1", root, branch: "fixture/task", executable, argv: [],
|
|
11
|
+
env: hostShellEnvironment({ AGENTHUB_PEER_ID: "claude-fixture" }), signal: signal.signal, timeoutMs: 5000,
|
|
12
|
+
current: () => !signal.signal.aborted && realPath(fixture.root) === root && realPath(fixture.executable) === executable });
|
|
13
|
+
if (!command) throw new Error("host receipt fixture refused");
|
|
14
|
+
process.stdout.write(`${JSON.stringify(command)}\n`);
|
|
15
|
+
let closing: Promise<void> | undefined;
|
|
16
|
+
const close = () => closing ??= (async () => { signal.abort(); await wrapper.close(); })();
|
|
17
|
+
process.on("SIGTERM", () => { void close().then(() => process.exit(0), () => process.exit(1)); });
|
|
18
|
+
process.on("SIGINT", () => { void close().then(() => process.exit(0), () => process.exit(1)); });
|
|
19
|
+
for await (const _chunk of process.stdin) { /* Parent ownership pipe, not command input. */ }
|
|
20
|
+
await close();
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Model-free native receipt. Usage: python3 this.py OUTPUT --bun /absolute/bun --claude /absolute/claude [--claude ...]
|
|
2
|
+
import argparse, hashlib, http.server, json, os, pathlib, select, shlex, signal, subprocess, sys, threading
|
|
3
|
+
parser=argparse.ArgumentParser(); parser.add_argument('output'); parser.add_argument('--bun',required=True); parser.add_argument('--claude',action='append',required=True)
|
|
4
|
+
args=parser.parse_args(); ROOT=pathlib.Path(args.output).resolve(); ROOT.mkdir(mode=0o700,parents=False,exist_ok=False)
|
|
5
|
+
HOST=pathlib.Path(__file__).with_name('claude-shell-wrapper-host.ts').resolve()
|
|
6
|
+
MCP="import sys,json,os,time\nfor line in sys.stdin:\n try: q=json.loads(line)\n except: continue\n method=q.get('method'); rid=q.get('id')\n if rid is None: continue\n if method=='initialize': r={'protocolVersion':'2024-11-05','capabilities':{'tools':{}},'serverInfo':{'name':'fixture','version':'1'}}\n elif method=='tools/list': r={'tools':[{'name':'approve','description':'Person approval fixture','inputSchema':{'type':'object','properties':{'tool_name':{'type':'string'},'input':{'type':'object'},'tool_use_id':{'type':'string'}},'required':['tool_name','input','tool_use_id'],'additionalProperties':False}}]}\n elif method=='tools/call':\n mode=os.environ['CASE']; args=q.get('params',{}).get('arguments',{})\n if mode=='timeout': time.sleep(60)\n if mode=='approval-timeout': time.sleep(.1)\n if mode=='disconnect': sys.exit(1)\n if mode=='tool-error':\n print(json.dumps({'jsonrpc':'2.0','id':rid,'error':{'code':-32000,'message':'fixture hub unavailable'}}),flush=True); continue\n if mode in ('deny','hook-failure','approval-timeout'): value={'behavior':'deny','message':'no person answered' if mode=='approval-timeout' else 'fixture denied'}\n else:\n updated=args.get('input',{})\n if mode=='normalized' and updated.get('description') in ('probe1','probe2'): updated={'command':os.environ['NORMALIZED'],'description':'host-bound fixture'}\n value={'behavior':'allow','updatedInput':updated}\n with open(os.environ['RECEIPT'],'a') as f: print(json.dumps({'arguments':args,'response':value}),file=f)\n r={'content':[{'type':'text','text':json.dumps(value)}]}\n else: r={}\n print(json.dumps({'jsonrpc':'2.0','id':rid,'result':r}),flush=True)\n"
|
|
7
|
+
(ROOT/'mcp.py').write_text(MCP)
|
|
8
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
9
|
+
def log_message(self,*a): pass
|
|
10
|
+
def do_POST(self):
|
|
11
|
+
q=json.loads(self.rfile.read(int(self.headers['Content-Length'])))
|
|
12
|
+
state=self.server.state
|
|
13
|
+
state['requests']+=1
|
|
14
|
+
if 'count_tokens' in self.path:
|
|
15
|
+
b=json.dumps({'input_tokens':100}).encode(); self.send_response(200);self.end_headers();self.wfile.write(b);return
|
|
16
|
+
msgs=q.get('messages',[])
|
|
17
|
+
with open(state['record'],'a') as f:f.write(json.dumps({'path':self.path,'keys':list(q),'tools':[x.get('name') for x in q.get('tools',[])],'messages':msgs})+'\n')
|
|
18
|
+
count=sum(1 for m in msgs if isinstance(m.get('content'),list) for x in m['content'] if isinstance(x,dict) and x.get('type')=='tool_result'); seen=count>=len(state['commands'])
|
|
19
|
+
content=[{'type':'text','text':'Fixture finished.'}] if seen else [{'type':'tool_use','id':'toolu_probe_'+str(count),'name':'Bash','input':{'command':state['commands'][count],'description':'probe'+str(count)}}]
|
|
20
|
+
result={'id':'msg_probe_'+str(count),'type':'message','role':'assistant','model':'claude-sonnet-4-5','content':content,'stop_reason':'end_turn' if seen else 'tool_use','stop_sequence':None,'usage':{'input_tokens':100,'output_tokens':10}}
|
|
21
|
+
if not q.get('stream'):
|
|
22
|
+
self.send_response(200);self.send_header('Content-Type','application/json');self.end_headers();self.wfile.write(json.dumps(result).encode());return
|
|
23
|
+
self.send_response(200);self.send_header('Content-Type','text/event-stream');self.end_headers()
|
|
24
|
+
start=dict(result);start.update(content=[],stop_reason=None,usage={'input_tokens':100,'output_tokens':0})
|
|
25
|
+
events=[('message_start',{'type':'message_start','message':start})]
|
|
26
|
+
for i,c in enumerate(content):
|
|
27
|
+
block=dict(c)
|
|
28
|
+
if c['type']=='tool_use': block['input']={}
|
|
29
|
+
else: block['text']=''
|
|
30
|
+
events.append(('content_block_start',{'type':'content_block_start','index':i,'content_block':block}))
|
|
31
|
+
delta={'type':'input_json_delta','partial_json':json.dumps(c['input'])} if c['type']=='tool_use' else {'type':'text_delta','text':c['text']}
|
|
32
|
+
events.extend([('content_block_delta',{'type':'content_block_delta','index':i,'delta':delta}),('content_block_stop',{'type':'content_block_stop','index':i})])
|
|
33
|
+
events.extend([('message_delta',{'type':'message_delta','delta':{'stop_reason':result['stop_reason'],'stop_sequence':None},'usage':{'output_tokens':10}}),('message_stop',{'type':'message_stop'})])
|
|
34
|
+
try:
|
|
35
|
+
for e,v in events:self.wfile.write(('event: '+e+'\ndata: '+json.dumps(v)+'\n\n').encode());self.wfile.flush()
|
|
36
|
+
except BrokenPipeError:pass
|
|
37
|
+
|
|
38
|
+
def stop(process):
|
|
39
|
+
if process is None: return True
|
|
40
|
+
try:
|
|
41
|
+
if process.stdin: process.stdin.close()
|
|
42
|
+
try: process.wait(timeout=25)
|
|
43
|
+
except subprocess.TimeoutExpired:
|
|
44
|
+
if process.poll() is None:
|
|
45
|
+
try: os.killpg(process.pid,signal.SIGKILL)
|
|
46
|
+
except ProcessLookupError: pass
|
|
47
|
+
process.wait(timeout=5)
|
|
48
|
+
return process.returncode==0
|
|
49
|
+
except (OSError,subprocess.TimeoutExpired):return False
|
|
50
|
+
|
|
51
|
+
def empty_group(pid):
|
|
52
|
+
try:table=subprocess.run(['/bin/ps','-axo','pgid=,stat='],env={'PATH':'/usr/bin:/bin','LC_ALL':'C','TZ':'UTC'},capture_output=True,text=True,timeout=3)
|
|
53
|
+
except (OSError,subprocess.TimeoutExpired):return False
|
|
54
|
+
if table.returncode or len(table.stdout)>1024*1024:return False
|
|
55
|
+
parsed=[line.split() for line in table.stdout.splitlines() if line.strip()]
|
|
56
|
+
return bool(parsed) and all(len(row)==2 for row in parsed) and not any(row[0]==str(pid) and not row[1].startswith('Z') for row in parsed)
|
|
57
|
+
|
|
58
|
+
def rows(file):
|
|
59
|
+
return [json.loads(line) for line in file.read_text().splitlines() if line]
|
|
60
|
+
|
|
61
|
+
def sha(file):
|
|
62
|
+
digest=hashlib.sha256()
|
|
63
|
+
with file.open('rb') as stream:
|
|
64
|
+
for block in iter(lambda:stream.read(1024*1024),b''):digest.update(block)
|
|
65
|
+
return digest.hexdigest()
|
|
66
|
+
|
|
67
|
+
results=[]
|
|
68
|
+
for raw_binary in args.claude:
|
|
69
|
+
binary=pathlib.Path(raw_binary).resolve(); identity=ROOT/('identity-'+binary.name); identity.mkdir()
|
|
70
|
+
version=subprocess.run([str(binary),'--version'],env={'HOME':str(identity),'CLAUDE_CONFIG_DIR':str(identity/'config'),'PATH':'/usr/bin:/bin'},capture_output=True,text=True,timeout=10)
|
|
71
|
+
assert version.returncode==0 and version.stdout.strip().split()[0] in ('2.1.309','2.1.310'),(version.returncode,version.stdout)
|
|
72
|
+
label=version.stdout.strip().split()[0]; digest=sha(binary)
|
|
73
|
+
for mode in ('context','normalized'):
|
|
74
|
+
d=ROOT/(label+'-'+mode); d.mkdir(); home=d/'home';home.mkdir(); work=d/"work 'quoted'";work.mkdir();(work/'sub').mkdir()
|
|
75
|
+
work=work.resolve(); host_bin=d/"host bin's";host_bin.mkdir(); executable=host_bin/'fixture';counter=d/'effects'
|
|
76
|
+
program='#!/usr/bin/python3\nimport json,os,pathlib,sys\np=pathlib.Path('+repr(str(counter))+');p.write_text(str(int(p.read_text())+1) if p.exists() else "1")\nprint(json.dumps({"marker":"HOST_EXECUTABLE","cwd":os.getcwd(),"executable":os.path.realpath(sys.argv[0]),"nativeVars":{k:os.environ.get(k) for k in ["GIT_DIR","RIPGREP_CONFIG_PATH"]},"peer":os.environ.get("AGENTHUB_PEER_ID"),"term":os.environ.get("TERM"),"home":os.environ.get("HOME"),"path":os.environ.get("PATH"),"tmpdir":os.environ.get("TMPDIR")}))\n'
|
|
77
|
+
executable.write_text(program);executable.chmod(0o700)
|
|
78
|
+
helper=None;srv=None;native=None
|
|
79
|
+
result={'version':version.stdout.strip(),'binarySha256':digest,'case':mode}
|
|
80
|
+
try:
|
|
81
|
+
rewritten=''
|
|
82
|
+
if mode=='normalized':
|
|
83
|
+
fixture=d/'host.json';fixture.write_text(json.dumps({'root':str(work),'executable':str(executable)}))
|
|
84
|
+
helper=subprocess.Popen([str(pathlib.Path(args.bun).resolve()),str(HOST),str(fixture)],env={'HOME':str(home),'PATH':'/opt/homebrew/bin:/usr/bin:/bin','LANG':'C','TERM':'dumb'},stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=open(d/'host.stderr','w'),text=True,start_new_session=True)
|
|
85
|
+
assert select.select([helper.stdout],[],[],10)[0],'host fixture handshake timeout'
|
|
86
|
+
reply=json.loads(helper.stdout.readline());rewritten=reply['command'];assert isinstance(rewritten,str) and rewritten
|
|
87
|
+
commands=['cd sub && pwd','pwd']+(['pwd'] if mode=='normalized' else [])
|
|
88
|
+
srv=http.server.ThreadingHTTPServer(('127.0.0.1',0),Handler);srv.state={'requests':0,'record':str(d/'api.jsonl'),'commands':commands}
|
|
89
|
+
threading.Thread(target=srv.serve_forever,daemon=True).start()
|
|
90
|
+
config={'mcpServers':{'fixture':{'command':'/usr/bin/python3','args':[str(ROOT/'mcp.py')],'env':{'CASE':mode,'RECEIPT':str(d/'mcp.jsonl'),'NORMALIZED':rewritten}}}}
|
|
91
|
+
(d/'mcp.json').write_text(json.dumps(config));(d/'settings.json').write_text(json.dumps({'permissions':{'ask':['Bash']}}))
|
|
92
|
+
native_env={'HOME':str(home),'CLAUDE_CONFIG_DIR':str(home/'config'),'PATH':'/usr/bin:/bin','ANTHROPIC_BASE_URL':'http://127.0.0.1:'+str(srv.server_port),'ANTHROPIC_API_KEY':'synthetic-probe-not-a-credential','CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC':'1','DISABLE_AUTOUPDATER':'1','GIT_DIR':str(d/'poison-git'),'RIPGREP_CONFIG_PATH':str(d/'poison-rg')}
|
|
93
|
+
argv=[str(binary),'-p','--output-format','stream-json','--verbose','--strict-mcp-config','--mcp-config',str(d/'mcp.json'),'--setting-sources','','--settings',str(d/'settings.json'),'--permission-mode','default','--permission-prompts','host','--permission-prompt-tool','mcp__fixture__approve','--model','claude-sonnet-4-5','Run the fixture commands.']
|
|
94
|
+
native=subprocess.Popen(argv,cwd=work,env=native_env,stdout=open(d/'stdout','w'),stderr=open(d/'stderr','w'),start_new_session=True)
|
|
95
|
+
native.wait(timeout=25);assert native.returncode==0,(native.returncode,(d/'stderr').read_text())
|
|
96
|
+
assert (d/'stderr').read_text()==''
|
|
97
|
+
calls=rows(d/'mcp.jsonl');assert len(calls)==len(commands),calls
|
|
98
|
+
frames={}
|
|
99
|
+
for frame in rows(d/'stdout'):
|
|
100
|
+
if frame.get('type')!='user':continue
|
|
101
|
+
for block in frame.get('message',{}).get('content',[]):
|
|
102
|
+
if block.get('type')=='tool_result':
|
|
103
|
+
ident=block['tool_use_id'];assert ident not in frames;frames[ident]=(frame.get('tool_use_result'),block)
|
|
104
|
+
assert set(frames)=={'toolu_probe_'+str(i) for i in range(len(commands))},frames
|
|
105
|
+
for i,call in enumerate(calls):
|
|
106
|
+
assert call['arguments']=={'tool_name':'Bash','input':{'command':commands[i],'description':'probe'+str(i)},'tool_use_id':'toolu_probe_'+str(i)},call
|
|
107
|
+
expected=call['arguments']['input'] if i==0 or mode=='context' else {'command':rewritten,'description':'host-bound fixture'}
|
|
108
|
+
assert call['response']=={'behavior':'allow','updatedInput':expected},call
|
|
109
|
+
def output(i):
|
|
110
|
+
tool,block=frames['toolu_probe_'+str(i)];assert isinstance(tool,dict) and block.get('is_error') is False and tool.get('stderr')=='' and tool.get('interrupted') is False,(tool,block)
|
|
111
|
+
assert block.get('content')==tool['stdout'];return tool['stdout']
|
|
112
|
+
assert output(0).strip()==str(work/'sub')
|
|
113
|
+
if mode=='context':
|
|
114
|
+
assert output(1).strip()==str(work/'sub');result['persistentCwd']=True
|
|
115
|
+
else:
|
|
116
|
+
observed=json.loads(output(1));assert observed['marker']=='HOST_EXECUTABLE' and observed['cwd']==str(work) and observed['executable']==str(executable.resolve()),observed
|
|
117
|
+
assert observed['nativeVars']=={'GIT_DIR':None,'RIPGREP_CONFIG_PATH':None},observed
|
|
118
|
+
assert observed['peer']=='claude-fixture' and observed['term']=='dumb' and observed['home']==str(home) and observed['path']=='/opt/homebrew/bin:/usr/bin:/bin',observed
|
|
119
|
+
assert observed['tmpdir'] and 'ahub-shell-' in observed['tmpdir'] and str(d) not in observed['tmpdir'],observed
|
|
120
|
+
replay_tool,replay=frames['toolu_probe_2'];assert replay.get('is_error') is True and 'host shell execution refused' in json.dumps(replay_tool),frames['toolu_probe_2']
|
|
121
|
+
assert counter.read_text()=='1',counter.read_text()
|
|
122
|
+
result.update(rewrite=True,priorCd=True,environmentIsolation=True,replayRefused=True,effects=1,quotedPaths=True)
|
|
123
|
+
result.update(passed=True,nativeExit=0,requests=srv.state['requests'],permissionCalls=len(calls))
|
|
124
|
+
except Exception as error:result.update(passed=False,error=repr(error))
|
|
125
|
+
finally:
|
|
126
|
+
if native is not None:
|
|
127
|
+
# Signal only while this unreaped direct child still owns its pid, never a group id after successful wait.
|
|
128
|
+
if native.poll() is None:
|
|
129
|
+
try:os.killpg(native.pid,signal.SIGKILL)
|
|
130
|
+
except ProcessLookupError:pass
|
|
131
|
+
native.wait(timeout=5)
|
|
132
|
+
import time
|
|
133
|
+
deadline=time.monotonic()+1
|
|
134
|
+
while not empty_group(native.pid) and time.monotonic()<deadline:time.sleep(.02)
|
|
135
|
+
result['nativeGroupEmpty']=empty_group(native.pid)
|
|
136
|
+
if not result['nativeGroupEmpty']:result.update(passed=False,cleanup='native group not established empty; no unrelated process signalled')
|
|
137
|
+
result['hostStopped']=stop(helper)
|
|
138
|
+
if not result['hostStopped']:result.update(passed=False,cleanup='host wrapper cleanup failed')
|
|
139
|
+
if srv:srv.shutdown()
|
|
140
|
+
result['binaryUnchanged']=sha(binary)==digest
|
|
141
|
+
if not result['binaryUnchanged']:result.update(passed=False,error='native binary changed during probe')
|
|
142
|
+
results.append(result);print(json.dumps(result),flush=True)
|
|
143
|
+
receipt={'cases':results,'certified':all(row['passed'] for row in results),'account':False,'inference':False}
|
|
144
|
+
(ROOT/'receipt.json').write_text(json.dumps(receipt,indent=2));print(json.dumps({'receipt':str(ROOT/'receipt.json'),'certified':receipt['certified']}),flush=True)
|
|
145
|
+
raise SystemExit(0 if receipt['certified'] else 1)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {mkdtempSync, mkdirSync, readFileSync} from 'node:fs';
|
|
2
|
+
import {join, resolve} from 'node:path';
|
|
3
|
+
import {homedir} from 'node:os';
|
|
4
|
+
import {fileURLToPath} from 'node:url';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
const launcher=resolve(process.argv[2] ?? join(homedir(),'.pi/agent/bin/pi'));
|
|
7
|
+
const packageRoot=resolve(process.argv[3] ?? join(homedir(),'.pi/agent/install/releases/1.4.0/node_modules/@earendil-works/pi-coding-agent'));
|
|
8
|
+
const extension=fileURLToPath(new URL('../../src/pi/extension.ts',import.meta.url));
|
|
9
|
+
const driver=fileURLToPath(new URL('./pi-140-native.mjs',import.meta.url));
|
|
10
|
+
import {PiToolReceipts} from '../../src/pi/tool-receipts.ts';
|
|
11
|
+
const home=mkdtempSync('/tmp/ahub-505-native-home.'); mkdirSync(join(home,'agent'));
|
|
12
|
+
const pkg=JSON.parse(readFileSync(join(packageRoot,'package.json'),'utf8'));
|
|
13
|
+
assert.equal(pkg.name,'@earendil-works/pi-coding-agent');assert.equal(pkg.version,'1.4.0');
|
|
14
|
+
const version=Bun.spawn([launcher,'--version'],{cwd:home,env:{HOME:home,PATH:process.env.PATH!,PI_CODING_AGENT_DIR:join(home,'agent')},stdin:'ignore',stdout:'pipe',stderr:'pipe'});
|
|
15
|
+
const versionTimer=setTimeout(()=>version.kill(),10000);
|
|
16
|
+
const [versionCode,versionOut,versionErr]=await Promise.all([version.exited,new Response(version.stdout).text(),new Response(version.stderr).text()]);clearTimeout(versionTimer);
|
|
17
|
+
assert.equal(versionCode,0);assert.equal(versionOut.trim(),'1.4.0');assert.equal(versionErr,'');
|
|
18
|
+
const ledger=new PiToolReceipts(join(home,'hub.db'));
|
|
19
|
+
let effects=0, providers=0; const events:any[]=[];
|
|
20
|
+
const bridge=Bun.serve({hostname:'127.0.0.1',port:0,async fetch(req){
|
|
21
|
+
const url=new URL(req.url); if(req.headers.get('authorization')!=='Bearer fixture')return new Response('forbidden',{status:403});
|
|
22
|
+
if(url.pathname==='/commands') {await Bun.sleep(25);return Response.json({command:null});}
|
|
23
|
+
const body=await req.json() as any;
|
|
24
|
+
if(url.pathname==='/event'){events.push(body.type);return Response.json({ok:true});}
|
|
25
|
+
if(url.pathname==='/budget')return Response.json({decisions:[]});
|
|
26
|
+
if(url.pathname==='/tool') {
|
|
27
|
+
const text=await ledger.execute(body.sessionId,body.toolCallId,body.name,body.args,async()=>{
|
|
28
|
+
if(body.args.value==='deny')return 'error: fixture permission denied';
|
|
29
|
+
effects++;return 'fixture success';
|
|
30
|
+
});
|
|
31
|
+
return Response.json({text,failed:text.startsWith('error:')});
|
|
32
|
+
}
|
|
33
|
+
providers++;return new Response('provider forbidden',{status:500});
|
|
34
|
+
}});
|
|
35
|
+
const env={HOME:home,PATH:process.env.PATH!,PI_CODING_AGENT_DIR:join(home,'agent'),HUB_EXTENSION:extension,PI_COMPAT_PACKAGE:packageRoot,
|
|
36
|
+
AGENTHUB_PI_BRIDGE_URL:`http://127.0.0.1:${bridge.port}`,AGENTHUB_PI_BRIDGE_TOKEN:'fixture',AGENTHUB_PI_OWNER_TOKEN:'fixture',
|
|
37
|
+
AGENTHUB_PI_TOOLS:JSON.stringify([{name:'compat_probe',description:'model-free fixture',parameters:{type:'object',properties:{value:{type:'string'}},required:['value']}}])};
|
|
38
|
+
const child=Bun.spawn(['node',driver],{cwd:home,env,stdin:'ignore',stdout:'pipe',stderr:'pipe'});
|
|
39
|
+
const timer=setTimeout(()=>child.kill(),10000);
|
|
40
|
+
const [code,stdout,stderr]=await Promise.all([child.exited,new Response(child.stdout).text(),new Response(child.stderr).text()]);clearTimeout(timer);
|
|
41
|
+
console.log(JSON.stringify({code,stdout,stderr,effects,providers,events,isolatedHome:home}));
|
|
42
|
+
const native=Bun.spawn([launcher,'--mode','rpc','--provider','agent-hub-local','--model','fixture','--no-builtin-tools','--no-skills','--no-prompt-templates','--no-extensions','--extension',env.HUB_EXTENSION,'--session-dir',join(home,'native-sessions')],{
|
|
43
|
+
cwd:home,env:{...env,AGENTHUB_PI_RELAY_URL:`http://127.0.0.1:${bridge.port}/v1`,AGENTHUB_PI_RELAY_TOKEN:'fixture',AGENTHUB_PI_MODELS:JSON.stringify([{id:'fixture',name:'fixture'}])},stdin:'pipe',stdout:'pipe',stderr:'pipe'});
|
|
44
|
+
native.stdin.write(JSON.stringify({id:'probe',type:'get_state'})+'\n');
|
|
45
|
+
const nativeTimer=setTimeout(()=>native.kill(),10000);
|
|
46
|
+
let wire='';for await(const chunk of native.stdout){wire+=new TextDecoder().decode(chunk);if(wire.includes('"command":"get_state"')){native.kill();break;}}
|
|
47
|
+
await native.exited;clearTimeout(nativeTimer);const nativeErr=await new Response(native.stderr).text();
|
|
48
|
+
console.log(JSON.stringify({nativeRpc:wire,nativeStderr:nativeErr,providers,events}));
|
|
49
|
+
bridge.stop(true);await ledger.close();
|
|
50
|
+
assert.equal(code,0);assert.equal(stderr,'');
|
|
51
|
+
const result=JSON.parse(stdout);
|
|
52
|
+
assert.equal(result.extension,'loaded');assert.equal(result.first.isError,false);assert.deepEqual(result.first,result.replay);assert.equal(result.denied.isError,true);
|
|
53
|
+
assert.equal(effects,1);assert.equal(providers,0);assert.equal(nativeErr,'');
|
|
54
|
+
const state=wire.trim().split('\n').map(line=>JSON.parse(line)).find(item=>item.command==='get_state');
|
|
55
|
+
assert.equal(state?.success,true);assert.equal(state.data.messageCount,0);assert.ok(state.data.sessionId);assert.ok(state.data.sessionFile);
|
|
56
|
+
assert.ok(events.includes('session_start'));assert.ok(events.includes('session_shutdown'));
|
|
57
|
+
console.log('pi 1.4.0 model-free receipt: PASS');
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url';
|
|
2
|
+
const { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager } = await import(pathToFileURL(`${process.env.PI_COMPAT_PACKAGE}/dist/index.js`).href);
|
|
3
|
+
const cwd = process.env.HOME;
|
|
4
|
+
const settingsManager = SettingsManager.create(cwd, process.env.PI_CODING_AGENT_DIR);
|
|
5
|
+
const loader = new DefaultResourceLoader({cwd, agentDir:process.env.PI_CODING_AGENT_DIR, settingsManager,
|
|
6
|
+
noExtensions:true, additionalExtensionPaths:[process.env.HUB_EXTENSION], noSkills:true, noPromptTemplates:true, noThemes:true});
|
|
7
|
+
await loader.reload();
|
|
8
|
+
const {session, extensionsResult} = await createAgentSession({cwd, agentDir:process.env.PI_CODING_AGENT_DIR,
|
|
9
|
+
resourceLoader:loader, settingsManager, sessionManager:SessionManager.create(cwd, `${cwd}/sessions`), noTools:'builtin'});
|
|
10
|
+
if (extensionsResult.errors.length) throw new Error(JSON.stringify(extensionsResult.errors));
|
|
11
|
+
await session.bindExtensions({mode:'rpc'});
|
|
12
|
+
const tool = session.extensionRunner.getAllRegisteredTools().find(t=>t.definition.name==='compat_probe');
|
|
13
|
+
if (!tool) throw new Error('hub extension tool not registered');
|
|
14
|
+
const invoke = async (id, args) => tool.definition.execute(id, args, new AbortController().signal);
|
|
15
|
+
const first = await invoke('same-call', {value:'fixture'});
|
|
16
|
+
const replay = await invoke('same-call', {value:'fixture'});
|
|
17
|
+
const denied = await invoke('denied-call', {value:'deny'});
|
|
18
|
+
console.log(JSON.stringify({extension:'loaded',first,replay,denied,tools:session.getActiveToolNames()}));
|
|
19
|
+
await session.extensionRunner.emit({type:'session_shutdown'});
|
|
20
|
+
session.dispose();
|
|
21
|
+
process.exit(0);
|