myagentmemory 0.4.13 → 0.4.15

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.
@@ -0,0 +1,457 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomBytes, randomUUID } from "node:crypto";
3
+ import * as fs from "node:fs";
4
+ import { createServer } from "node:http";
5
+ import * as path from "node:path";
6
+ import { getDefaultPluginInstallRoot, PluginBootstrapFailure, } from "./plugin-bootstrap.js";
7
+ import { validatePluginEntitlementStatusV1 } from "./plugin-host.js";
8
+ const API_ORIGIN = "https://api.agentmemory.paperpilot.me";
9
+ const ARTIFACT_ORIGIN = "https://plugins.agentmemory.paperpilot.me";
10
+ const ACTIVATION_FILE = "credentials/temporary-access.json";
11
+ const REQUEST_TIMEOUT_MS = 30_000;
12
+ const EMAIL_MAX_BYTES = 254;
13
+ const FORM_MAX_BYTES = 2_048;
14
+ const SERVICE_JSON_MAX_BYTES = 1024 * 1024;
15
+ const ACTIVATION_CREDENTIAL = /^am_activation_[A-Za-z0-9_-]{32,256}$/;
16
+ const MISSING_ENTITLEMENT = {
17
+ plan: null,
18
+ state: "missing",
19
+ features: [],
20
+ capabilities: {},
21
+ reason: "Enter an email address to activate the free daily session allowance",
22
+ };
23
+ function cloneEntitlement(value) {
24
+ return structuredClone(value);
25
+ }
26
+ function freeEntitlement(dailySessionLimit) {
27
+ return {
28
+ plan: "free",
29
+ state: "active",
30
+ features: ["session-intelligence", "web-console"],
31
+ capabilities: {
32
+ "session-index": { enabled: true },
33
+ "session-worker": {
34
+ enabled: true,
35
+ quota: { limit: dailySessionLimit, window: "day", scope: "account" },
36
+ },
37
+ learning: { enabled: true },
38
+ "retrieval-evaluation": { enabled: true },
39
+ "operational-metrics": { enabled: true },
40
+ "web-console": { enabled: true },
41
+ "memory-explorer": { enabled: true },
42
+ },
43
+ reason: `${dailySessionLimit} free agent sessions per UTC day`,
44
+ };
45
+ }
46
+ function isEmail(value) {
47
+ return (Buffer.byteLength(value, "utf-8") <= EMAIL_MAX_BYTES &&
48
+ [...value].every((character) => character.charCodeAt(0) >= 32 && character.charCodeAt(0) !== 127) &&
49
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value));
50
+ }
51
+ function securityHeaders(contentType) {
52
+ return {
53
+ "Cache-Control": "no-store",
54
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
55
+ "Content-Type": contentType,
56
+ "Referrer-Policy": "same-origin",
57
+ "X-Content-Type-Options": "nosniff",
58
+ };
59
+ }
60
+ function activationPage(action, error) {
61
+ const errorHtml = error ? `<p class="error">${error}</p>` : "";
62
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Activate AgentMemory</title><style>body{font:16px system-ui;max-width:34rem;margin:10vh auto;padding:0 1.5rem;color:#18212b}form{display:grid;gap:1rem}input,button{font:inherit;padding:.8rem;border-radius:.5rem;border:1px solid #aab4bf}button{background:#18212b;color:#fff;cursor:pointer}.muted{color:#586574}.error{color:#a21d24}</style></head><body><h1>Activate AgentMemory</h1><p>Enter an email address to enable the free daily agent-session allowance on this device.</p>${errorHtml}<form method="post" action="${action}"><label>Email <input type="email" name="email" autocomplete="email" maxlength="254" required autofocus></label><button type="submit">Activate and return to terminal</button></form><p class="muted">The AgentMemory CLI sends your email plus core, bundle, platform, architecture, and release-channel metadata to the private activation service. D1 stores a daily count of opaque SessionStart operations for your normalized email. The request never includes memory, session content, queries, repository paths, raw agent session identifiers, IP addresses, or user-agent strings. Activation records expire after 365 days without use.</p></body></html>`;
63
+ }
64
+ function completionPage() {
65
+ return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>AgentMemory activated</title></head><body><h1>Activation complete</h1><p>You can close this tab and return to the terminal.</p></body></html>';
66
+ }
67
+ function send(response, status, body, contentType = "text/html; charset=utf-8") {
68
+ response.writeHead(status, securityHeaders(contentType));
69
+ response.end(body);
70
+ }
71
+ async function readBoundedBody(response, maxBytes) {
72
+ if (!response.body)
73
+ return new Uint8Array();
74
+ const reader = response.body.getReader();
75
+ const chunks = [];
76
+ let size = 0;
77
+ while (true) {
78
+ const { done, value } = await reader.read();
79
+ if (done)
80
+ break;
81
+ size += value.byteLength;
82
+ if (size > maxBytes) {
83
+ await reader.cancel();
84
+ throw new PluginBootstrapFailure("service_response_too_large", "The plugin service response is too large");
85
+ }
86
+ chunks.push(value);
87
+ }
88
+ const output = new Uint8Array(size);
89
+ let offset = 0;
90
+ for (const chunk of chunks) {
91
+ output.set(chunk, offset);
92
+ offset += chunk.byteLength;
93
+ }
94
+ return output;
95
+ }
96
+ async function readJson(response) {
97
+ try {
98
+ return JSON.parse(new TextDecoder().decode(await readBoundedBody(response, SERVICE_JSON_MAX_BYTES)));
99
+ }
100
+ catch (error) {
101
+ if (error instanceof PluginBootstrapFailure)
102
+ throw error;
103
+ throw new PluginBootstrapFailure("service_response_invalid", "The plugin service returned invalid JSON");
104
+ }
105
+ }
106
+ function readForm(request) {
107
+ return new Promise((resolve, reject) => {
108
+ let size = 0;
109
+ const chunks = [];
110
+ request.on("data", (chunk) => {
111
+ size += chunk.byteLength;
112
+ if (size > FORM_MAX_BYTES) {
113
+ reject(new Error("form too large"));
114
+ request.destroy();
115
+ return;
116
+ }
117
+ chunks.push(chunk);
118
+ });
119
+ request.on("end", () => resolve(new URLSearchParams(Buffer.concat(chunks).toString("utf-8"))));
120
+ request.on("error", reject);
121
+ });
122
+ }
123
+ function isSameOriginActivationPost(request, expectedHost, activationPath) {
124
+ const expectedOrigin = `http://${expectedHost}`;
125
+ const origin = request.headers.origin;
126
+ const fetchSite = request.headers["sec-fetch-site"];
127
+ if (origin === "null") {
128
+ return (fetchSite === "same-origin" &&
129
+ request.headers["sec-fetch-mode"] === "navigate" &&
130
+ request.headers["sec-fetch-dest"] === "document" &&
131
+ request.headers["sec-fetch-user"] === "?1");
132
+ }
133
+ if (origin !== undefined)
134
+ return origin === expectedOrigin;
135
+ if (fetchSite !== undefined && fetchSite !== "same-origin")
136
+ return false;
137
+ const referer = request.headers.referer;
138
+ if (referer !== undefined) {
139
+ try {
140
+ const parsed = new URL(referer);
141
+ return parsed.origin === expectedOrigin && parsed.pathname === activationPath;
142
+ }
143
+ catch {
144
+ return false;
145
+ }
146
+ }
147
+ // Privacy-focused and older browsers may omit all three headers. The exact
148
+ // loopback Host plus the 192-bit, single-use path remains the CSRF capability.
149
+ return true;
150
+ }
151
+ export function openLoopbackUrl(url) {
152
+ const parsed = new URL(url);
153
+ if (parsed.protocol !== "http:" || parsed.hostname !== "127.0.0.1")
154
+ return false;
155
+ try {
156
+ const child = process.platform === "darwin"
157
+ ? spawn("open", [parsed.toString()], { detached: true, stdio: "ignore" })
158
+ : process.platform === "win32"
159
+ ? spawn("explorer.exe", [parsed.toString()], { detached: true, stdio: "ignore" })
160
+ : spawn("xdg-open", [parsed.toString()], { detached: true, stdio: "ignore" });
161
+ child.unref();
162
+ return true;
163
+ }
164
+ catch {
165
+ return false;
166
+ }
167
+ }
168
+ export async function collectTemporaryActivation(openUrl = openLoopbackUrl) {
169
+ const nonce = randomBytes(24).toString("hex");
170
+ const activationPath = `/activate/${nonce}`;
171
+ let expectedHost = "";
172
+ let settle = null;
173
+ let fail = null;
174
+ const result = new Promise((resolve, reject) => {
175
+ settle = resolve;
176
+ fail = reject;
177
+ });
178
+ const server = createServer(async (request, response) => {
179
+ if (request.headers.host !== expectedHost || request.url !== activationPath) {
180
+ send(response, 404, "Not found", "text/plain; charset=utf-8");
181
+ return;
182
+ }
183
+ if (request.method === "GET") {
184
+ send(response, 200, activationPage(activationPath));
185
+ return;
186
+ }
187
+ if (request.method !== "POST" || !isSameOriginActivationPost(request, expectedHost, activationPath)) {
188
+ send(response, 403, "Forbidden", "text/plain; charset=utf-8");
189
+ return;
190
+ }
191
+ try {
192
+ const email = (await readForm(request)).get("email")?.trim() ?? "";
193
+ if (!isEmail(email)) {
194
+ send(response, 400, activationPage(activationPath, "Enter a valid email address."));
195
+ return;
196
+ }
197
+ send(response, 200, completionPage());
198
+ settle?.(email);
199
+ settle = null;
200
+ server.close();
201
+ }
202
+ catch {
203
+ send(response, 400, activationPage(activationPath, "The activation form could not be read."));
204
+ }
205
+ });
206
+ server.requestTimeout = 10_000;
207
+ server.headersTimeout = 10_000;
208
+ server.maxHeadersCount = 40;
209
+ await new Promise((resolve, reject) => {
210
+ server.once("error", reject);
211
+ server.listen(0, "127.0.0.1", resolve);
212
+ });
213
+ const address = server.address();
214
+ if (!address || typeof address === "string")
215
+ throw new PluginBootstrapFailure("activation_failed", "Activation did not bind to loopback");
216
+ expectedHost = `127.0.0.1:${address.port}`;
217
+ const url = `http://${expectedHost}${activationPath}`;
218
+ console.log(`AgentMemory activation: ${url}`);
219
+ let opened = false;
220
+ try {
221
+ opened = openUrl(url);
222
+ }
223
+ catch {
224
+ server.close();
225
+ throw new PluginBootstrapFailure("browser_unavailable", `Open this URL in a browser: ${url}`);
226
+ }
227
+ if (!opened) {
228
+ server.close();
229
+ throw new PluginBootstrapFailure("browser_unavailable", `Open this URL in a browser: ${url}`);
230
+ }
231
+ const timer = setTimeout(() => {
232
+ fail?.(new PluginBootstrapFailure("activation_timeout", "Temporary activation timed out", true));
233
+ server.close();
234
+ }, 5 * 60_000);
235
+ timer.unref();
236
+ try {
237
+ return await result;
238
+ }
239
+ finally {
240
+ clearTimeout(timer);
241
+ server.close();
242
+ }
243
+ }
244
+ export class TemporaryPluginBackend {
245
+ root;
246
+ coreVersion;
247
+ apiOrigin;
248
+ artifactOrigin;
249
+ fetchImplementation;
250
+ openUrl;
251
+ activate;
252
+ constructor(options = {}) {
253
+ this.root = path.resolve(options.root ?? getDefaultPluginInstallRoot());
254
+ this.coreVersion = options.coreVersion ?? "0.0.0";
255
+ this.apiOrigin = options.apiOrigin ?? API_ORIGIN;
256
+ this.artifactOrigin = options.artifactOrigin ?? ARTIFACT_ORIGIN;
257
+ this.fetchImplementation = options.fetch ?? globalThis.fetch;
258
+ this.openUrl = options.openUrl ?? openLoopbackUrl;
259
+ this.activate = options.activate ?? (() => collectTemporaryActivation(this.openUrl));
260
+ }
261
+ async getLocalEntitlement() {
262
+ const activation = this.readActivation();
263
+ return activation ? freeEntitlement(activation.dailySessionLimit) : cloneEntitlement(MISSING_ENTITLEMENT);
264
+ }
265
+ async resolveAccess(request) {
266
+ const activation = this.readActivation();
267
+ let email = activation?.email;
268
+ if (!email) {
269
+ if (!request.allowAuthentication)
270
+ return {
271
+ kind: "auth_required",
272
+ entitlement: cloneEntitlement(MISSING_ENTITLEMENT),
273
+ nextAction: {
274
+ kind: "authenticate",
275
+ url: "https://jayzeng.github.io/agentmemory/",
276
+ message: "Run plugin install in an interactive terminal to enter an email address",
277
+ },
278
+ };
279
+ email = await this.activate();
280
+ }
281
+ const response = await this.request(`${this.apiOrigin}/v1/plugin/access`, {
282
+ method: "POST",
283
+ headers: { "Content-Type": "application/json" },
284
+ body: JSON.stringify({
285
+ schemaVersion: 1,
286
+ email,
287
+ bundleId: request.bundleId,
288
+ installedVersion: request.installedVersion ?? null,
289
+ coreVersion: this.coreVersion,
290
+ channel: request.channel,
291
+ platform: process.platform,
292
+ architecture: process.arch,
293
+ consentVersion: "activation-v2",
294
+ }),
295
+ });
296
+ const value = (await readJson(response));
297
+ validatePluginEntitlementStatusV1(value.entitlement);
298
+ if (typeof value.artifactGrant !== "string" || !value.artifactGrant)
299
+ throw new PluginBootstrapFailure("service_response_invalid", "The access response omitted its artifact grant");
300
+ if (typeof value.usageCredential !== "string" || !ACTIVATION_CREDENTIAL.test(value.usageCredential))
301
+ throw new PluginBootstrapFailure("service_response_invalid", "The access response omitted its usage credential");
302
+ const freeQuota = value.entitlement.capabilities["session-worker"]?.quota;
303
+ if (value.entitlement.plan !== "free" ||
304
+ value.entitlement.state !== "active" ||
305
+ !freeQuota ||
306
+ freeQuota.scope !== "account" ||
307
+ freeQuota.window !== "day")
308
+ throw new PluginBootstrapFailure("service_response_invalid", "The free session policy is invalid");
309
+ this.writeActivation(email, value.usageCredential, freeQuota.limit);
310
+ return { kind: "granted", entitlement: value.entitlement, artifactGrant: value.artifactGrant };
311
+ }
312
+ async reserveSession(operationId) {
313
+ return this.sessionUsage("reserve", operationId);
314
+ }
315
+ async commitSession(operationId) {
316
+ return this.sessionUsage("commit", operationId);
317
+ }
318
+ async releaseSession(operationId) {
319
+ return this.sessionUsage("release", operationId);
320
+ }
321
+ async listReleases(request) {
322
+ const response = await this.request(`${this.apiOrigin}/v1/plugin/releases`, {
323
+ headers: { Authorization: `Bearer ${request.artifactGrant}` },
324
+ });
325
+ const value = (await readJson(response));
326
+ if (!Array.isArray(value.releases))
327
+ throw new PluginBootstrapFailure("service_response_invalid", "The release response is invalid");
328
+ return value.releases;
329
+ }
330
+ async downloadArtifact(request) {
331
+ const response = await this.request(`${this.artifactOrigin}/v1/artifacts/download`, {
332
+ headers: { Authorization: `Bearer ${request.artifactGrant}` },
333
+ });
334
+ const declared = Number(response.headers.get("Content-Length"));
335
+ if (Number.isFinite(declared) && declared !== request.release.size)
336
+ throw new PluginBootstrapFailure("artifact_size_mismatch", "The artifact response size is invalid");
337
+ const artifact = await readBoundedBody(response, request.release.size);
338
+ if (artifact.byteLength !== request.release.size)
339
+ throw new PluginBootstrapFailure("artifact_size_mismatch", "The artifact response size is invalid");
340
+ return artifact;
341
+ }
342
+ async getManagementAction() {
343
+ return null;
344
+ }
345
+ activationPath() {
346
+ return path.join(this.root, ...ACTIVATION_FILE.split("/"));
347
+ }
348
+ readActivation() {
349
+ const activationPath = this.activationPath();
350
+ if (!fs.existsSync(activationPath))
351
+ return null;
352
+ try {
353
+ const rootStat = fs.lstatSync(this.root);
354
+ const credentialsStat = fs.lstatSync(path.dirname(activationPath));
355
+ if (!rootStat.isDirectory() ||
356
+ rootStat.isSymbolicLink() ||
357
+ !credentialsStat.isDirectory() ||
358
+ credentialsStat.isSymbolicLink())
359
+ return null;
360
+ const stat = fs.lstatSync(activationPath);
361
+ if (!stat.isFile() || stat.isSymbolicLink() || (process.platform !== "win32" && (stat.mode & 0o077) !== 0))
362
+ return null;
363
+ const value = JSON.parse(fs.readFileSync(activationPath, "utf-8"));
364
+ if (value.schemaVersion !== 2 ||
365
+ !isEmail(value.email) ||
366
+ !Number.isFinite(Date.parse(value.activatedAt)) ||
367
+ !ACTIVATION_CREDENTIAL.test(value.usageCredential) ||
368
+ !Number.isSafeInteger(value.dailySessionLimit) ||
369
+ value.dailySessionLimit <= 0 ||
370
+ value.dailySessionLimit > 10_000)
371
+ return null;
372
+ return value;
373
+ }
374
+ catch {
375
+ return null;
376
+ }
377
+ }
378
+ writeActivation(email, usageCredential, dailySessionLimit) {
379
+ if (!isEmail(email))
380
+ throw new PluginBootstrapFailure("email_invalid", "Enter a valid email address");
381
+ if (!ACTIVATION_CREDENTIAL.test(usageCredential))
382
+ throw new PluginBootstrapFailure("activation_failed", "The activation credential is invalid");
383
+ if (!Number.isSafeInteger(dailySessionLimit) || dailySessionLimit <= 0 || dailySessionLimit > 10_000)
384
+ throw new PluginBootstrapFailure("activation_failed", "The free session allowance is invalid");
385
+ const target = this.activationPath();
386
+ fs.mkdirSync(this.root, { recursive: true, mode: 0o700 });
387
+ const rootStat = fs.lstatSync(this.root);
388
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
389
+ throw new PluginBootstrapFailure("activation_path_invalid", "The plugin activation root is unsafe");
390
+ const directory = path.dirname(target);
391
+ if (!fs.existsSync(directory))
392
+ fs.mkdirSync(directory, { mode: 0o700 });
393
+ const directoryStat = fs.lstatSync(directory);
394
+ if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink())
395
+ throw new PluginBootstrapFailure("activation_path_invalid", "The plugin activation directory is unsafe");
396
+ const temporary = `${target}.tmp-${process.pid}-${randomUUID()}`;
397
+ fs.writeFileSync(temporary, `${JSON.stringify({ schemaVersion: 2, email, activatedAt: new Date().toISOString(), usageCredential, dailySessionLimit }, null, 2)}\n`, { mode: 0o600, flag: "wx" });
398
+ fs.renameSync(temporary, target);
399
+ }
400
+ async sessionUsage(action, operationId) {
401
+ const activation = this.readActivation();
402
+ if (!activation)
403
+ throw new PluginBootstrapFailure("auth_required", "Run plugin install to activate AgentMemory");
404
+ const response = await this.request(`${this.apiOrigin}/v1/plugin/sessions/${action}`, {
405
+ method: "POST",
406
+ headers: {
407
+ Authorization: `Bearer ${activation.usageCredential}`,
408
+ "Content-Type": "application/json",
409
+ },
410
+ body: JSON.stringify({ schemaVersion: 1, operationId }),
411
+ });
412
+ const value = (await readJson(response));
413
+ const decision = value.decision;
414
+ if (!decision ||
415
+ typeof decision.allowed !== "boolean" ||
416
+ !["reserved", "committed", "released", "exhausted", "missing"].includes(String(decision.state)) ||
417
+ !Number.isSafeInteger(decision.limit) ||
418
+ Number(decision.limit) <= 0 ||
419
+ !Number.isSafeInteger(decision.used) ||
420
+ Number(decision.used) < 0 ||
421
+ !Number.isSafeInteger(decision.remaining) ||
422
+ Number(decision.remaining) < 0 ||
423
+ typeof decision.resetAt !== "string" ||
424
+ !Number.isFinite(Date.parse(decision.resetAt)) ||
425
+ typeof decision.idempotent !== "boolean")
426
+ throw new PluginBootstrapFailure("service_response_invalid", "The session usage response is invalid");
427
+ return decision;
428
+ }
429
+ async request(url, init = {}) {
430
+ let response;
431
+ try {
432
+ response = await this.fetchImplementation(url, {
433
+ ...init,
434
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
435
+ headers: { Accept: "application/json", ...init.headers },
436
+ });
437
+ }
438
+ catch {
439
+ throw new PluginBootstrapFailure("service_unavailable", "The AgentMemory plugin service is unavailable", true);
440
+ }
441
+ if (!response.ok) {
442
+ let message = `The AgentMemory plugin service returned HTTP ${response.status}`;
443
+ try {
444
+ const value = (await readJson(response));
445
+ if (typeof value.error?.message === "string")
446
+ message = value.error.message;
447
+ }
448
+ catch (error) {
449
+ if (error instanceof PluginBootstrapFailure && error.code === "service_response_too_large")
450
+ throw error;
451
+ // Keep the bounded generic response.
452
+ }
453
+ throw new PluginBootstrapFailure("service_request_failed", message, response.status >= 500);
454
+ }
455
+ return response;
456
+ }
457
+ }