Skip to content

feat: shared curator AI-model access (opt-in, membership-gated)#1157

Open
Zigoljube wants to merge 17 commits into
mainfrom
feat/llm-sharing-module
Open

feat: shared curator AI-model access (opt-in, membership-gated)#1157
Zigoljube wants to merge 17 commits into
mainfrom
feat/llm-sharing-module

Conversation

@Zigoljube

Copy link
Copy Markdown
Contributor

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 _meta graph locally, so the network/genesis hash is untouched.

Design

  • Membership = the invite you already sent. Authorization reuses the
    delegatee-peer binding written to _meta at invite/join time
    (allowedDelegateePeer / delegationDelegateePeer). No new identity system,
    no separate recipient-acceptance step — approved members inherit access.
  • Key isolation. Members only ever send prompts and receive completions; the
    API key stays on the curator's node.
  • Grant lives in _meta as two triples (sharedModelEnabled,
    sharedModelId) and syncs to members the same way the rest of _meta does.
  • Reuses the model the curator already configures (config.llm);
    config.sharedModel can scope/override it. A built-in mock provider supports
    offline testing.

What's included

Commit What
feat(agent) SharedModelMethods mixin: per-CG grant in _meta, provider client (mock + openai-compatible), per-member daily quota, membership gate, P2P verb /dkg/10.0.2/shared-model-invoke
feat(cli) daemon routes (model/share, model/grant, model/invoke, invite-with-model) + sharedModel config wiring
feat(node-ui) "AI Model Access" toggle in the Share Project modal (curator opt-in)
feat(cli,agent) OpenAI-compatible endpoint …/model/v1/chat/completions so any OpenAI client points at the curator's model
docs(agent) feature README incl. member-usage roadmap + the cross-device findings below

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

Method & path Purpose
POST …/invite-with-model invite a member and (optionally) share the model in one call
POST …/model/share curator toggles sharing for a CG
GET …/model/grant read the grant { enabled, modelId }
POST …/model/invoke member invokes (native shape)
POST …/model/v1/chat/completions OpenAI-compatible member usage

Testing & findings

Validated:

  • Unitshared-model suite (mock provider, authorize gate, daily quota,
    wire round-trip, OpenAI request/response mapping): 10/10.
  • Buildpnpm --filter @origintrail-official/dkg-agent build green;
    build:runtime ships a dist-ui carrying the curator toggle.
  • Live single-node — curator-local invoke returns a completion.
  • Live two-node (separate nodes, stable link) — a member node (own peer id +
    agent) joins a private CG, the curator approves, the authenticated
    post-approval _meta sync lands the grant on the first poll, and
    …/model/invoke returns
    {"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 _meta is present; the remaining work is purely the
cross-NAT _meta bootstrap, 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 _meta and can only reach a member
through the existing curated-CG sync. Cross-NAT first-sync exposed:

  1. Bootstrap auth deadlock. An authenticated private meta-sync request needs
    the curator peer id as targetPeerId, resolved from the member's local
    _meta
    — the thing it's fetching. Only the join-approved-triggered
    runImmediatePostApprovalSync breaks it (it's handed the curator peer id);
    background/subscribe catchup sends an unauthenticated request that a private
    CG rejects (phase=meta, request-authorize.ts).
  2. synced-flag poison. POST …/subscribe marks the CG synced:true even
    when _meta was denied; buildSyncRequest (dkg-agent-cg-resolve.ts) then
    derives needsAuth from synced, not metaSynced, so every later
    meta-sync — including the authenticated post-approval one — is sent
    unauthenticated and permanently denied (recoverable only by restart, since
    subscribedContextGraphs is in-memory).
    Suggested fix: gate needsAuth on metaSynced for private CGs, and/or
    don't set synced until _meta lands.
  3. A re-fired already-member join-approved is dropped by the requester's
    trusted-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 pending join 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)

  • Quotas/grant are node-local and in-memory (reset on restart) — no on-chain
    accounting or payment; monetization is a follow-up.
  • No streaming; single completion per request; no tool-calls forwarded.
  • Cross-NAT first-sync of the grant depends on the upstream _meta sync above;
    reliable procedure documented in the feature README.

🤖 Generated with Claude Code

Ziga Drev and others added 5 commits June 13, 2026 20:00
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>
@Zigoljube Zigoljube marked this pull request as ready for review June 14, 2026 09:52
const metaGraph = contextGraphMetaGraphUri(contextGraphId);
const result = await this.store.query(
`SELECT ?peer WHERE { GRAPH <${metaGraph}> {
{ ?s <https://dkg.network/ontology#allowedDelegateePeer> ?peer }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 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[];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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.

Branimir Rakic and others added 2 commits June 14, 2026 12:38
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>
Branimir Rakic and others added 7 commits June 14, 2026 12:59
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>
@TomazOT

TomazOT commented Jun 14, 2026

Copy link
Copy Markdown

Really like the shape of this. Reusing the invite/_meta delegatee-peer binding for auth instead of inventing a new identity layer, keeping the API key node-local, and making it owner-gated and default-off are all the right calls, and the pure decideSharedModelAuthorization + mock provider make it easy to reason about and test.

A few small things worth tightening while it's still MVP, in rough priority:

  1. contextGraphId reaches SPARQL before the membership gate. In handleSharedModelInvoke, req.contextGraphId comes straight off the wire (decodeInvokeRequest validates message shape but not the id), then flows through contextGraphMetaGraphUri / contextGraphDataGraphUri into the interpolated queries in getContextGraphModelGrant and isPeerContextGraphMember, both of which run on the caller-supplied id before membership is confirmed. If those builders don't already assertSafeIri the id, a peer could inject SPARQL against the curator's store pre-auth. Could you confirm they sanitize, or add an id-charset check in decodeInvokeRequest? Cheap belt-and-suspenders either way.
  2. No timeout on the provider call. openAiCompatibleComplete uses a bare fetch with no AbortController, so a slow upstream can pin the curator's handler (and the member's sendReliable) open. A timeout bounds the failure mode.
  3. Clamp maxTokens. Full token metering is clearly a follow-up (the usage: {0,0,0} note), but a simple clamp to a configured ceiling is a cheap interim guard against a single oversized request driving cost/latency.
  4. Minor: quota.allow(fromPeerId) is evaluated before decideSharedModelAuthorization, so a request denied for another reason still spends a quota slot. Moving the consume to after the other checks pass keeps the counter honest.

Post-MVP (tracking, not blockers for this PR)

  • Token metering. Bound spend by tokens rather than request count, make the counter durable across restarts (it's in-memory today, so the bound resets on restart), and fill in the usage block so OpenAI clients get real accounting. This is the proper version of the maxTokens clamp above.
  • Per-invocation audit trail. For a curator lending their key, a record of who invoked, when, and at what token cost is what makes abuse detectable and usage accountable.
  • Separate scoped key by default. sharedModel reusing llm.apiKey means enabling sharing points the curator's personal key at the group. Defaulting to a dedicated key (fail closed if unset) shrinks the blast radius.
  • Abuse/liability posture. The curator runs members' prompts on their own provider account, so a jailbreak or ToS-tripping prompt lands under the curator's account. Worth a per-member disable and a docs note making the trust assumption explicit.
  • Revocation coverage. A quick regression test confirming that removing a member from the CG actually cuts model access (the delegatee-peer binding is removed and propagates), since access is only as revocable as that binding.

// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 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.

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.

3 participants