Skip to content

Commit 0a7c784

Browse files
authored
fix(server,app): own the tunnel client, clearer login and npm errors, stale grants (#85)
* fix(server,app): own the tunnel client, clearer login and npm errors, stale grants - The CLI now owns frpc's lifetime for its data dir: a server killed hard left its frpc running (ppid 1) through restarts and `vana server stop`. The supervisor stops any frpc on <dataDir>/tunnel/frpc.toml when the server exits and before every (re)start; `vana server stop` stops it too. - `vana login` says "Your Personal Server is not running" when this account has a data dir here or a registration at the gateway, and keeps "No Personal Server found" for new accounts. - Installing the server runtime falls back to an npm on PATH when the one beside Node is missing or broken, and otherwise explains how to fix it (exit 6) instead of crashing with `spawn .../npm ENOENT`. - `vana app requests show|list` check each grant at the gateway and report revoked or replaced grants with live: false and a new `vana app request` as next; an unreachable gateway is reported as unverified. Claude-Session: https://claude.ai/code/session_01Fcv6uEy4zcXaigzDNxeW3j * test(server): kill the supervised server only after it is ready The real-process supervisor tests sent kill -9 as soon as the fake server wrote its pid file, which it does just before its ready line. On a busy runner the kill could land in between: a server that dies before it is ready is a failed first start, which is never restarted, so the test waited 10s for a restart that could not come. Wait for server-ready too. Also bound the ps that a restart waits on. Claude-Session: https://claude.ai/code/session_01Fcv6uEy4zcXaigzDNxeW3j
1 parent c8599a6 commit 0a7c784

14 files changed

Lines changed: 1294 additions & 58 deletions

File tree

‎AGENTS.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,11 @@ pnpm cli -- <args> # run the built CLI
110110
`server-failed` / `restart-limit` event. It holds the data dir's lock
111111
across restarts, so `vana server stop` (SIGTERM to the lock's pid) always
112112
finds it and nothing restarts after a stop. It does not survive a reboot:
113-
never add a LaunchAgent, endpoint security flags those.
113+
never add a LaunchAgent, endpoint security flags those. A server killed
114+
hard leaves its frpc running (reparented to init); the CLI owns that
115+
frpc's lifetime, matched by `-c <dataDir>/tunnel/frpc.toml`, and stops it
116+
when the server exits, before every (re)start and on `vana server stop`
117+
(`src/personal-server/local/tunnel-process.ts`).
114118
- **Another account's server is never this account's.** `status` and
115119
`server status` list it under "Also running"; `connect`, `collect` and
116120
`server sync` refuse to write into it under `--no-input`, `--yes`, `--ipc`

‎skills/agent-access/SKILL.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,9 @@ vana app requests show <request-id> --json
251251
`<request-id>` is `data.requestId` from the request. `requests show`
252252
exits 0 either way: until the owner approves, `data.status` is `pending`
253253
and `data.grantId` is null. Once approved, `data.grantId` is the grant to
254-
read with.
254+
read with. If the owner later revokes it, or a newer approval for the same
255+
app replaces its scopes, `data.live` turns false, `data.grant.state` says
256+
`revoked` or `replaced`, and `next` is a fresh `vana app request`.
255257

256258
Keep requests to sources the Vana web app lists. A request that includes
257259
a source it doesn't list cannot be approved on the web, and data from

‎skills/builder/SKILL.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,10 @@ vana app requests show dcr_... --json # did they approve yet?
7878
```
7979

8080
`show` refreshes from the service and writes the result down, so once it
81-
reports a `grantId` that id stays available offline.
81+
reports a `grantId` that id stays available offline. Both commands check
82+
each grant at the gateway: `data.grant.state` is `revoked` or `replaced`
83+
(one grant per owner and app, so a later approval replaces its scopes) when
84+
it no longer holds, and `unverified` when the gateway did not answer.
8285

8386
## Asking a question instead of reading raw data
8487

‎src/cli/app/requests.ts‎

Lines changed: 165 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,21 @@
22
* `vana app requests list|show <id>` - what this machine asked for, and
33
* what came back.
44
*
5-
* `list` reads the local index alone, so it works offline and is the way an
5+
* `list` reads the local index, so it works offline and is the way an
66
* agent recovers a request id it did not keep. `show` merges the local
77
* entry with the live status and writes back what changed, which is how a
88
* `--no-input` request eventually learns its grant id.
9+
*
10+
* An approval is not forever: the owner can revoke the grant, and a later
11+
* approval for the same app replaces its scopes (one grant per owner and
12+
* app). Both commands check every grant id against the gateway, and say
13+
* when it no longer holds, or that it could not be checked.
914
*/
1015

16+
import {
17+
createGatewayClient,
18+
type GatewayClient,
19+
} from "@opendatalabs/vana-sdk";
1120
import { createDirectDataController } from "@opendatalabs/vana-sdk/server";
1221
import {
1322
AppKeyMissingError,
@@ -31,6 +40,87 @@ export interface RequestsDeps {
3140
resolveKey?: typeof resolveAppKey;
3241
createController?: typeof createDirectDataController;
3342
requests?: RequestsStore;
43+
createClient?: (gatewayUrl: string) => Pick<GatewayClient, "getGrant">;
44+
}
45+
46+
/** Whether a request's grant still gives the app what it asked for. */
47+
export type GrantCheck =
48+
| { state: "active" }
49+
| {
50+
state: "revoked" | "replaced" | "expired" | "missing";
51+
reason: string;
52+
revokedAt?: string;
53+
grantScopes?: string[];
54+
}
55+
| { state: "unverified"; reason: string };
56+
57+
const GRANT_LOOKUP_TIMEOUT_MS = 5_000;
58+
59+
const bareScope = (scope: string) => scope.replace(/^write:/, "");
60+
61+
/**
62+
* Ask the gateway (public, no key needed) whether `grantId` still covers
63+
* `scopes`. Never throws: a gateway that cannot be reached is "unverified".
64+
*/
65+
export async function checkGrant(
66+
client: Pick<GatewayClient, "getGrant">,
67+
grantId: string,
68+
scopes: string[],
69+
): Promise<GrantCheck> {
70+
let timer: NodeJS.Timeout | undefined;
71+
try {
72+
const grant = await Promise.race([
73+
client.getGrant(grantId),
74+
new Promise<never>((_, reject) => {
75+
timer = setTimeout(
76+
() => reject(new Error("timed out")),
77+
GRANT_LOOKUP_TIMEOUT_MS,
78+
);
79+
}),
80+
]);
81+
if (!grant) {
82+
return {
83+
state: "missing",
84+
reason: `the gateway has no grant ${grantId}`,
85+
};
86+
}
87+
if (grant.revokedAt) {
88+
return {
89+
state: "revoked",
90+
reason: `revoked by the owner at ${grant.revokedAt}`,
91+
revokedAt: grant.revokedAt,
92+
};
93+
}
94+
const granted = new Set(grant.scopes.map(bareScope));
95+
const lost = scopes.filter((scope) => !granted.has(bareScope(scope)));
96+
if (lost.length) {
97+
return {
98+
state: "replaced",
99+
reason: `replaced by a later approval: the grant now covers ${grant.scopes.join(", ") || "nothing"}, not ${lost.join(", ")}`,
100+
grantScopes: grant.scopes,
101+
};
102+
}
103+
if (grant.expired) {
104+
return { state: "expired", reason: "the grant expired" };
105+
}
106+
return { state: "active" };
107+
} catch (error) {
108+
return {
109+
state: "unverified",
110+
reason: `the gateway was not reached (${error instanceof Error ? error.message : String(error)})`,
111+
};
112+
} finally {
113+
if (timer) clearTimeout(timer);
114+
}
115+
}
116+
117+
/** False once the grant is known to be gone; unverified keeps the record's word. */
118+
function grantHolds(check: GrantCheck | null): boolean {
119+
return !check || check.state === "active" || check.state === "unverified";
120+
}
121+
122+
function newRequestRemedy(scopes: string[]): string {
123+
return `vana app request --scopes ${scopes.join(",")}`;
34124
}
35125

36126
function resolveNetworkOrFail(
@@ -78,26 +168,57 @@ export async function runAppRequestsList(
78168
appAddress,
79169
network: network.name,
80170
});
171+
const withGrant = entries.filter((entry) => entry.grantId);
172+
const client = withGrant.length
173+
? (deps.createClient ?? createGatewayClient)(network.gatewayUrl)
174+
: null;
175+
const checks = new Map<string, GrantCheck>();
176+
await Promise.all(
177+
withGrant.map(async (entry) => {
178+
checks.set(
179+
entry.requestId,
180+
await checkGrant(
181+
client!,
182+
entry.grantId!,
183+
entry.approvedScopes ?? entry.scopes,
184+
),
185+
);
186+
}),
187+
);
188+
const gone = [...checks.values()].filter((check) => !grantHolds(check));
81189

82190
return emitAppOutcome(options, {
83191
status: "done",
84192
code: "ok",
85193
message:
86194
entries.length === 0
87195
? "No access requests from this machine yet."
88-
: `${entries.length} access request${entries.length === 1 ? "" : "s"}.`,
196+
: `${entries.length} access request${entries.length === 1 ? "" : "s"}${gone.length ? `; ${gone.length} grant${gone.length === 1 ? " is" : "s are"} no longer in force` : ""}.`,
89197
remedy:
90-
entries.length === 0 ? "vana app request --scopes <scope>" : undefined,
198+
entries.length === 0
199+
? "vana app request --scopes <scope>"
200+
: gone.length
201+
? "vana app requests show <id>"
202+
: undefined,
91203
network: network.name,
92204
data: {
93205
count: entries.length,
94-
requests: entries.map((entry) => ({
95-
requestId: entry.requestId,
96-
status: entry.status,
97-
scopes: entry.scopes,
98-
grantId: entry.grantId ?? null,
99-
createdAt: entry.createdAt,
100-
})),
206+
requests: entries.map((entry) => {
207+
const check = checks.get(entry.requestId) ?? null;
208+
return {
209+
requestId: entry.requestId,
210+
status: entry.status,
211+
scopes: entry.scopes,
212+
grantId: entry.grantId ?? null,
213+
createdAt: entry.createdAt,
214+
...(check
215+
? {
216+
live: grantHolds(check),
217+
grant: check,
218+
}
219+
: {}),
220+
};
221+
}),
101222
},
102223
});
103224
}
@@ -169,7 +290,11 @@ export async function runAppRequestsShow(
169290
requests.update(requestId, {
170291
status: live.status as StoredRequestStatus,
171292
...(live.grantId ? { grantId: live.grantId } : {}),
172-
...(live.scopes ? { approvedScopes: live.scopes } : {}),
293+
// What was approved is fixed at approval; never let a later read of
294+
// the status overwrite it, or a replaced grant would look covered.
295+
...(live.scopes && !stored.approvedScopes
296+
? { approvedScopes: live.scopes }
297+
: {}),
173298
...(live.delivery ? { delivery: live.delivery } : {}),
174299
...(live.personalServerUrl
175300
? { personalServerUrl: live.personalServerUrl }
@@ -186,20 +311,42 @@ export async function runAppRequestsShow(
186311
?.map((question) => question.derivedScope)
187312
.find((scope) => scopes.includes(scope)) ?? scopes[0];
188313

314+
const grant = grantId
315+
? await checkGrant(
316+
(deps.createClient ?? createGatewayClient)(network.gatewayUrl),
317+
grantId,
318+
stored.approvedScopes ?? scopes,
319+
)
320+
: null;
321+
const holds = grantHolds(grant);
322+
const note = !live
323+
? " (local record, service not reached)"
324+
: grant?.state === "unverified"
325+
? ` (grant not verified: ${grant.reason})`
326+
: "";
327+
189328
return emitAppOutcome(options, {
190329
status: "done",
191330
code: "ok",
192-
message: `${requestId}: ${status}${live ? "" : " (local record, service not reached)"}.`,
193-
remedy: grantId
194-
? `vana app read ${nextScope} --grant ${grantId}`
195-
: status === "pending"
196-
? `still waiting; approve at ${stored.approvalUrl}`
197-
: undefined,
331+
message:
332+
grant && grant.state !== "active" && !holds
333+
? `${requestId}: ${status}, but the grant is no longer in force: ${grant.reason}.`
334+
: `${requestId}: ${status}${note}.`,
335+
remedy: !holds
336+
? newRequestRemedy(stored.scopes)
337+
: grantId
338+
? `vana app read ${nextScope} --grant ${grantId}`
339+
: status === "pending"
340+
? `still waiting; approve at ${stored.approvalUrl}`
341+
: undefined,
198342
network: network.name,
199343
data: {
200344
requestId,
201345
status,
202-
live: Boolean(live),
346+
// The service answered, and the grant, when there is one, has not
347+
// been revoked or replaced. An unverified grant keeps the record's word.
348+
live: Boolean(live) && holds,
349+
...(grant ? { grant } : {}),
203350
scopes,
204351
requestedScopes: stored.scopes,
205352
grantId,

0 commit comments

Comments
 (0)