From 40144212225b59b3401416f6f40b03f9e2c5e993 Mon Sep 17 00:00:00 2001 From: Volod Date: Wed, 7 Oct 2026 19:22:10 -0700 Subject: [PATCH 1/4] fix(login): say where the credentials were really saved Login always printed "Credentials saved to ~/.vana/auth.json", even when VANA_HOME moved the file or a non-production Account gave it its own name (auth.account-dev.vana.org.json). It now prints getAuthFilePath(), home-shortened. Claude-Session: https://claude.ai/code/session_01Fcv6uEy4zcXaigzDNxeW3j --- src/cli/auth.ts | 9 +++++++++ src/cli/index.ts | 5 +++-- test/cli/auth.test.ts | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/cli/auth.ts b/src/cli/auth.ts index 3bbb43f..0681f16 100644 --- a/src/cli/auth.ts +++ b/src/cli/auth.ts @@ -11,6 +11,7 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; import { getVanaHome } from "../core/paths.js"; +import { formatDisplayPath } from "./render/format.js"; export interface VanaCredentials { account: { @@ -65,6 +66,14 @@ export function getAuthFilePath(): string { return path.join(getVanaHome(), `auth.${safe}.json`); } +/** + * The line login prints after saving: the file this login actually went to, + * which follows VANA_HOME and the Account environment, home-shortened. + */ +export function credentialsSavedLine(): string { + return `Credentials saved to ${formatDisplayPath(getAuthFilePath())}`; +} + function normalizeCredentials( creds: LegacyVanaCredentials, ): VanaCredentials | null { diff --git a/src/cli/index.ts b/src/cli/index.ts index b927856..559a71f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -175,6 +175,7 @@ import { readStoredAuthFile, readStoredAccountAddress, saveCredentials, + credentialsSavedLine, clearCredentials, isExpired, formatAddress, @@ -8045,7 +8046,7 @@ async function loginToPersonalServer( await updateCliConfig({ personalServerUrl: psUrl }); renderer?.success(`Logged in to ${psUrl}`); - renderer?.detail("Credentials saved to ~/.vana/auth.json"); + renderer?.detail(credentialsSavedLine()); if ( liveAccount && liveAccount.address.toLowerCase() !== result.address.toLowerCase() @@ -8286,7 +8287,7 @@ async function runLogin( ); } } - renderer.detail("Credentials saved to ~/.vana/auth.json"); + renderer.detail(credentialsSavedLine()); }, onExpired: () => { renderer.fail("Authorization expired"); diff --git a/test/cli/auth.test.ts b/test/cli/auth.test.ts index c44d443..f108104 100644 --- a/test/cli/auth.test.ts +++ b/test/cli/auth.test.ts @@ -13,6 +13,7 @@ vi.mock("node:child_process", () => ({ import { accountSessionToPreserve, + credentialsSavedLine, getAccountUrl, getAuthFilePath, getAuthTarget, @@ -834,6 +835,38 @@ describe("getAccountUrl", () => { } }); + it("says where the login was really saved", async () => { + const home = await mkdtemp(join(tmpdir(), "vana-auth-line-")); + const original = { + home: process.env.HOME, + vanaHome: process.env.VANA_HOME, + }; + try { + process.env.HOME = home; + delete process.env.VANA_HOME; + delete process.env.VANA_ENV; + delete process.env.VANA_ACCOUNT_URL; + expect(credentialsSavedLine()).toBe( + "Credentials saved to ~/.vana/auth.json", + ); + process.env.VANA_ACCOUNT_URL = "https://account-dev.vana.org"; + expect(credentialsSavedLine()).toBe( + "Credentials saved to ~/.vana/auth.account-dev.vana.org.json", + ); + const elsewhere = await mkdtemp(join(tmpdir(), "vana-auth-home-")); + process.env.VANA_HOME = join(elsewhere, ".vana"); + expect(credentialsSavedLine()).toBe( + `Credentials saved to ${join(elsewhere, ".vana", "auth.account-dev.vana.org.json")}`, + ); + await rm(elsewhere, { recursive: true, force: true }); + } finally { + process.env.HOME = original.home; + if (original.vanaHome === undefined) delete process.env.VANA_HOME; + else process.env.VANA_HOME = original.vanaHome; + await rm(home, { recursive: true, force: true }); + } + }); + it("lets VANA_ACCOUNT_URL win over VANA_ENV", () => { process.env.VANA_ACCOUNT_URL = "http://localhost:3000/"; process.env.VANA_ENV = "dev"; From cb4b7cffbc7f2616173cb094edb953a01ab035c1 Mon Sep 17 00:00:00 2001 From: Volod Date: Wed, 7 Oct 2026 19:23:07 -0700 Subject: [PATCH 2/4] feat(app): extend the live grant instead of replacing it The gateway keeps one grant per owner and app, and an approval replaces its scopes. `vana app request` now reads the app's live grant when this machine already holds an approval for the app key (or with --owner) and asks for the union, printing what it keeps, adds and removes first. --remove-scopes gives entries up, --no-merge-grant sends --scopes verbatim. Two different earlier approvers are never guessed between. The --json outcome carries kept, added, removed and grantUnion. The merge is a local copy of the SDK's mergeWithLiveGrant (vana-sdk#215, unreleased); removeScopes reaches the approval page once the SDK pin is bumped to the release that sends it. Claude-Session: https://claude.ai/code/session_01Fcv6uEy4zcXaigzDNxeW3j --- README.md | 5 +- skills/builder/SKILL.md | 12 ++ src/cli/app/index.ts | 26 +++- src/cli/app/request.ts | 265 ++++++++++++++++++++++++++++++- src/core/grant-union.ts | 165 ++++++++++++++++++++ src/core/requests-store.ts | 7 +- test/cli/app-request.test.ts | 292 +++++++++++++++++++++++++++++++++++ 7 files changed, 758 insertions(+), 14 deletions(-) create mode 100644 src/core/grant-union.ts diff --git a/README.md b/README.md index a1cee94..0ae6319 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,10 @@ vana app request --scopes github.repositories # prints an approval URL, wai vana app read github.repositories --grant # signed read ``` -`request` returns the grant id once the person approves. `read` stops at +`request` returns the grant id once the person approves. Asking the same +person again extends their grant rather than replacing it: `request` keeps +what the live grant already covers and prints what it keeps, adds and +removes (`--remove-scopes` to give one up). `read` stops at exit 4 with the exact price before spending anything; add `--pay` to settle it from escrow and `--max-fee` to cap it. The rest of the group: diff --git a/skills/builder/SKILL.md b/skills/builder/SKILL.md index 5003697..719b6ee 100644 --- a/skills/builder/SKILL.md +++ b/skills/builder/SKILL.md @@ -63,6 +63,18 @@ vana app request --scopes spotify.savedTracks,spotify.playlists --json Waits up to ten minutes by default (`--timeout `). On approval the outcome carries `grantId` and the exact read command in `remedy`. +One grant per owner and app: an approval replaces the grant's scopes, it +does not add a second grant. So when this machine already holds an approval +for the app key, `request` reads that live grant and asks for the union, and +prints what it keeps, adds and removes before creating the request. Give up +a scope with `--remove-scopes a,b`; pick whose grant to extend with +`--owner
` when more than one person approved; send `--scopes` +verbatim with `--no-merge-grant`. `--json` carries `kept`, `added`, +`removed` and `grantUnion.status` (`merged`, `no_live_grant`, +`owner_unknown`, `owner_ambiguous`, `disabled`, `unavailable`). With no +earlier approval the person is unknown until they approve, and the approval +page keeps what they already granted. + When no human is watching the terminal, do not block: ```bash diff --git a/src/cli/app/index.ts b/src/cli/app/index.ts index a900fec..a8f94bc 100644 --- a/src/cli/app/index.ts +++ b/src/cli/app/index.ts @@ -50,6 +50,18 @@ export function registerAppCommands( "--scopes ", "Comma-separated scopes to request (with --question, defaults to --derived)", ) + .option( + "--remove-scopes ", + "Comma-separated scopes the app's live grant should drop", + ) + .option( + "--owner
", + "Whose live grant to extend, when more than one person approved this app", + ) + .option( + "--no-merge-grant", + "Send --scopes verbatim instead of keeping what the live grant covers", + ) .option("--question ", "Derivative question to carry on the request") .option("--derived ", "Scope the answer is written to") .option( @@ -64,12 +76,14 @@ export function registerAppCommands( .option("--app-id ", "App id shown during approval") .option("--app-name ", "App name shown during approval") .option("--app-url ", "App homepage shown during approval") - .action(async (commandOptions: Record) => { - process.exitCode = await runAppRequest({ - ...getOptions(), - ...commandOptions, - }); - }); + .action( + async (commandOptions: Record) => { + process.exitCode = await runAppRequest({ + ...getOptions(), + ...commandOptions, + }); + }, + ); const requests = app .command("requests") diff --git a/src/cli/app/request.ts b/src/cli/app/request.ts index ec29868..ed61c46 100644 --- a/src/cli/app/request.ts +++ b/src/cli/app/request.ts @@ -11,6 +11,10 @@ * so a `--no-input` run is findable afterwards. */ +import { + createGatewayClient, + type GatewayClient, +} from "@opendatalabs/vana-sdk"; import { createDirectDataController } from "@opendatalabs/vana-sdk/server"; import { AppKeyMissingError, @@ -29,10 +33,22 @@ import { type StoredRequestStatus, } from "../../core/requests-store.js"; import { readAppProfile } from "../../core/app-profile.js"; +import { + GrantUnionConflictError, + mergeWithLiveGrant, + unionGrantScopes, + type GrantUnion, +} from "../../core/grant-union.js"; import { emitAppOutcome, type AppCommandOptions } from "./outcome.js"; export interface RequestCommandOptions extends AppCommandOptions { scopes?: string; + /** Live grant entries to give up instead of carrying them over. */ + removeScopes?: string; + /** Whose live grant to extend, when this machine cannot tell. */ + owner?: string; + /** `false` (`--no-merge-grant`) sends --scopes verbatim. */ + mergeGrant?: boolean; /** Derivative question text, requires --derived and --sources. */ question?: string; derived?: string; @@ -53,6 +69,181 @@ export interface RequestDeps { sleep?: (ms: number) => Promise; now?: () => number; readProfile?: typeof readAppProfile; + createClient?: (gatewayUrl: string) => GrantUnionClient; +} + +type GrantUnionClient = Pick< + GatewayClient, + "getGrant" | "listGrantsByUser" | "getBuilder" +>; + +/** + * What a new request does to the app's live grant: the gateway keeps one + * grant per owner and app, and an approval replaces its scopes, so the + * request carries what the grant already covers. + */ +export type GrantUnionPlan = GrantUnion & { + status: + | "merged" + | "no_live_grant" + | "owner_unknown" + | "owner_ambiguous" + | "disabled" + | "unavailable"; + owner?: string; + grantId?: string; + reason?: string; +}; + +const GRANT_LOOKUP_TIMEOUT_MS = 5_000; +const ADDRESS = /^0x[0-9a-fA-F]{40}$/; + +function withTimeout(promise: Promise, ms: number): Promise { + let timer: NodeJS.Timeout | undefined; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("timed out")), ms); + }), + ]).finally(() => clearTimeout(timer)); +} + +/** + * Work out whose live grant to extend and merge it. + * + * The person is anonymous until they approve, so the owner comes from + * `--owner` or from an earlier approval of this app key on this machine. + * Grant ids are one per owner and app, so two different grant ids mean two + * different people approved, and merging either one's scopes would ask the + * other for data they never granted: that case is left to the approval + * page, which signs the union for whoever actually approves. + * + * Never throws for the network: an unreachable gateway sends the request + * with --scopes only. + */ +export async function planGrantUnion(input: { + scopes: string[]; + removeScopes: string[]; + owner?: string; + merge: boolean; + appAddress: string; + network: string; + gatewayUrl: string; + requests: RequestsStore; + createClient: (gatewayUrl: string) => GrantUnionClient; +}): Promise { + const requestedOnly = unionGrantScopes([], input.scopes, input.removeScopes); + if (!input.merge) { + return { status: "disabled", ...requestedOnly }; + } + + let grantIds: string[] = []; + if (!input.owner) { + grantIds = [ + ...new Set( + input.requests + .list({ appAddress: input.appAddress, network: input.network }) + .filter( + (entry) => entry.grantId && entry.gatewayUrl === input.gatewayUrl, + ) + .map((entry) => entry.grantId!.toLowerCase()), + ), + ]; + if (grantIds.length === 0) { + return { + status: "owner_unknown", + reason: "no earlier approval for this app on this machine", + ...requestedOnly, + }; + } + if (grantIds.length > 1) { + return { + status: "owner_ambiguous", + reason: `${grantIds.length} different people approved this app; pass --owner to extend one person's grant`, + ...requestedOnly, + }; + } + } + + try { + const client = input.createClient(input.gatewayUrl); + let owner = input.owner; + let granteeId: string | undefined; + if (!owner) { + const previous = await withTimeout( + client.getGrant(grantIds[0]!), + GRANT_LOOKUP_TIMEOUT_MS, + ); + if (!previous) { + return { + status: "owner_unknown", + reason: `the gateway has no grant ${grantIds[0]}`, + ...requestedOnly, + }; + } + owner = previous.grantorAddress; + granteeId = previous.granteeId; + } + const merged = await withTimeout( + mergeWithLiveGrant({ + gateway: client, + owner, + ...(granteeId ? { granteeId } : { appAddress: input.appAddress }), + scopes: input.scopes, + removeScopes: input.removeScopes, + }), + GRANT_LOOKUP_TIMEOUT_MS, + ); + return { ...merged, owner }; + } catch (error) { + return { + status: "unavailable", + reason: `the gateway was not reached (${error instanceof Error ? error.message : String(error)})`, + ...(input.owner ? { owner: input.owner } : {}), + ...requestedOnly, + }; + } +} + +/** The kept/added/removed lists every outcome of a request carries. */ +function grantUnionData(plan: GrantUnionPlan): Record { + return { + kept: plan.kept, + added: plan.added, + removed: plan.removed, + grantUnion: { + status: plan.status, + owner: plan.owner ?? null, + grantId: plan.grantId ?? null, + ...(plan.reason ? { reason: plan.reason } : {}), + notCarried: plan.notCarried, + }, + }; +} + +function describePlan(plan: GrantUnionPlan): string { + const list = (entries: string[]) => entries.join(", ") || "nothing"; + const lines: string[] = []; + if (plan.status === "merged") { + lines.push(` Live grant ${plan.grantId} (owner ${plan.owner})`); + } else if (plan.status === "no_live_grant") { + lines.push(` Live grant none for owner ${plan.owner}`); + } else if (plan.status !== "disabled") { + lines.push( + ` Live grant not read: ${plan.reason}. The approval page keeps what the approver already granted.`, + ); + } + lines.push(` Keeping ${list(plan.kept)}`); + lines.push(` Adding ${list(plan.added)}`); + if (plan.removed.length > 0 || plan.status !== "merged") { + lines.push(` Removing ${list(plan.removed)}`); + } + if (plan.notCarried.length > 0) { + lines.push( + ` Not carried ${plan.notCarried.join(", ")} (this request cannot hold these; the approval page decides)`, + ); + } + return `\n${lines.join("\n")}\n`; } const POLL_INTERVAL_MS = 3_000; @@ -192,6 +383,59 @@ export async function runAppRequest( // What `vana app register --app-name/--app-url` remembered for this key. const profile = (deps.readProfile ?? readAppProfile)(key.address); + const removeScopes = splitList(options.removeScopes); + if (options.owner && !ADDRESS.test(options.owner)) { + return emitAppOutcome(options, { + status: "failed", + code: "bad_usage", + message: `--owner ${options.owner} is not an address.`, + network: network.name, + }); + } + const requests = deps.requests ?? createRequestsStore(); + let plan: GrantUnionPlan; + try { + plan = await planGrantUnion({ + scopes, + removeScopes, + owner: options.owner, + merge: options.mergeGrant !== false, + appAddress: key.address, + network: network.name, + gatewayUrl: network.gatewayUrl, + requests, + createClient: deps.createClient ?? createGatewayClient, + }); + } catch (error) { + if (error instanceof GrantUnionConflictError) { + return emitAppOutcome(options, { + status: "failed", + code: "bad_usage", + message: error.message, + network: network.name, + }); + } + throw error; + } + // A question's source may not also be a raw read on the same request (the + // service refuses it: the person is told the app will not see the + // sources), so a live raw read of one is left for the approval page. + const carriedSources = sourceScopes.filter((scope) => + plan.kept.includes(scope), + ); + if (carriedSources.length > 0) { + plan = { + ...plan, + scopes: plan.scopes.filter((scope) => !carriedSources.includes(scope)), + kept: plan.kept.filter((scope) => !carriedSources.includes(scope)), + notCarried: [...plan.notCarried, ...carriedSources], + }; + } + const requestScopes = plan.scopes; + if (!options.json && !options.quiet) { + process.stderr.write(describePlan(plan)); + } + const controller = (deps.createController ?? createDirectDataController)({ // The dev host set serves moksha; production serves both networks. env: network.env === "dev" ? "dev" : "production", @@ -204,13 +448,18 @@ export async function runAppRequest( options.appUrl ?? profile.url ?? "https://github.com/vana-com/vana-cli", }, source: resolveSourceKey(scopes, options.derived, sourceScopes), - scopes, + scopes: requestScopes, }); - const requests = deps.requests ?? createRequestsStore(); let created; try { + // `removeScopes` tells the approval page to leave those entries out of + // the union it signs. SDK 4.0.0 drops the field; it is sent from the + // SDK release that adds it (vana-sdk#215). + const extra: Record = + removeScopes.length > 0 ? { removeScopes } : {}; created = await controller.createAccessRequest({ + ...extra, returnUrl: options.returnUrl ?? "https://github.com/vana-com/vana-cli", ...(options.question && options.derived ? { @@ -241,7 +490,8 @@ export async function runAppRequest( appAddress: key.address, network: network.name, gatewayUrl: network.gatewayUrl, - scopes, + scopes: requestScopes, + ...(removeScopes.length > 0 ? { removeScopes } : {}), approvalUrl: created.approvalUrl, createdAt: new Date().toISOString(), status: "pending", @@ -263,8 +513,9 @@ export async function runAppRequest( data: { requestId: created.requestId, approvalUrl: created.approvalUrl, - scopes, + scopes: requestScopes, expiresAt: created.expiresAt ?? null, + ...grantUnionData(plan), }, }); } @@ -307,11 +558,11 @@ export async function runAppRequest( code: "grant_invalid", message: `The request was ${status.status}.`, network: network.name, - data: { requestId: created.requestId }, + data: { requestId: created.requestId, ...grantUnionData(plan) }, }); } - const approvedScopes = status.scopes ?? scopes; + const approvedScopes = status.scopes ?? requestScopes; const nextScope = options.derived && approvedScopes.includes(options.derived) ? options.derived @@ -330,6 +581,7 @@ export async function runAppRequest( scopes: approvedScopes, delivery: status.delivery ?? "personal_server", personalServerUrl: status.personalServerUrl ?? null, + ...grantUnionData(plan), }, }); } @@ -345,6 +597,7 @@ export async function runAppRequest( data: { requestId: created.requestId, approvalUrl: created.approvalUrl, + ...grantUnionData(plan), }, }); } diff --git a/src/core/grant-union.ts b/src/core/grant-union.ts new file mode 100644 index 0000000..c8c096e --- /dev/null +++ b/src/core/grant-union.ts @@ -0,0 +1,165 @@ +/** + * Extend an app's live grant instead of replacing it. + * + * The gateway keeps one grant per owner and app, and a new approval + * replaces that grant's scopes. A request for `whoop.recovery` from an app + * that holds `oura.sleep` would leave it with `whoop.recovery` alone, so a + * request has to carry what the grant already covers. + * + * Local copy of `mergeWithLiveGrant` / `unionGrantScopes` from + * `@opendatalabs/vana-sdk` (added in vana-sdk#215, not released yet; this + * package pins 4.0.0). Same names, inputs and outputs, so the swap is an + * import change once the SDK ships it. + */ + +import type { GatewayClient } from "@opendatalabs/vana-sdk"; +import { parseScope, parseScopeEntry } from "@opendatalabs/vana-sdk/server"; + +type GrantListItem = Awaited< + ReturnType +>[number]; + +/** The slice of the gateway client the helpers read through. */ +export type GrantUnionGateway = Pick & + Partial>; + +export interface MergeWithLiveGrantInput { + gateway: GrantUnionGateway; + /** The data owner (grantor) whose live grant to extend. */ + owner: string; + /** The app's bytes32 builder id; or pass `appAddress`. */ + granteeId?: string; + /** Resolved to `granteeId` through `gateway.getBuilder` when needed. */ + appAddress?: string; + scopes: readonly string[]; + /** Live entries the app gives up, matched verbatim. */ + removeScopes?: readonly string[]; +} + +export interface GrantUnion { + /** What to send on the new request. */ + scopes: string[]; + /** Live entries carried over. */ + kept: string[]; + /** Requested entries the live grant did not cover. */ + added: string[]; + /** Live entries dropped because they were in `removeScopes`. */ + removed: string[]; + /** Live entries a request cannot carry (wildcards, unknown operations). */ + notCarried: string[]; + liveScopes: string[]; +} + +export interface MergeWithLiveGrantResult extends GrantUnion { + status: "merged" | "no_live_grant"; + grantId?: string; +} + +export class GrantUnionConflictError extends Error { + constructor(readonly conflicting: string[]) { + super( + `${conflicting.join(", ")} cannot be both requested and removed. Drop it from --scopes or from --remove-scopes.`, + ); + this.name = "GrantUnionConflictError"; + } +} + +// A concrete scope with a known operation prefix: what a data connection +// request accepts. Wildcards (`chatgpt.*`) and unknown operations are not. +function isCarryable(entry: string): boolean { + try { + parseScope(parseScopeEntry(entry).scope); + return true; + } catch { + return false; + } +} + +const dedupe = (entries: readonly string[]) => [...new Set(entries)]; + +export function unionGrantScopes( + liveScopes: readonly string[], + scopes: readonly string[], + removeScopes: readonly string[] = [], +): GrantUnion { + const remove = new Set(removeScopes); + const conflicting = scopes.filter((entry) => remove.has(entry)); + if (conflicting.length > 0) { + throw new GrantUnionConflictError(conflicting); + } + const live = dedupe(liveScopes); + const liveSet = new Set(live); + const requested = dedupe(scopes); + const remaining = live.filter((entry) => !remove.has(entry)); + const kept = remaining.filter((entry) => isCarryable(entry)); + const added = requested.filter((entry) => !liveSet.has(entry)); + return { + scopes: dedupe([...kept, ...added]), + kept, + added, + removed: live.filter((entry) => remove.has(entry)), + notCarried: remaining.filter((entry) => !isCarryable(entry)), + liveScopes: live, + }; +} + +/** The owner's active grant for one app, newest version first. */ +export async function findLiveGrant( + gateway: Pick, + owner: string, + granteeId: string, +): Promise { + const grants = await gateway.listGrantsByUser(owner); + const matching = grants + .filter( + (grant) => + grant.granteeId.toLowerCase() === granteeId.toLowerCase() && + !grant.revokedAt && + !grant.expired, + ) + .sort((a, b) => { + const av = BigInt(a.grantVersion || "0"); + const bv = BigInt(b.grantVersion || "0"); + return av === bv ? 0 : av > bv ? -1 : 1; + }); + return matching[0] ?? null; +} + +export async function liveGrantScopes( + gateway: Pick, + owner: string, + granteeId: string, +): Promise { + const grant = await findLiveGrant(gateway, owner, granteeId); + return grant ? [...grant.scopes] : []; +} + +export async function mergeWithLiveGrant( + input: MergeWithLiveGrantInput, +): Promise { + const removeScopes = input.removeScopes ?? []; + unionGrantScopes([], input.scopes, removeScopes); + let granteeId = input.granteeId ?? null; + if (!granteeId) { + if (!input.appAddress || !input.gateway.getBuilder) { + throw new Error( + "mergeWithLiveGrant needs granteeId, or appAddress and gateway.getBuilder.", + ); + } + granteeId = (await input.gateway.getBuilder(input.appAddress))?.id ?? null; + } + const grant = granteeId + ? await findLiveGrant(input.gateway, input.owner, granteeId) + : null; + if (!grant) { + return { + status: "no_live_grant", + ...unionGrantScopes([], input.scopes, removeScopes), + }; + } + return { + status: "merged", + grantId: grant.id, + ...unionGrantScopes(grant.scopes, input.scopes, removeScopes), + }; +} diff --git a/src/core/requests-store.ts b/src/core/requests-store.ts index 2814d95..b369b8e 100644 --- a/src/core/requests-store.ts +++ b/src/core/requests-store.ts @@ -31,8 +31,13 @@ export interface StoredRequest { appAddress: string; network: string; gatewayUrl: string; - /** Scopes asked for, verbatim (may carry `write:` prefixes). */ + /** + * Scopes asked for, verbatim (may carry `write:` prefixes), after the + * app's live grant was merged in. + */ scopes: string[]; + /** Live grant entries the request gave up (`--remove-scopes`). */ + removeScopes?: string[]; approvalUrl: string; createdAt: string; status: StoredRequestStatus; diff --git a/test/cli/app-request.test.ts b/test/cli/app-request.test.ts index 6ed41c9..437cdd9 100644 --- a/test/cli/app-request.test.ts +++ b/test/cli/app-request.test.ts @@ -642,3 +642,295 @@ describe("vana app requests", () => { ); }); }); + +describe("vana app request - extending the live grant", () => { + const OWNER = "0x00000000000000000000000000000000000000aa"; + const BUILDER = `0x${"ab".repeat(32)}`; + + /** A store that already holds one approved request, so the owner is known. */ + function storeWithApproval(grantId = GRANT) { + const requests = store(); + requests.save({ + requestId: "dcr_earlier", + appAddress: account.address, + network: "mainnet", + gatewayUrl: "https://dp-rpc.vana.org", + scopes: ["oura.sleep"], + approvalUrl: "https://app.vana.org/approve/earlier", + createdAt: "2026-10-01T00:00:00Z", + status: "completed", + updatedAt: "2026-10-01T00:00:00Z", + grantId, + }); + return requests; + } + + function liveGateway(scopes: string[]) { + const liveGrant = { + id: GRANT, + grantorAddress: OWNER, + granteeId: BUILDER, + scopes, + revokedAt: null, + expired: false, + grantVersion: "3", + }; + const client = { + getGrant: vi.fn(async () => liveGrant as never), + listGrantsByUser: vi.fn(async () => [liveGrant] as never), + getBuilder: vi.fn(async () => ({ id: BUILDER }) as never), + }; + return { client, createClient: vi.fn(() => client) }; + } + + /** Controller stub recording the configured scopes and the create input. */ + function recordingController() { + const seen: { scopes?: string[]; input?: Record } = {}; + const createController = ((config: { scopes: string[] }) => { + seen.scopes = config.scopes; + const inner = controller([{ status: "pending" }])() as { + createAccessRequest: (input: Record) => unknown; + }; + return { + ...inner, + createAccessRequest: async (input: Record) => { + seen.input = input; + return inner.createAccessRequest(input); + }, + }; + }) as never; + return { seen, createController }; + } + + it("keeps what the live grant covers, adds the new scope, drops removals", async () => { + const requests = storeWithApproval(); + const gw = liveGateway(["oura.sleep", "github.repositories"]); + const { seen, createController } = recordingController(); + const exitCode = await runAppRequest( + { + json: true, + noInput: true, + scopes: "whoop.recovery", + removeScopes: "github.repositories", + }, + { + resolveKey: () => appKey, + requests, + createClient: gw.createClient, + createController, + }, + ); + expect(exitCode).toBe(7); + expect(gw.client.listGrantsByUser).toHaveBeenCalledWith(OWNER); + expect(seen.scopes).toEqual(["oura.sleep", "whoop.recovery"]); + expect(seen.input).toMatchObject({ removeScopes: ["github.repositories"] }); + const outcome = appOutcomeSchema.parse(JSON.parse(stdout)); + expect(outcome.data).toMatchObject({ + scopes: ["oura.sleep", "whoop.recovery"], + kept: ["oura.sleep"], + added: ["whoop.recovery"], + removed: ["github.repositories"], + grantUnion: { status: "merged", owner: OWNER, grantId: GRANT }, + }); + expect(requests.get(REQUEST_ID)).toMatchObject({ + scopes: ["oura.sleep", "whoop.recovery"], + removeScopes: ["github.repositories"], + }); + }); + + it("prints what will be kept, added and removed before creating", async () => { + const gw = liveGateway(["oura.sleep", "github.repositories"]); + let stderr = ""; + vi.mocked(process.stderr.write).mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); + await runAppRequest( + { + noInput: true, + scopes: "whoop.recovery", + removeScopes: "github.repositories", + }, + { + resolveKey: () => appKey, + requests: storeWithApproval(), + createClient: gw.createClient, + createController: controller([{ status: "pending" }]), + }, + ); + expect(stderr).toContain(`Live grant ${GRANT} (owner ${OWNER})`); + expect(stderr).toContain("Keeping oura.sleep"); + expect(stderr).toContain("Adding whoop.recovery"); + expect(stderr).toContain("Removing github.repositories"); + }); + + it("reads nothing when this machine has no earlier approval", async () => { + const gw = liveGateway(["oura.sleep"]); + const { seen, createController } = recordingController(); + await runAppRequest( + { json: true, noInput: true, scopes: "whoop.recovery" }, + { + resolveKey: () => appKey, + requests: store(), + createClient: gw.createClient, + createController, + }, + ); + expect(gw.createClient).not.toHaveBeenCalled(); + expect(seen.scopes).toEqual(["whoop.recovery"]); + expect(seen.input).not.toHaveProperty("removeScopes"); + const outcome = appOutcomeSchema.parse(JSON.parse(stdout)); + expect(outcome.data).toMatchObject({ + kept: [], + added: ["whoop.recovery"], + grantUnion: { status: "owner_unknown" }, + }); + }); + + it("does not guess between two people who approved this app", async () => { + const requests = storeWithApproval(); + requests.save({ + ...requests.get("dcr_earlier")!, + requestId: "dcr_other", + grantId: `0x${"22".repeat(32)}`, + }); + const gw = liveGateway(["oura.sleep"]); + const { seen, createController } = recordingController(); + await runAppRequest( + { json: true, noInput: true, scopes: "whoop.recovery" }, + { + resolveKey: () => appKey, + requests, + createClient: gw.createClient, + createController, + }, + ); + expect(gw.createClient).not.toHaveBeenCalled(); + expect(seen.scopes).toEqual(["whoop.recovery"]); + const outcome = appOutcomeSchema.parse(JSON.parse(stdout)); + expect(outcome.data).toMatchObject({ + grantUnion: { status: "owner_ambiguous" }, + }); + }); + + it("uses --owner to pick whose grant to extend", async () => { + const gw = liveGateway(["oura.sleep"]); + const { seen, createController } = recordingController(); + await runAppRequest( + { json: true, noInput: true, scopes: "whoop.recovery", owner: OWNER }, + { + resolveKey: () => appKey, + requests: store(), + createClient: gw.createClient, + createController, + }, + ); + expect(gw.client.getBuilder).toHaveBeenCalledWith(account.address); + expect(seen.scopes).toEqual(["oura.sleep", "whoop.recovery"]); + }); + + it("sends --scopes verbatim with --no-merge-grant", async () => { + const gw = liveGateway(["oura.sleep"]); + const { seen, createController } = recordingController(); + await runAppRequest( + { + json: true, + noInput: true, + scopes: "whoop.recovery", + mergeGrant: false, + }, + { + resolveKey: () => appKey, + requests: storeWithApproval(), + createClient: gw.createClient, + createController, + }, + ); + expect(gw.createClient).not.toHaveBeenCalled(); + expect(seen.scopes).toEqual(["whoop.recovery"]); + }); + + it("still creates the request when the gateway is down", async () => { + const failing = { + getGrant: vi.fn(async () => { + throw new Error("ECONNREFUSED"); + }), + listGrantsByUser: vi.fn(), + getBuilder: vi.fn(), + }; + const { seen, createController } = recordingController(); + const exitCode = await runAppRequest( + { json: true, noInput: true, scopes: "whoop.recovery" }, + { + resolveKey: () => appKey, + requests: storeWithApproval(), + createClient: () => failing as never, + createController, + }, + ); + expect(exitCode).toBe(7); + expect(seen.scopes).toEqual(["whoop.recovery"]); + const outcome = appOutcomeSchema.parse(JSON.parse(stdout)); + expect(outcome.data).toMatchObject({ + grantUnion: { status: "unavailable" }, + }); + }); + + it("refuses a scope that is both requested and removed", async () => { + const exitCode = await runAppRequest( + { + json: true, + noInput: true, + scopes: "whoop.recovery", + removeScopes: "whoop.recovery", + }, + { + resolveKey: () => appKey, + requests: store(), + createController: () => { + throw new Error("must not create a request"); + }, + }, + ); + expect(exitCode).toBe(2); + expect(appOutcomeSchema.parse(JSON.parse(stdout)).message).toContain( + "both requested and removed", + ); + }); + + it("leaves a live raw read of a question's source out of the request", async () => { + // The service refuses a source that is also a raw read on the request. + const gw = liveGateway(["github.repositories", "oura.sleep"]); + const { seen, createController } = recordingController(); + const exitCode = await runAppRequest( + { + json: true, + noInput: true, + question: "Which languages?", + derived: "myapp.languages", + sources: "github.repositories", + }, + { + resolveKey: () => appKey, + requests: storeWithApproval(), + createClient: gw.createClient, + createController, + }, + ); + expect(exitCode).toBe(7); + expect(seen.scopes).toEqual(["oura.sleep", "myapp.languages"]); + const outcome = appOutcomeSchema.parse(JSON.parse(stdout)); + expect(outcome.data).toMatchObject({ + kept: ["oura.sleep"], + grantUnion: { notCarried: ["github.repositories"] }, + }); + }); + + it("rejects an --owner that is not an address", async () => { + const exitCode = await runAppRequest( + { json: true, noInput: true, scopes: "a.b", owner: "alice" }, + { resolveKey: () => appKey, requests: store() }, + ); + expect(exitCode).toBe(2); + }); +}); From 669ee1ee343320572b022a4174af72d0f5952268 Mon Sep 17 00:00:00 2001 From: Volod Date: Wed, 7 Oct 2026 22:33:54 -0700 Subject: [PATCH 3/4] feat(app): use the SDK grant union and send removeScopes Bump @opendatalabs/vana-sdk to 4.3.1, which ships mergeWithLiveGrant and removeScopes on createAccessRequest. The local copy of the merge becomes a re-export plus the flag-worded conflict check, removeScopes goes on the request body as a typed field, and a malformed --remove-scopes entry is refused as bad usage before any call. Claude-Session: https://claude.ai/code/session_01Fcv6uEy4zcXaigzDNxeW3j --- package.json | 2 +- pnpm-lock.yaml | 16 ++-- src/cli/app/request.ts | 28 ++++-- src/core/grant-union.ts | 161 +++++------------------------------ test/cli/app-request.test.ts | 25 ++++++ 5 files changed, 78 insertions(+), 154 deletions(-) diff --git a/package.json b/package.json index d31cf08..614e10f 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,7 @@ "@inquirer/prompts": "8.3.0", "@inquirer/search": "^4.1.6", "@modelcontextprotocol/sdk": "^1.27.1", - "@opendatalabs/vana-sdk": "4.0.0", + "@opendatalabs/vana-sdk": "4.3.1", "@sigstore/bundle": "^5.0.0", "ajv": "^8.20.0", "chromium-bidi": "15.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fe62d0..197c030 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,8 +17,8 @@ importers: specifier: ^1.27.1 version: 1.27.1(zod@4.3.6) "@opendatalabs/vana-sdk": - specifier: 4.0.0 - version: 4.0.0(bufferutil@4.1.0)(react@19.2.4)(typescript@5.7.3)(utf-8-validate@5.0.10) + specifier: 4.3.1 + version: 4.3.1(bufferutil@4.1.0)(react@19.2.4)(typescript@5.7.3)(utf-8-validate@5.0.10) "@sigstore/bundle": specifier: ^5.0.0 version: 5.0.0 @@ -2583,10 +2583,10 @@ packages: integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==, } - "@opendatalabs/vana-sdk@4.0.0": + "@opendatalabs/vana-sdk@4.3.1": resolution: { - integrity: sha512-a53SwjfB+jDvKnB+AIyF2tM5YwqPQQdKVEekSYr93a4R1W/79Efs+9TuZtX3aC3rzUUYbuJXIZ9KzFrcW7Vjqg==, + integrity: sha512-IUc/MuQoeG01edhVDZKa3egeBDqWr9CK9Wz2enx51XazPXAQeuQrlj8ess2hTI4SgN/cVKbDQglFQ81Kx7dDOA==, } engines: { node: ">=22.0.0" } peerDependencies: @@ -14655,7 +14655,7 @@ snapshots: "@open-draft/until@2.1.0": {} - "@opendatalabs/vana-sdk@4.0.0(bufferutil@4.1.0)(react@19.2.4)(typescript@5.7.3)(utf-8-validate@5.0.10)": + "@opendatalabs/vana-sdk@4.3.1(bufferutil@4.1.0)(react@19.2.4)(typescript@5.7.3)(utf-8-validate@5.0.10)": dependencies: "@noble/hashes": 1.8.0 "@noble/secp256k1": 2.3.0 @@ -17259,14 +17259,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - "@vitest/mocker@4.0.18(msw@2.12.10(@types/node@20.19.33)(typescript@5.7.3))(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))": + "@vitest/mocker@4.0.18(msw@2.12.10(@types/node@20.19.33)(typescript@5.7.3))(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))": dependencies: "@vitest/spy": 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.10(@types/node@20.19.33)(typescript@5.7.3) - vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + vite: 7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) "@vitest/mocker@4.0.18(msw@2.12.10(@types/node@25.2.2)(typescript@5.7.3))(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))": dependencies: @@ -22561,7 +22561,7 @@ snapshots: vitest@4.0.18(@types/node@20.19.33)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.10(@types/node@20.19.33)(typescript@5.7.3))(yaml@2.8.2): dependencies: "@vitest/expect": 4.0.18 - "@vitest/mocker": 4.0.18(msw@2.12.10(@types/node@20.19.33)(typescript@5.7.3))(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) + "@vitest/mocker": 4.0.18(msw@2.12.10(@types/node@20.19.33)(typescript@5.7.3))(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) "@vitest/pretty-format": 4.0.18 "@vitest/runner": 4.0.18 "@vitest/snapshot": 4.0.18 diff --git a/src/cli/app/request.ts b/src/cli/app/request.ts index ed61c46..3f22278 100644 --- a/src/cli/app/request.ts +++ b/src/cli/app/request.ts @@ -15,7 +15,10 @@ import { createGatewayClient, type GatewayClient, } from "@opendatalabs/vana-sdk"; -import { createDirectDataController } from "@opendatalabs/vana-sdk/server"; +import { + createDirectDataController, + parseScopeEntry, +} from "@opendatalabs/vana-sdk/server"; import { AppKeyMissingError, resolveAppKey, @@ -34,6 +37,7 @@ import { } from "../../core/requests-store.js"; import { readAppProfile } from "../../core/app-profile.js"; import { + assertNoGrantUnionConflict, GrantUnionConflictError, mergeWithLiveGrant, unionGrantScopes, @@ -132,6 +136,7 @@ export async function planGrantUnion(input: { requests: RequestsStore; createClient: (gatewayUrl: string) => GrantUnionClient; }): Promise { + assertNoGrantUnionConflict(input.scopes, input.removeScopes); const requestedOnly = unionGrantScopes([], input.scopes, input.removeScopes); if (!input.merge) { return { status: "disabled", ...requestedOnly }; @@ -384,6 +389,19 @@ export async function runAppRequest( const profile = (deps.readProfile ?? readAppProfile)(key.address); const removeScopes = splitList(options.removeScopes); + for (const entry of removeScopes) { + try { + // Grammar only: a wildcard such as `chatgpt.*` is a valid removal. + parseScopeEntry(entry); + } catch (error) { + return emitAppOutcome(options, { + status: "failed", + code: "bad_usage", + message: `--remove-scopes ${entry}: ${error instanceof Error ? error.message : String(error)}`, + network: network.name, + }); + } + } if (options.owner && !ADDRESS.test(options.owner)) { return emitAppOutcome(options, { status: "failed", @@ -454,12 +472,10 @@ export async function runAppRequest( let created; try { // `removeScopes` tells the approval page to leave those entries out of - // the union it signs. SDK 4.0.0 drops the field; it is sent from the - // SDK release that adds it (vana-sdk#215). - const extra: Record = - removeScopes.length > 0 ? { removeScopes } : {}; + // the union it signs. No `owner` is passed: the union above is already + // merged into the controller's scopes, so the controller sends them as is. created = await controller.createAccessRequest({ - ...extra, + ...(removeScopes.length > 0 ? { removeScopes } : {}), returnUrl: options.returnUrl ?? "https://github.com/vana-com/vana-cli", ...(options.question && options.derived ? { diff --git a/src/core/grant-union.ts b/src/core/grant-union.ts index c8c096e..9f6000f 100644 --- a/src/core/grant-union.ts +++ b/src/core/grant-union.ts @@ -6,54 +6,21 @@ * that holds `oura.sleep` would leave it with `whoop.recovery` alone, so a * request has to carry what the grant already covers. * - * Local copy of `mergeWithLiveGrant` / `unionGrantScopes` from - * `@opendatalabs/vana-sdk` (added in vana-sdk#215, not released yet; this - * package pins 4.0.0). Same names, inputs and outputs, so the swap is an - * import change once the SDK ships it. + * The merge itself lives in `@opendatalabs/vana-sdk` (`mergeWithLiveGrant`, + * `unionGrantScopes`). This module only adds the CLI's flag-worded + * requested/removed conflict check, run before any network call. */ -import type { GatewayClient } from "@opendatalabs/vana-sdk"; -import { parseScope, parseScopeEntry } from "@opendatalabs/vana-sdk/server"; - -type GrantListItem = Awaited< - ReturnType ->[number]; - -/** The slice of the gateway client the helpers read through. */ -export type GrantUnionGateway = Pick & - Partial>; - -export interface MergeWithLiveGrantInput { - gateway: GrantUnionGateway; - /** The data owner (grantor) whose live grant to extend. */ - owner: string; - /** The app's bytes32 builder id; or pass `appAddress`. */ - granteeId?: string; - /** Resolved to `granteeId` through `gateway.getBuilder` when needed. */ - appAddress?: string; - scopes: readonly string[]; - /** Live entries the app gives up, matched verbatim. */ - removeScopes?: readonly string[]; -} - -export interface GrantUnion { - /** What to send on the new request. */ - scopes: string[]; - /** Live entries carried over. */ - kept: string[]; - /** Requested entries the live grant did not cover. */ - added: string[]; - /** Live entries dropped because they were in `removeScopes`. */ - removed: string[]; - /** Live entries a request cannot carry (wildcards, unknown operations). */ - notCarried: string[]; - liveScopes: string[]; -} - -export interface MergeWithLiveGrantResult extends GrantUnion { - status: "merged" | "no_live_grant"; - grantId?: string; -} +export { + findLiveGrant, + liveGrantScopes, + mergeWithLiveGrant, + unionGrantScopes, + type GrantUnion, + type GrantUnionGateway, + type MergeWithLiveGrantInput, + type MergeWithLiveGrantResult, +} from "@opendatalabs/vana-sdk/server"; export class GrantUnionConflictError extends Error { constructor(readonly conflicting: string[]) { @@ -64,102 +31,18 @@ export class GrantUnionConflictError extends Error { } } -// A concrete scope with a known operation prefix: what a data connection -// request accepts. Wildcards (`chatgpt.*`) and unknown operations are not. -function isCarryable(entry: string): boolean { - try { - parseScope(parseScopeEntry(entry).scope); - return true; - } catch { - return false; - } -} - -const dedupe = (entries: readonly string[]) => [...new Set(entries)]; - -export function unionGrantScopes( - liveScopes: readonly string[], +/** + * Refuse an entry that is both requested and removed, worded for the CLI + * flags. The SDK makes the same check, but its message names its own + * option names. + */ +export function assertNoGrantUnionConflict( scopes: readonly string[], - removeScopes: readonly string[] = [], -): GrantUnion { + removeScopes: readonly string[], +): void { const remove = new Set(removeScopes); - const conflicting = scopes.filter((entry) => remove.has(entry)); + const conflicting = [...new Set(scopes.filter((entry) => remove.has(entry)))]; if (conflicting.length > 0) { throw new GrantUnionConflictError(conflicting); } - const live = dedupe(liveScopes); - const liveSet = new Set(live); - const requested = dedupe(scopes); - const remaining = live.filter((entry) => !remove.has(entry)); - const kept = remaining.filter((entry) => isCarryable(entry)); - const added = requested.filter((entry) => !liveSet.has(entry)); - return { - scopes: dedupe([...kept, ...added]), - kept, - added, - removed: live.filter((entry) => remove.has(entry)), - notCarried: remaining.filter((entry) => !isCarryable(entry)), - liveScopes: live, - }; -} - -/** The owner's active grant for one app, newest version first. */ -export async function findLiveGrant( - gateway: Pick, - owner: string, - granteeId: string, -): Promise { - const grants = await gateway.listGrantsByUser(owner); - const matching = grants - .filter( - (grant) => - grant.granteeId.toLowerCase() === granteeId.toLowerCase() && - !grant.revokedAt && - !grant.expired, - ) - .sort((a, b) => { - const av = BigInt(a.grantVersion || "0"); - const bv = BigInt(b.grantVersion || "0"); - return av === bv ? 0 : av > bv ? -1 : 1; - }); - return matching[0] ?? null; -} - -export async function liveGrantScopes( - gateway: Pick, - owner: string, - granteeId: string, -): Promise { - const grant = await findLiveGrant(gateway, owner, granteeId); - return grant ? [...grant.scopes] : []; -} - -export async function mergeWithLiveGrant( - input: MergeWithLiveGrantInput, -): Promise { - const removeScopes = input.removeScopes ?? []; - unionGrantScopes([], input.scopes, removeScopes); - let granteeId = input.granteeId ?? null; - if (!granteeId) { - if (!input.appAddress || !input.gateway.getBuilder) { - throw new Error( - "mergeWithLiveGrant needs granteeId, or appAddress and gateway.getBuilder.", - ); - } - granteeId = (await input.gateway.getBuilder(input.appAddress))?.id ?? null; - } - const grant = granteeId - ? await findLiveGrant(input.gateway, input.owner, granteeId) - : null; - if (!grant) { - return { - status: "no_live_grant", - ...unionGrantScopes([], input.scopes, removeScopes), - }; - } - return { - status: "merged", - grantId: grant.id, - ...unionGrantScopes(grant.scopes, input.scopes, removeScopes), - }; } diff --git a/test/cli/app-request.test.ts b/test/cli/app-request.test.ts index 437cdd9..1a5109a 100644 --- a/test/cli/app-request.test.ts +++ b/test/cli/app-request.test.ts @@ -898,6 +898,31 @@ describe("vana app request - extending the live grant", () => { ); }); + it("refuses a malformed --remove-scopes entry before any call", async () => { + const exitCode = await runAppRequest( + { + json: true, + noInput: true, + scopes: "whoop.recovery", + removeScopes: "delete:oura.sleep", + }, + { + resolveKey: () => appKey, + requests: store(), + createClient: () => { + throw new Error("must not read the gateway"); + }, + createController: () => { + throw new Error("must not create a request"); + }, + }, + ); + expect(exitCode).toBe(2); + expect(appOutcomeSchema.parse(JSON.parse(stdout)).message).toContain( + "--remove-scopes delete:oura.sleep", + ); + }); + it("leaves a live raw read of a question's source out of the request", async () => { // The service refuses a source that is also a raw read on the request. const gw = liveGateway(["github.repositories", "oura.sleep"]); From 0a819d6604d09b372a0f68a1045f6ff9a1759d20 Mon Sep 17 00:00:00 2001 From: Volod Date: Wed, 7 Oct 2026 22:34:05 -0700 Subject: [PATCH 4/4] fix(app): never let an enclave read sign a payment Since vana-sdk 4.2.0 the jobs client signs a quoted price on its own when the gateway answers 402. Pass maxPrice "0" so a charged enclave read stops before anything is signed and exits 4 as payment_required, as the CLI promised before the bump. Claude-Session: https://claude.ai/code/session_01Fcv6uEy4zcXaigzDNxeW3j --- src/cli/app/read.ts | 21 +++++++++++++++++++ test/cli/app-read-loop.test.ts | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/cli/app/read.ts b/src/cli/app/read.ts index 51e0bbd..a0cd08b 100644 --- a/src/cli/app/read.ts +++ b/src/cli/app/read.ts @@ -473,6 +473,10 @@ function emitReadSuccess( * than implying the read was free by protocol design. When the gateway * starts quoting a price, this is where the escrow path plugs in. * + * Since vana-sdk 4.2.0 the jobs client signs a quoted price on its own when + * the gateway answers 402. `maxPrice: "0"` keeps this leg from spending: + * a charged read stops before anything is signed, as `payment_required`. + * * Waking a cold sandbox takes seconds, so the inline wait is used and a * timeout is `not_ready` (exit 6) rather than a failure. */ @@ -499,6 +503,7 @@ async function runEnclaveRead( grantId: grantId as `0x${string}`, scope, wait: MAX_INLINE_WAIT_SECONDS, + maxPrice: "0", }); const text = new TextDecoder().decode(result.body); @@ -519,6 +524,22 @@ async function runEnclaveRead( server: "gateway job queue", }); } catch (error) { + if (isPaymentRequired(error)) { + const details = error.details ?? {}; + return emitAppOutcome(options, { + status: "failed", + code: "payment_required", + message: + "This enclave read is charged, and paying for enclave reads is not supported by this CLI yet. Nothing was signed or spent.", + network: network.name, + data: { + delivery: "enclave", + owner, + amount: details.amount ?? null, + asset: details.asset ?? null, + }, + }); + } return emitAppOutcome(options, { status: "failed", code: enclaveErrorCode(error), diff --git a/test/cli/app-read-loop.test.ts b/test/cli/app-read-loop.test.ts index b2a437c..a9fd6d7 100644 --- a/test/cli/app-read-loop.test.ts +++ b/test/cli/app-read-loop.test.ts @@ -539,6 +539,9 @@ describe("vana app read, enclave delivery", () => { grantId: GRANT, scope: "github.repos", chainId: 14800, + // The jobs client signs a quoted price on its own; the CLI never lets + // an enclave read spend. + maxPrice: "0", }); const outcome = appOutcomeSchema.parse(JSON.parse(stdout)); expect(outcome.data).toMatchObject({ @@ -573,6 +576,41 @@ describe("vana app read, enclave delivery", () => { expect(outcome.remedy).toContain("Personal Server setup"); }); + it("stops a charged enclave read at exit 4 without signing a payment", async () => { + const exitCode = await runAppRead( + "github.repos", + { json: true, grant: GRANT, pay: true }, + { + resolveKey: () => appKey, + requests: enclaveRequests(), + receipts: store(), + createClient: () => grantClient(), + createJobs: (() => ({ + readRaw: async () => { + const error = Object.assign( + new Error( + "The quoted price is above the maxPrice you set for this read", + ), + { + name: "PaymentRequiredError", + details: { amount: "1000", asset: "0xasset", maxPrice: "0" }, + }, + ); + throw error; + }, + })) as never, + }, + ); + expect(exitCode).toBe(4); + const outcome = appOutcomeSchema.parse(JSON.parse(stdout)); + expect(outcome.code).toBe("payment_required"); + expect(outcome.data).toMatchObject({ + delivery: "enclave", + amount: "1000", + asset: "0xasset", + }); + }); + it("treats a waking sandbox as not ready, worth retrying", async () => { const exitCode = await runAppRead( "github.repos",