agent-yes 1.236.0 → 1.237.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{JsonlStore-CWOA3aDH.js → JsonlStore-CPGIR0Wy.js} +1 -1
- package/dist/SUPPORTED_CLIS-C0RwjYrz.js +11 -0
- package/dist/{SUPPORTED_CLIS-C7bkkXue.js → SUPPORTED_CLIS-CDrJz9Xo.js} +2 -2
- package/dist/{agent-yes.config-BhjzWavn.js → agent-yes.config-DPtMM0S-.js} +2 -2
- package/dist/{agentShare-BxAaZ-UR.js → agentShare-Cl_J3dfx.js} +9 -9
- package/dist/{agentYesHome-CtHb5b71.js → agentYesHome-CtmsY6I-.js} +1 -1
- package/dist/browser-B3dOptjL.js +311 -0
- package/dist/browser-BRMyG4JA.js +13514 -0
- package/dist/callback-CFbkvqTh.js +12 -0
- package/dist/{callback-B1AWp2tC.js → callback-D0_AeyN1.js} +6 -6
- package/dist/{callbackCore-CcbVa8r2.js → callbackCore-CvmGSvrj.js} +1 -1
- package/dist/{callbackCore-Me97qn68.js → callbackCore-DAYcemCC.js} +1 -1
- package/dist/{channels-Lsot9pyU.js → channels-BiGUYTuz.js} +2 -2
- package/dist/{channels-DHXLnU4r.js → channels-CMNMFJlY.js} +6 -6
- package/dist/channels.js +6 -310
- package/dist/chunk-cZBX9HZv.js +20 -0
- package/dist/cli.js +11 -11
- package/dist/{configShared-BXz2vi4Q.js → configShared-9wVlbPyC.js} +1 -1
- package/dist/{cwdConflictWarn-ArYYtOGy.js → cwdConflictWarn-DWHWlBxS.js} +2 -2
- package/dist/{cwdDeprecation-BGX20-Fe.js → cwdDeprecation-Cd8Ykm2t.js} +1 -1
- package/dist/{e2e-CYlKpfxa.js → e2e-CEIdJGZ7.js} +1 -1
- package/dist/{expose-ByT4ef7C.js → expose-DROTwbyU.js} +1 -1
- package/dist/{forkNested-Bai0Q7BL.js → forkNested-DqPisz9d.js} +2 -2
- package/dist/{globalPidIndex-Ca4sTr1b.js → globalPidIndex-D9DOSd-c.js} +1 -1
- package/dist/index.js +7 -7
- package/dist/{messageLog-zVGWLX7w.js → messageLog-C-YIzhRj.js} +1 -1
- package/dist/{nodeRuntime-fVRbz6w6.js → nodeRuntime-Bf88NPqj.js} +2 -2
- package/dist/{notifyDaemon-Cxb0-5a0.js → notifyDaemon-D_8TRVHt.js} +8 -8
- package/dist/{openBrowser-DL-EO8Hi.js → openBrowser-ChLcPELv.js} +1 -1
- package/dist/{peer-68ZwnxLs.js → peer-B5JKF4BP.js} +3 -3
- package/dist/peer-D5lr1Afv.js +4 -0
- package/dist/{pidStore-Hjba3K6Q.js → pidStore-6EZd5DUv.js} +4 -4
- package/dist/pidStore-bCVS2Ldt.js +6 -0
- package/dist/{reaper-iQXyufgp.js → reaper-DP49VCKx.js} +2 -2
- package/dist/{reaper-pJpMYKmj.js → reaper-IhkB_RO0.js} +1 -1
- package/dist/{remotes-CF1LJLxY.js → remotes-BClMgQZW.js} +3 -3
- package/dist/{remotes-BDdfhL6N.js → remotes-Bem6WIyU.js} +3 -3
- package/dist/{runningLock-BFk3Mo8K.js → runningLock-DBoONoM-.js} +1 -1
- package/dist/{rustBinary-CFSG7it2.js → rustBinary-Bb9qJZ5w.js} +2 -2
- package/dist/{schedule-CXHdiUHa.js → schedule-B8Erb3y6.js} +11 -11
- package/dist/{serve-DEEwIlVV.js → serve-QejbNTr-.js} +187 -43
- package/dist/{setup-QfDtv5tw.js → setup-Dzi5Cc3g.js} +3 -3
- package/dist/{share-CDER9qNq.js → share--aHaPSjt.js} +2 -2
- package/dist/{share-D4K0JnHg.js → share-B9u8-I3y.js} +2 -2
- package/dist/{spawnGate-PwzImpOw.js → spawnGate-Bo_LFgt9.js} +3 -3
- package/dist/spawnGate-CuWCd2im.js +5 -0
- package/dist/{store-BXJERNbW.js → store-Ck0XRn-K.js} +1 -1
- package/dist/{subcommands-_Ci82sb1.js → subcommands-DFr-5FOL.js} +26 -21
- package/dist/{subcommands-m2DRpvHt.js → subcommands-ymJWO2iv.js} +7 -7
- package/dist/terminal-vNeFNLT8.js +138 -0
- package/dist/terminal.js +3 -0
- package/dist/{todoAutomation-x6guaQXc.js → todoAutomation-Buo457VO.js} +2 -2
- package/dist/{todoCli-CAMVVz9u.js → todoCli-CNNR9Czu.js} +4 -4
- package/dist/{tray-CF8xRQqt.js → tray-D5qFaqlS.js} +2 -2
- package/dist/{ts-D1FDmhc-.js → ts-Dd1FK7sR.js} +11 -28
- package/dist/{versionChecker-DJEtU1JU.js → versionChecker-XfEBINhH.js} +2 -2
- package/dist/{webrtcLink-CEYNs0pt.js → webrtcLink-VQhWmKOw.js} +2 -2
- package/dist/{webrtcRemote-CNC_bMH6.js → webrtcRemote-DJDtCTpj.js} +3 -3
- package/dist/widgets.js +7 -0
- package/dist/{workspaceConfig-CuGjX2_R.js → workspaceConfig-B7HaQ-MI.js} +2 -2
- package/dist/{ws-C7B4xWQl.js → ws-Dun8gGiV.js} +9 -9
- package/package.json +13 -1
- package/ts/serve.ts +144 -5
- package/ts/subcommands.ts +6 -0
- package/ts/termToken.spec.ts +44 -0
- package/ts/termToken.ts +85 -0
- package/ts/terminal/browser.ts +607 -0
- package/ts/terminal.spec.ts +50 -0
- package/ts/terminal.ts +245 -0
- package/ts/widgets.ts +14 -0
- package/dist/SUPPORTED_CLIS-BDXbzEWF.js +0 -11
- package/dist/callback-Dtk0wMsl.js +0 -12
- package/dist/peer-CX4qQx5U.js +0 -4
- package/dist/pidStore-Cp3HekUh.js +0 -6
- package/dist/spawnGate-C21TL4f2.js +0 -5
package/ts/serve.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
} from "./subcommands.ts";
|
|
40
40
|
import { answerAsk, listAsks } from "./askApi.ts";
|
|
41
41
|
import { TYPING_BADGE } from "./badges.ts";
|
|
42
|
+
import { isTermToken, mintTermToken, verifyTermToken, type TermScope } from "./termToken.ts";
|
|
42
43
|
import { isCallbackRevoked, loadCallbackSecretReadOnly } from "./callback.ts";
|
|
43
44
|
import { CLAUDE_SESSION_PIN_ENV } from "./sessionEnv.ts";
|
|
44
45
|
import { MAX_CALLBACK_MSG_BYTES, frameVisitorMessage, verifyCapability } from "./callbackCore.ts";
|
|
@@ -217,6 +218,122 @@ function checkAuth(req: Request, expectedToken: string): boolean {
|
|
|
217
218
|
return q ? tokenEqual(q, expectedToken) : false;
|
|
218
219
|
}
|
|
219
220
|
|
|
221
|
+
// Authorization result: the master token grants everything (as before); a scoped
|
|
222
|
+
// terminal-embed token (termToken.ts) grants a NARROW capability confined to one
|
|
223
|
+
// agent by scopedGate() below.
|
|
224
|
+
type AuthResult = { kind: "master" } | { kind: "scoped"; scope: TermScope };
|
|
225
|
+
|
|
226
|
+
function authorizeRequest(req: Request, expectedToken: string): AuthResult | null {
|
|
227
|
+
const auth = req.headers.get("authorization") ?? "";
|
|
228
|
+
const bearer = auth.startsWith("Bearer ") ? auth.slice(7) : null;
|
|
229
|
+
const provided = bearer ?? new URL(req.url).searchParams.get("token") ?? "";
|
|
230
|
+
if (!provided) return null;
|
|
231
|
+
if (tokenEqual(provided, expectedToken)) return { kind: "master" };
|
|
232
|
+
// A scoped token is verified against the master token (stateless HMAC).
|
|
233
|
+
if (isTermToken(provided)) {
|
|
234
|
+
const scope = verifyTermToken(expectedToken, provided);
|
|
235
|
+
if (scope) return { kind: "scoped", scope };
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The embeddable terminal routes. Token-gated (master OR scoped) but CORS-open so
|
|
241
|
+
// a cross-origin report page can reach the daemon — the token is still required to
|
|
242
|
+
// get a 200, and no cookies are involved, so `*` leaks nothing.
|
|
243
|
+
function isTermCorsPath(p: string): boolean {
|
|
244
|
+
return /^\/api\/(tail|size)\/.+$/.test(p) || p === "/api/send";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function withTermCors(req: Request, res: Response | undefined): Response | undefined {
|
|
248
|
+
if (!res) return res; // mux upgrade returns undefined
|
|
249
|
+
if (isTermCorsPath(new URL(req.url).pathname)) {
|
|
250
|
+
try {
|
|
251
|
+
res.headers.set("Access-Control-Allow-Origin", "*");
|
|
252
|
+
res.headers.set("Vary", "Origin");
|
|
253
|
+
} catch {
|
|
254
|
+
/* immutable headers — skip */
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return res;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function termCorsPreflight(): Response {
|
|
261
|
+
return new Response(null, {
|
|
262
|
+
status: 204,
|
|
263
|
+
headers: {
|
|
264
|
+
"Access-Control-Allow-Origin": "*",
|
|
265
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
266
|
+
"Access-Control-Allow-Headers": "content-type, authorization",
|
|
267
|
+
"Access-Control-Max-Age": "600",
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Does `keyword` resolve to the exact pid a scoped token is bound to? */
|
|
273
|
+
async function scopedKeywordOk(keyword: string, scope: TermScope): Promise<boolean> {
|
|
274
|
+
try {
|
|
275
|
+
const rec = await resolveOne(keyword, defaultOpts({ all: true }));
|
|
276
|
+
return String(rec.pid) === String(scope.pid);
|
|
277
|
+
} catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Confine a scoped token to its capability. Returns a 403 Response when the
|
|
284
|
+
* request is outside the token's grant, or null when it's allowed to proceed.
|
|
285
|
+
* Allowed: GET /api/tail|size/<kw> for the bound pid; POST /api/send for the bound
|
|
286
|
+
* pid IFF the token is interactive (canSend). Everything else is denied.
|
|
287
|
+
*/
|
|
288
|
+
async function scopedGate(
|
|
289
|
+
scope: TermScope,
|
|
290
|
+
method: string,
|
|
291
|
+
p: string,
|
|
292
|
+
): Promise<Response | null> {
|
|
293
|
+
const forbid = (m: string) => new Response(m, { status: 403 });
|
|
294
|
+
const m = /^\/api\/(?:tail|size)\/(.+)$/.exec(p);
|
|
295
|
+
if (method === "GET" && m) {
|
|
296
|
+
return (await scopedKeywordOk(decodeURIComponent(m[1]!), scope))
|
|
297
|
+
? null
|
|
298
|
+
: forbid("scoped token: not bound to this agent");
|
|
299
|
+
}
|
|
300
|
+
if (method === "POST" && p === "/api/send") {
|
|
301
|
+
if (!scope.canSend) return forbid("scoped token is read-only");
|
|
302
|
+
return null; // pid binding enforced in the /api/send handler (it has the body)
|
|
303
|
+
}
|
|
304
|
+
// Interactive viewers may renegotiate the agent's PTY size (drag-to-resize) —
|
|
305
|
+
// send-capable scope only, bound pid. The keyword is in the path, so bind here.
|
|
306
|
+
const rz = /^\/api\/resize\/(.+)$/.exec(p);
|
|
307
|
+
if (method === "POST" && rz) {
|
|
308
|
+
if (!scope.canSend) return forbid("scoped token is read-only");
|
|
309
|
+
return (await scopedKeywordOk(decodeURIComponent(rz[1]!), scope))
|
|
310
|
+
? null
|
|
311
|
+
: forbid("scoped token: not bound to this agent");
|
|
312
|
+
}
|
|
313
|
+
return forbid("scoped token: route not permitted");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Mint a scoped terminal-embed token locally (no daemon round-trip): read the
|
|
318
|
+
* master token file, resolve the keyword to a concrete pid, and sign. Used by
|
|
319
|
+
* `ay term mint`. Throws if there's no serve token or the agent can't be resolved.
|
|
320
|
+
*/
|
|
321
|
+
export async function mintScopedTermToken(
|
|
322
|
+
keyword: string,
|
|
323
|
+
opts: { ttlSec: number; canSend: boolean },
|
|
324
|
+
): Promise<{ token: string; pid: string; exp: number; canSend: boolean }> {
|
|
325
|
+
const master = await loadTokenReadOnly();
|
|
326
|
+
if (!master)
|
|
327
|
+
throw new Error(
|
|
328
|
+
"no serve token at ~/.agent-yes/.serve-token — run `ay serve` once on this host to create it",
|
|
329
|
+
);
|
|
330
|
+
const rec = await resolveOne(keyword, defaultOpts({ all: true }));
|
|
331
|
+
const pid = String(rec.pid);
|
|
332
|
+
const exp = Math.floor(Date.now() / 1000) + Math.max(1, Math.floor(opts.ttlSec));
|
|
333
|
+
const token = mintTermToken(master, { pid, canSend: opts.canSend, exp });
|
|
334
|
+
return { token, pid, exp, canSend: opts.canSend };
|
|
335
|
+
}
|
|
336
|
+
|
|
220
337
|
const defaultOpts = (overrides: Partial<CommonOpts> = {}): CommonOpts => ({
|
|
221
338
|
all: false,
|
|
222
339
|
active: false,
|
|
@@ -2019,13 +2136,24 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
2019
2136
|
req: Request,
|
|
2020
2137
|
srv?: { upgrade: (r: Request, o?: unknown) => boolean },
|
|
2021
2138
|
): Promise<Response> => {
|
|
2022
|
-
if (!checkAuth(req, token)) {
|
|
2023
|
-
return new Response("Unauthorized", { status: 401 });
|
|
2024
|
-
}
|
|
2025
|
-
|
|
2026
2139
|
const url = new URL(req.url);
|
|
2027
2140
|
const p = url.pathname;
|
|
2028
2141
|
|
|
2142
|
+
// CORS preflight for the embeddable terminal routes carries no auth header —
|
|
2143
|
+
// answer it before the auth gate.
|
|
2144
|
+
if (req.method === "OPTIONS" && isTermCorsPath(p)) return termCorsPreflight();
|
|
2145
|
+
|
|
2146
|
+
const authResult = authorizeRequest(req, token);
|
|
2147
|
+
if (!authResult) {
|
|
2148
|
+
return withTermCors(req, new Response("Unauthorized", { status: 401 }))!;
|
|
2149
|
+
}
|
|
2150
|
+
// A scoped token is confined to its bound agent + capability; the master token
|
|
2151
|
+
// is unrestricted (existing behavior).
|
|
2152
|
+
if (authResult.kind === "scoped") {
|
|
2153
|
+
const denied = await scopedGate(authResult.scope, req.method, p);
|
|
2154
|
+
if (denied) return withTermCors(req, denied)!;
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2029
2157
|
// GET /api/mux — WebSocket request multiplexer. HTTP/1.1 gives a browser
|
|
2030
2158
|
// ~6 concurrent same-origin connections and no multiplexing, so console
|
|
2031
2159
|
// bursts (a palette query fans out ~20 tail reads + a history search)
|
|
@@ -2727,6 +2855,10 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
2727
2855
|
pid: record.pid,
|
|
2728
2856
|
cols: size?.cols ?? null,
|
|
2729
2857
|
rows: size?.rows ?? null,
|
|
2858
|
+
// Enough identity for a widget's title bar without exposing /api/ls to a
|
|
2859
|
+
// scoped token (it's bound to this pid anyway — no extra info leaked).
|
|
2860
|
+
cwd: record.cwd,
|
|
2861
|
+
cli: record.cli,
|
|
2730
2862
|
// This host negotiates the PTY size from viewer capacities (presence
|
|
2731
2863
|
// `cap`) — a capable console should report its cap instead of pushing
|
|
2732
2864
|
// /api/resize directly, so viewers stop fighting last-writer-wins.
|
|
@@ -2936,6 +3068,11 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
2936
3068
|
if (!keyword || typeof keyword !== "string") {
|
|
2937
3069
|
return new Response("missing keyword", { status: 400 });
|
|
2938
3070
|
}
|
|
3071
|
+
// A scoped interactive token may only write to the agent it's bound to
|
|
3072
|
+
// (the gate already confirmed canSend; bind the pid now that we have the body).
|
|
3073
|
+
if (authResult.kind === "scoped" && !(await scopedKeywordOk(keyword, authResult.scope))) {
|
|
3074
|
+
return new Response("scoped token: not bound to this agent", { status: 403 });
|
|
3075
|
+
}
|
|
2939
3076
|
try {
|
|
2940
3077
|
const record = await resolveOne(keyword, defaultOpts());
|
|
2941
3078
|
if (!record.fifo_file)
|
|
@@ -3804,7 +3941,9 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
3804
3941
|
if (req.method === "GET" && p === "/icon.svg") return serveUiFile("icon.svg", "image/svg+xml");
|
|
3805
3942
|
if (req.method === "GET" && p === "/favicon.ico") return new Response(null, { status: 204 });
|
|
3806
3943
|
if (p.startsWith("/cb/")) return handleCallback(req, p);
|
|
3807
|
-
|
|
3944
|
+
// CORS-open the embeddable terminal routes (tail/size/send) so a cross-origin
|
|
3945
|
+
// report page can reach this daemon; the token is still required for a 200.
|
|
3946
|
+
return withTermCors(req, await apiFetch(req, srv)) as Response;
|
|
3808
3947
|
};
|
|
3809
3948
|
|
|
3810
3949
|
const serverOpts: any = {
|
package/ts/subcommands.ts
CHANGED
|
@@ -365,6 +365,7 @@ const SUBCOMMANDS = new Set([
|
|
|
365
365
|
"todo",
|
|
366
366
|
"ch",
|
|
367
367
|
"channels",
|
|
368
|
+
"term",
|
|
368
369
|
"serve",
|
|
369
370
|
"schedule",
|
|
370
371
|
"remote",
|
|
@@ -486,6 +487,10 @@ export async function runSubcommand(argv: string[]): Promise<number | null> {
|
|
|
486
487
|
const { cmdCh } = await import("./channels.ts");
|
|
487
488
|
return cmdCh(rest);
|
|
488
489
|
}
|
|
490
|
+
case "term": {
|
|
491
|
+
const { cmdTerm } = await import("./terminal.ts");
|
|
492
|
+
return cmdTerm(rest);
|
|
493
|
+
}
|
|
489
494
|
case "serve": {
|
|
490
495
|
const { cmdServe } = await import("./serve.ts");
|
|
491
496
|
return cmdServe(rest);
|
|
@@ -622,6 +627,7 @@ export async function cmdHelp(managerCommands = true): Promise<number> {
|
|
|
622
627
|
` ay send <keyword> <msg> send a message (keyword '.' = agent in this cwd)\n` +
|
|
623
628
|
` ay msgs [keyword] [--in|--out] inter-agent message log (sent + received)\n` +
|
|
624
629
|
` ay ch mk|join|send|read|tail <topic> local-first E2E channels: AI ↔ humans on a topic (ay ch help)\n` +
|
|
630
|
+
` ay term embed <pid> <script> to embed a live read-only agent terminal in a page (ay term help)\n` +
|
|
625
631
|
` ay key <keyword> <key...> send raw keystrokes (down/up/enter/esc/…) — drives menus\n` +
|
|
626
632
|
` ay select <keyword> <N> pick option N of a needs_input selection menu\n` +
|
|
627
633
|
` ay attach <keyword> interactive attach (detach: Ctrl-\\)\n` +
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isTermToken, mintTermToken, verifyTermToken } from "./termToken.ts";
|
|
3
|
+
|
|
4
|
+
const MASTER = "master-token-abc123";
|
|
5
|
+
const now = 1_000_000; // fixed epoch seconds for deterministic exp checks
|
|
6
|
+
|
|
7
|
+
describe("termToken", () => {
|
|
8
|
+
it("round-trips a valid read-only token", () => {
|
|
9
|
+
const tok = mintTermToken(MASTER, { pid: "4242", canSend: false, exp: now + 900 });
|
|
10
|
+
expect(isTermToken(tok)).toBe(true);
|
|
11
|
+
const scope = verifyTermToken(MASTER, tok, now);
|
|
12
|
+
expect(scope).toEqual({ pid: "4242", canSend: false, exp: now + 900 });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("round-trips an interactive token (canSend true)", () => {
|
|
16
|
+
const tok = mintTermToken(MASTER, { pid: "7", canSend: true, exp: now + 60 });
|
|
17
|
+
expect(verifyTermToken(MASTER, tok, now)?.canSend).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("rejects a token signed with a different master (forgery)", () => {
|
|
21
|
+
const tok = mintTermToken(MASTER, { pid: "7", canSend: true, exp: now + 60 });
|
|
22
|
+
expect(verifyTermToken("other-master", tok, now)).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("rejects an expired token", () => {
|
|
26
|
+
const tok = mintTermToken(MASTER, { pid: "7", canSend: false, exp: now - 1 });
|
|
27
|
+
expect(verifyTermToken(MASTER, tok, now)).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("rejects a tampered payload (pid swap) — signature no longer matches", () => {
|
|
31
|
+
const tok = mintTermToken(MASTER, { pid: "7", canSend: false, exp: now + 60 });
|
|
32
|
+
const [prefix, , sig] = tok.split(".");
|
|
33
|
+
const evil = Buffer.from(JSON.stringify({ p: "9999", w: 1, x: now + 60 })).toString("base64url");
|
|
34
|
+
expect(verifyTermToken(MASTER, `${prefix}.${evil}.${sig}`, now)).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("rejects the master token itself and other non-scoped strings", () => {
|
|
38
|
+
expect(isTermToken(MASTER)).toBe(false);
|
|
39
|
+
expect(verifyTermToken(MASTER, MASTER, now)).toBeNull();
|
|
40
|
+
expect(verifyTermToken(MASTER, "ayt1.only-two-parts", now)).toBeNull();
|
|
41
|
+
expect(verifyTermToken(MASTER, "", now)).toBeNull();
|
|
42
|
+
expect(verifyTermToken(MASTER, undefined, now)).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
package/ts/termToken.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Scoped terminal-embed tokens — a capability you can safely put in a web page.
|
|
2
|
+
//
|
|
3
|
+
// The serve daemon's MASTER token (~/.agent-yes/.serve-token) is full-fleet
|
|
4
|
+
// read+write+spawn = RCE; it must NEVER land in an embed. A scoped token instead
|
|
5
|
+
// grants a NARROW capability: read (and optionally write to) exactly ONE agent
|
|
6
|
+
// session, and only until it expires. It is a stateless HMAC bearer token — the
|
|
7
|
+
// daemon verifies it with the same master token it already holds, so there is no
|
|
8
|
+
// server-side token store to manage or revoke (short TTL is the bound).
|
|
9
|
+
//
|
|
10
|
+
// ayt1.<payloadB64url>.<sigB64url>
|
|
11
|
+
// payload = {p: <pid>, w: 0|1 (write/interactive), x: <exp epoch-seconds>}
|
|
12
|
+
// sig = HMAC-SHA256(keyFor(masterToken), "ayt1.<payloadB64url>")
|
|
13
|
+
//
|
|
14
|
+
// `ay term mint` signs one locally (it can read the master token file); the
|
|
15
|
+
// daemon's checkAuth verifies it and confines the request to the bound pid.
|
|
16
|
+
// (ed25519 per-author signing is a stronger future form; symmetric HMAC already
|
|
17
|
+
// delivers short-TTL + read-only + single-session, which is what embeds need.)
|
|
18
|
+
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
|
19
|
+
|
|
20
|
+
export interface TermScope {
|
|
21
|
+
/** The agent pid this token is bound to (resolved to a concrete pid at mint time). */
|
|
22
|
+
pid: string;
|
|
23
|
+
/** Interactive: may also write to this pid's stdin (/api/send). Read-only when false. */
|
|
24
|
+
canSend: boolean;
|
|
25
|
+
/** Expiry, epoch seconds. */
|
|
26
|
+
exp: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const PREFIX = "ayt1";
|
|
30
|
+
|
|
31
|
+
/** Dedicated HMAC key derived from the master token (so the token isn't signed with the raw master). */
|
|
32
|
+
function keyFor(masterToken: string): Buffer {
|
|
33
|
+
return createHash("sha256")
|
|
34
|
+
.update("ay/term/token/v1\n" + masterToken)
|
|
35
|
+
.digest();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** True for anything shaped like a scoped terminal token (cheap pre-check before verify). */
|
|
39
|
+
export function isTermToken(tok: unknown): tok is string {
|
|
40
|
+
return typeof tok === "string" && tok.startsWith(PREFIX + ".");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Mint a scoped token bound to `scope.pid`, signed with the master token. */
|
|
44
|
+
export function mintTermToken(masterToken: string, scope: TermScope): string {
|
|
45
|
+
const payload = Buffer.from(
|
|
46
|
+
JSON.stringify({ p: scope.pid, w: scope.canSend ? 1 : 0, x: Math.floor(scope.exp) }),
|
|
47
|
+
).toString("base64url");
|
|
48
|
+
const body = `${PREFIX}.${payload}`;
|
|
49
|
+
const sig = createHmac("sha256", keyFor(masterToken)).update(body).digest().toString("base64url");
|
|
50
|
+
return `${body}.${sig}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Verify a scoped token against the master token. Returns the scope if the
|
|
55
|
+
* signature is valid AND it hasn't expired, else null. Fail-closed on any
|
|
56
|
+
* malformed input. `nowSec` is injectable for tests.
|
|
57
|
+
*/
|
|
58
|
+
export function verifyTermToken(
|
|
59
|
+
masterToken: string,
|
|
60
|
+
tok: unknown,
|
|
61
|
+
nowSec: number = Math.floor(Date.now() / 1000),
|
|
62
|
+
): TermScope | null {
|
|
63
|
+
if (!isTermToken(tok)) return null;
|
|
64
|
+
const parts = tok.split(".");
|
|
65
|
+
if (parts.length !== 3) return null;
|
|
66
|
+
const body = `${parts[0]}.${parts[1]}`;
|
|
67
|
+
const expected = createHmac("sha256", keyFor(masterToken)).update(body).digest();
|
|
68
|
+
let got: Buffer;
|
|
69
|
+
try {
|
|
70
|
+
got = Buffer.from(parts[2]!, "base64url");
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
// constant-time compare; length check first (timingSafeEqual throws on mismatch)
|
|
75
|
+
if (got.length !== expected.length || !timingSafeEqual(got, expected)) return null;
|
|
76
|
+
let obj: { p?: unknown; w?: unknown; x?: unknown };
|
|
77
|
+
try {
|
|
78
|
+
obj = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf-8"));
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
if (typeof obj.p !== "string" || !obj.p) return null;
|
|
83
|
+
if (typeof obj.x !== "number" || !Number.isFinite(obj.x) || obj.x <= nowSec) return null; // expired
|
|
84
|
+
return { pid: obj.p, canSend: obj.w === 1, exp: obj.x };
|
|
85
|
+
}
|