Skip to content

feat: mirror runs to a shared team hub - #6

Open
datj9 wants to merge 34 commits into
mainfrom
feat/hub-phase1
Open

datj9 wants to merge 34 commits into
mainfrom
feat/hub-phase1

Conversation

@datj9

@datj9 datj9 commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Adds lg-hub: a third binary that mirrors a member's runs to a shared SQLite-backed HTTP service, so a team can see each other's agent runs without anyone's transcript or variables leaving their machine.

Implements phase 1 of docs/hub-design.md and docs/hub-implementation-plan.md, both of which land in this branch.

What ships

# hub host
lg-hub init && lg-hub member add alice && lg-hub serve

# member machine
lg enroll http://10.0.0.5:8369 <token>
lg sync --enable          # per-repo opt-in
lg run examples/hello.yaml
lg sync --all

Runs then mirror automatically as they execute, and curl /v1/runs/alice/<runId> returns them.

The security boundary

src/team/project.ts is the file worth reading first. It decides what leaves a machine, and it is built field by field rather than as a filtered copy, so there is no field a secret could ride in on:

  • vars reach the hub as key names only — values are never copied anywhere.
  • Node output never reaches the hub.
  • Node error is path-rewritten, secret-masked via SCAN_RULES, and capped at 200 characters.
  • cwd is rewritten through the existing rewritePaths.

ProjectedState is a distinct hand-written type, never Omit<RunState, …> — so a future content-carrying field added to RunState cannot silently start publishing itself.

Verified against the built binary, not just the suite

A 12-step acceptance run against a live hub, 25 assertions:

  • A run with --var apiKey=sk-… syncs, and the secret value is absent from what the hub returns, while varKeys: ["apiKey"] is present and no output field exists.
  • lg-hub export --jsonl reproduces all 14 local event lines byte-for-byte.
  • The hub was killed and the run was unaffected — same exit code, same node outcomes as with no hub configured, no stall, one line on stderr.

That last one is phase 1's central promise: a hub outage cannot affect a run. No hub code path can throw into the engine, change a checkpoint, or change an exit code.

Design notes worth knowing

  • events is append-only in the database too, enforced by trigger rather than convention, with a global sha256(prev || json) hash chain. A divergent seq for an existing key aborts the whole transaction and acks nothing.
  • Attribution is server-side. member comes from the token's key_id; the wire schema has no member field for a client value to occupy.
  • The wire schema is strict where it must be and permissive where it must be. Projected state and nodes reject unknown keys, because an unknown key there is an unclassified field that might carry content. Event lines tolerate unknown keys, because the hub stores them verbatim and a member on a newer lg than the hub must still be able to sync.
  • lg-hub serve refuses a non-loopback bind without --behind-tls-proxy. A bearer token over plaintext HTTP is a credential shape this project's own scanner has a rule for.
  • src/core/ is touched once, in 1638030, to add streamId. Legacy checkpoints derive one deterministically in memory and write nothing — re-saving inside load would recurse into save, advance the checkpoint generation on a read, and mark untouched runs as modified.

Two caveats, stated in the README rather than buried

  • Phase 1 does not mask on egress. The projection is the only gate; whatever does reach the hub is served back as stored. A repo whose var key names are themselves sensitive should keep sync disabled. Redaction-on-read is phase 2.
  • The masking is an allowlist, not a proof. It catches the shapes SCAN_RULES knows and nothing else.

Reviewing this

30 commits, each green under npm run typecheck && npm test (577 tests, 35 files).

Nine are fix: commits repairing work landed earlier in this same branch, each naming its defect in the message — they are the record of what review caught, and every one was a wrong-result-with-a-green-build rather than a failing test. Suggested reading order:

  1. docs/hub-design.md §4.1 and src/team/project.ts — the security boundary
  2. src/hub/wire.ts — the frozen vocabulary and its deliberate strictness asymmetry
  3. src/core/store.ts:48-72 — the one engine touch, and why the obvious implementation is wrong
  4. src/team/batch.ts — two timers with opposite unref rules, both load-bearing

Not in this branch

No inbox (phase 3), no web UI (phase 4), no briefs, no encryption at rest, no redaction on read (all phase 2). Nothing on the hub is encrypted yet. No full transcripts, ever.

One known limitation recorded for phase 2: a sync cursor that ends up ahead of the hub is unrecoverable, because the client cannot learn which seqs the hub is missing. The hub can — returning its own high-water on a rejected push would fix it, but that is a protocol change.

datj9 and others added 30 commits August 25, 2026 09:55
Reverses three documented non-goals - no daemon, no signal bus or inbox, no
raw transcript upload - and records what each one cost. Covers component
boundaries, wire protocol, identity, storage, the inbox, the web UI, failure
behavior, a phased plan, and the threat model behind all of it.

Four decisions are recorded with the losing argument kept rather than dropped:

D-1  the push is the sharing decision. Sync is opt-in per repo, pushed run
     events are team-readable, briefs stay private to the sender until shared.
D-1b the acceptor names the graph. An inbox message reaches an agent only as
     pre-fenced data, never as the prompt that chose the task.
D-3  SQLite is the hub's truth; JSONL is a derived export. Revision 1 had it
     backwards and claimed append-only was a physical property of a file,
     which is false - sed -i disproves it. The laptop's event log is unchanged.
D-4  no central conversation store, on measured evidence: 61% of 80 sampled
     transcripts on the author's machine carry a credential shape scan.ts
     already recognises, and that is a floor, not an estimate.

Nothing under src/ is built yet. Three questions in section 14 need answering
before phase 1, one of which - where the brief encryption key is wrapped - has
no option that is both unattended-restartable and safe from a root operator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fifteen commits for phase 1, then phases 2-5 at commit granularity, with a
delegation verdict per commit. An adversarial review of the first draft found
four defects that produce a wrong result rather than a failing build, all
verified against the tree before being fixed here:

- events was WITHOUT ROWID while cursors keyed on (received_at, rowid).
  Verified impossible: SELECT rowid fails with "no such column: rowid".
- RunState carries content, not just status. vars values and nodes[*].output
  are raw agent stdout, so pushing a checkpoint verbatim would publish secrets
  team-readable in phase 1, a phase before any masking exists. Commit 1.10 now
  projects both out on the member's machine before anything leaves it.
- INSERT OR REPLACE on events fires the append-only delete trigger, and the
  shortest path to a green test is deleting the trigger. Spelled out instead.
- scan.test.ts asserts the complete ordered rule list, so adding a rule without
  updating it is red.

Also settled what the review found under-specified: the wire types and that
event lines are stored verbatim rather than re-serialized, the hash chain order,
error body shapes, the body cap, hashToken's input, timingSafeEqual over
equal-length digests, the data dir, the shebang, node >= 22.13 for node:sqlite,
and that the batcher must gate on the repo opt-in, unref its timer, catch every
batch promise, and compose with the existing onEvent rather than replace it.

Design doc amended to match: no WITHOUT ROWID, a chain_head table, members in a
table rather than members.jsonl, no admin http route, user_version, and a new
section 4.1 on what a pushed state omits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Commit 1.4 typed EventBatch.state as ProjectedState while commit 1.10 declared
that interface, so 1.4 could not compile on its own. The type is wire
vocabulary and belongs in src/hub/wire.ts; 1.10 now supplies only the function
that produces it, and imports the type.

Two consequences worth naming. The type omits streamId, because EventBatch
already carries it at top level - which is also what makes it buildable before
commit 1.9 adds the field to RunState. And vars becomes varKeys: string[]
rather than a map with nulled values, because a nulled map still has a slot a
later change can refill with nothing failing, whereas a list of key names has
nowhere to put a value at all.
…wire

Two defects from review of 8402e49.

The plan had moved ahead of the design doc it defers to. Its own header says the
design wins on disagreement, so introducing varKeys in the plan while design
4.1 still described a map with nulled values left the plan in violation of its
own governance rule - with the better content. Design 4.1 now leads with the
varKeys shape and the reason for it, and the plan follows.

EventBatch names its run twice, once at the top level and once inside the
projected state, and nothing said what happens when the two disagree. A client
could push runId A carrying a state describing run B and the hub would store a
row whose status, cost and node table belong to a different run: a wrong answer
that never errors, which is the failure class the plan's preamble exists to
catch. eventBatchSchema now refuses any batch where state.runId or
state.graphName disagrees with the top-level value, 1.4 gains the two
assertions, and 1.7 maps the refusal to 400 run identity mismatch with nothing
stored.

Refusal rather than precedence, and refusal rather than dropping the two fields
from ProjectedState - dropping them would leave HubStore.runState() returning
an object that cannot say which run it describes, and would make the jsonl
export reconstruct identity by joining runs.
@gitguardian

gitguardian Bot commented Aug 26, 2026

Copy link
Copy Markdown

️✅ There are no secrets present in this pull request anymore.

If these secrets were true positive and are still valid, we highly recommend you to revoke them.
While these secrets were previously flagged, we no longer have a reference to the
specific commits where they were detected. Once a secret has been leaked into a git
repository, you should consider it compromised, even if it was deleted immediately.
Find here more information about risks.


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@datj9-reader datj9-reader left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: approve the code, but CI is red — GitGuardian must be dismissed before merge

Reviewed by probing the auth and isolation boundaries against a live in-memory hub, not by reading alone.

Verified by execution

  • npx vitest run -> 577/577 pass, 35 files
  • npx tsc --noEmit -> 0 errors
  • check (22) and check (24) both green

The CI failure is a false positive, and I verified it

GitGuardian Security Checks reports "5 secrets uncovered!" — all 5 in src/hub/auth.test.ts, flagged in commits 3852eee2 ("feat: add hub auth") and 8a2cfcc4.

The flagged literal decodes to:

MjM0NTY3ODkw       -> 234567890
YWJjZGVmZ2hpamtsbW5v -> abcdefghijklmno
cHFyc3R1dnd4eQ     -> pqrstuvwxy

It is the alphabet. Not a credential.

The current tree already fixes this — HEAD assembles the fixture from split parts at runtime (auth.test.ts:17) with a comment explaining exactly why. The old commit 3852eee2 had the bare literal on one line; 9ce1769 does not. Both flagged commits are ancestors of HEAD, so GitGuardian is scanning branch history, not the merged result.

Two consequences worth naming:

  1. This does not self-resolve on a normal merge — the commits enter main's history. Squash-merge collapses them so only the mitigated tree lands. The repo allows squash.
  2. Either way the incident needs dismissing in the GitGuardian dashboard as a test fixture, or it stays open against the repo.

Security probes — all clean

Probe Result
Bob writes into Alice's namespace via member in body 400member comes from the token, never the body
Bob POSTs to Alice's streamId 200 but filed under bob — writes are member-scoped
Read-only scope attempts ingest 403
No auth / malformed token 401 / 401
Path traversal in member segment 404
Exact replay acked, duplicates: 1, nothing rewritten
Same seq, different content 409 seq conflict — tamper-evident
Event runId disagreeing with envelope 400 run identity mismatch

resolveMember uses timingSafeEqual over equal-length hex digests and guards the length mismatch so a malformed stored hash yields 401 rather than a 500. Token hashes are never stored in plaintext. Revocation is checked on lookup.

Findings

1. Non-contiguous seq is accepted silently (low). After seq 1, a batch at seq 5 is accepted and the high-water jumps to 5:

4. gap (seq 5 after 1) -> 200 {"highWaterSeq":5,"accepted":1}

Seqs 2-4 can then never be ingested — they are at or below the high-water mark and get dropped as duplicates. Since a hub outage is explicitly allowed to drop batches, a gap is a real scenario, and the missing events become permanently unacceptable with no signal. The 409-on-divergence policy is loud; the gap is silent. Worth either a gaps counter in IngestResult or a note in the design doc that gaps are accepted by design.

2. Cross-member reads are open to any read-scope token (by design — confirm). GET /v1/runs/alice/<id> returns 200 for Bob's token. That matches a shared-team model and the design doc never claims per-member read isolation, so I read it as intended. Flagging only so it is an explicit decision: any member token reads every member's run state and events.

Notes

  • feed() clamps with Math.max(1, Math.floor(limit)), so limit=-1, 0, abc and 999999 all return sane pages.
  • Ingest is one transaction; a divergent seq aborts the whole batch with nothing written.
  • Commit authorship on all commits resolves to datj9.

# Conflicts:
#	src/commands/resume.ts
#	src/handoff/scan.test.ts
#	src/handoff/scan.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants