Skill Tracer
Skills › gstack › browse › skill-token.ts

skill-token.ts

91 lines · browse/src/skill-token.ts at 3f79d81

1/**
2 * Skill-token — scoped tokens minted per `$B skill run` invocation.
3 *
4 * Why this exists:
5 * When `$B skill run <name>` spawns a browser-skill script, the script needs
6 * to call back into the daemon over loopback HTTP. It MUST NOT receive the
7 * daemon root token — a script that gets the root token can call any endpoint
8 * with full authority, defeating the trusted/untrusted distinction.
9 *
10 * This module wraps `token-registry.ts` to mint per-spawn session tokens
11 * bound to read+write scope (the 17-cmd browser-driving surface, minus the
12 * `eval`/`js`/admin commands that live in the admin scope). The token's
13 * clientId encodes the skill name and spawn id, so revocation is
14 * deterministic when the script exits or times out.
15 *
16 * Lifecycle:
17 * spawn start → mintSkillToken() → set GSTACK_SKILL_TOKEN in child env
18 * ↓
19 * script makes HTTP calls /command with Bearer <skill-token>
20 * ↓
21 * spawn exit / timeout → revokeSkillToken() → token invalidated
22 *
23 * Why scopes = ['read', 'write']:
24 * These map to SCOPE_READ + SCOPE_WRITE in token-registry.ts and cover
25 * navigation, reading, and interaction commands the bulk of skills need.
26 * Excludes admin (eval/js/cookies/storage) deliberately — agent-authored
27 * skills should not get arbitrary JS execution. Phase 2 may add an opt-in
28 * `admin: true` frontmatter flag for cases that genuinely need it, gated
29 * by stronger review at skillify time.
30 *
31 * Zero side effects on import. Safe to import from tests.
32 */
34import * as crypto from 'crypto';
35import { createToken, revokeToken, type ScopeCategory, type TokenInfo } from './token-registry';
37/** Length of TTL slack (in seconds) past the spawn timeout. */
38const TOKEN_TTL_SLACK = 30;
40/** Default scopes for skill tokens. Excludes `admin` (eval/js) and `control`. */
41const DEFAULT_SKILL_SCOPES: ScopeCategory[] = ['read', 'write'];
43/** Generate a fresh spawn id. Caller passes this to spawn AND revoke. */
44export function generateSpawnId(): string {
45 return crypto.randomBytes(8).toString('hex');
48/** Build the canonical clientId for a skill spawn. */
49export function skillClientId(skillName: string, spawnId: string): string {
50 return `skill:${skillName}:${spawnId}`;
53export interface MintSkillTokenOptions {
54 skillName: string;
55 spawnId: string;
56 /** Spawn timeout in seconds. Token TTL = timeout + 30s slack. */
57 spawnTimeoutSeconds: number;
58 /**
59 * Override the default scopes. Phase 1 callers should not pass this; reserved
60 * for future opt-in flags (e.g. an `admin: true` frontmatter for trusted
61 * human-authored skills that need eval/js).
62 */
63 scopes?: ScopeCategory[];
66/**
67 * Mint a fresh scoped token for a skill spawn.
68 *
69 * Returns the token info; the caller passes `info.token` to the child via the
70 * GSTACK_SKILL_TOKEN env var. The clientId is deterministic from skillName +
71 * spawnId so the corresponding `revokeSkillToken()` always finds the right
72 * record.
73 */
74export function mintSkillToken(opts: MintSkillTokenOptions): TokenInfo {
75 const clientId = skillClientId(opts.skillName, opts.spawnId);
76 return createToken({
77 clientId,
78 scopes: opts.scopes ?? DEFAULT_SKILL_SCOPES,
79 tabPolicy: 'shared', // skill scripts may switch tabs as needed
80 rateLimit: 0, // skill scripts can run as fast as the daemon allows
81 expiresSeconds: opts.spawnTimeoutSeconds + TOKEN_TTL_SLACK,
82 });
85/**
86 * Revoke the token for a finished spawn. Idempotent — revoking an already-revoked
87 * token returns false but is not an error.
88 */
89export function revokeSkillToken(skillName: string, spawnId: string): boolean {
90 return Boolean(revokeToken(skillClientId(skillName, spawnId)));