behavior-wrapped 0.2.14 → 0.2.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.
- package/README.md +7 -4
- package/dist/assets/{index-BIbVOfPJ.css → index-BGWEDZlc.css} +1 -1
- package/dist/assets/index-BuozLh2C.js +11 -0
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/scripts/decrypt-research-donation.mjs +25 -0
- package/server/analysis.mjs +5 -3
- package/server/encrypted-donation-schema.mjs +45 -0
- package/server/launcher.mjs +15 -4
- package/server/research-donation-crypto.mjs +68 -0
- package/server/research-donation-schema.mjs +10 -5
- package/server/research-donation.mjs +17 -5
- package/server/store.mjs +24 -0
- package/dist/assets/index-yvo-hzIv.js +0 -11
package/dist/index.html
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
<meta name="theme-color" content="#0d0b1b" />
|
|
7
7
|
<meta name="description" content="Your private, local-first Claude Code behavior report." />
|
|
8
8
|
<title>Behavior Wrapped</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-BuozLh2C.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BGWEDZlc.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "behavior-wrapped",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.15",
|
|
4
4
|
"description": "A private, local-first Wrapped report for Claude Code and Codex behavior.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"analyze:phrases": "node scripts/mine-phrase-families.mjs",
|
|
45
45
|
"review:interactions": "node scripts/review-interaction-tone.mjs",
|
|
46
46
|
"review:workarounds": "node scripts/review-workaround-judge.mjs",
|
|
47
|
+
"research:decrypt": "node scripts/decrypt-research-donation.mjs",
|
|
47
48
|
"worker:dev": "npx --yes wrangler@4.86.0 dev",
|
|
48
49
|
"worker:deploy": "npx --yes wrangler@4.86.0 deploy",
|
|
49
50
|
"test": "node --test tests/*.test.mjs",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { decryptResearchDonation } from "../server/research-donation-crypto.mjs";
|
|
7
|
+
|
|
8
|
+
const [, , inputArg, outputArg, keyArg] = process.argv;
|
|
9
|
+
if (!inputArg || !outputArg) {
|
|
10
|
+
console.error("Usage: node scripts/decrypt-research-donation.mjs <encrypted-envelope.json> <private-output.json> [private-key.pem]");
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const input = path.resolve(inputArg);
|
|
15
|
+
const output = path.resolve(outputArg);
|
|
16
|
+
const privateKeyPath = path.resolve(keyArg || path.join(os.homedir(), ".config", "behavior-wrapped", "keys", "research-donation-rsa-2026-08.pem"));
|
|
17
|
+
const passphrase = process.env.BEHAVIOR_WRAPPED_DONATION_KEY_PASSPHRASE || (process.platform === "darwin"
|
|
18
|
+
? execFileSync("security", ["find-generic-password", "-a", os.userInfo().username, "-s", "behavior-wrapped-research-key-2026-08", "-w"], { encoding: "utf8" }).trim()
|
|
19
|
+
: "");
|
|
20
|
+
|
|
21
|
+
if (!passphrase) throw new Error("Set BEHAVIOR_WRAPPED_DONATION_KEY_PASSPHRASE before decrypting.");
|
|
22
|
+
const envelope = JSON.parse(fs.readFileSync(input, "utf8"));
|
|
23
|
+
const donation = decryptResearchDonation(envelope, fs.readFileSync(privateKeyPath, "utf8"), passphrase);
|
|
24
|
+
fs.writeFileSync(output, `${JSON.stringify(donation, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
25
|
+
console.log(`Decrypted donation written with private permissions: ${output}`);
|
package/server/analysis.mjs
CHANGED
|
@@ -485,14 +485,16 @@ function donationSessionSummary(messages, suppliedSummary) {
|
|
|
485
485
|
return `${shortened.slice(0, Math.max(shortened.lastIndexOf(" "), 1)).trim()}…`;
|
|
486
486
|
}
|
|
487
487
|
|
|
488
|
-
export function makeDonationPreview(sessionRecords, metadataById, { disabledRedactions = [], disabledMatches = [] } = {}) {
|
|
488
|
+
export function makeDonationPreview(sessionRecords, metadataById, { disabledRedactions = [], disabledMatches = [], unredacted = false } = {}) {
|
|
489
489
|
const detections = [];
|
|
490
490
|
const sessions = sessionRecords.map(({ sessionId, records }) => {
|
|
491
491
|
const messages = records.flatMap((record) => {
|
|
492
492
|
if (record.type !== "user" && record.type !== "assistant") return [];
|
|
493
493
|
const value = visibleText(record);
|
|
494
494
|
if (!value) return [];
|
|
495
|
-
const redacted =
|
|
495
|
+
const redacted = unredacted
|
|
496
|
+
? { text: value, detections: [] }
|
|
497
|
+
: redactText(value, [], { disabledKinds: disabledRedactions, disabledMatches, includeHeuristicSecrets: false });
|
|
496
498
|
detections.push(...redacted.detections);
|
|
497
499
|
return [{ role: record.type, timestamp: record.timestamp || null, text: redacted.text }];
|
|
498
500
|
});
|
|
@@ -501,5 +503,5 @@ export function makeDonationPreview(sessionRecords, metadataById, { disabledReda
|
|
|
501
503
|
});
|
|
502
504
|
const redactions = donationRedactionInventory(detections);
|
|
503
505
|
const detectionCount = detections.filter((detection) => detection.enabled !== false).length;
|
|
504
|
-
return { format: "behavior-wrapped-donation-preview-v1", createdLocally: true, detectionCount, redactions, sessions };
|
|
506
|
+
return { format: "behavior-wrapped-donation-preview-v1", createdLocally: true, unredacted, detectionCount, redactions, sessions };
|
|
505
507
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { MAX_DONATION_BYTES } from "./research-donation-schema.mjs";
|
|
2
|
+
|
|
3
|
+
export const DONATION_ENVELOPE_FORMAT = "behavior-wrapped-encrypted-donation-v1";
|
|
4
|
+
export const DONATION_ENCRYPTION_ALGORITHM = "RSA-OAEP-256+A256GCM";
|
|
5
|
+
export const DONATION_KEY_ID = "research-donation-rsa-2026-08";
|
|
6
|
+
export const MAX_ENCRYPTED_DONATION_BYTES = 2_500_000;
|
|
7
|
+
|
|
8
|
+
const base64url = /^[A-Za-z0-9_-]+$/;
|
|
9
|
+
const timestamp = /^\d{4}-\d{2}-\d{2}T/;
|
|
10
|
+
|
|
11
|
+
function exactKeys(value, expected) {
|
|
12
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
13
|
+
&& Object.keys(value).sort().join("|") === [...expected].sort().join("|");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function boundedInteger(value, maximum) {
|
|
17
|
+
return Number.isInteger(value) && value >= 0 && value <= maximum;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function encryptedDonationAAD(envelope) {
|
|
21
|
+
return JSON.stringify({
|
|
22
|
+
format: envelope.format,
|
|
23
|
+
encryption: { algorithm: envelope.encryption.algorithm, keyId: envelope.encryption.keyId },
|
|
24
|
+
metadata: envelope.metadata,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function sanitizeEncryptedDonationEnvelope(value) {
|
|
29
|
+
if (!exactKeys(value, ["ciphertext", "encryption", "format", "metadata"])) return null;
|
|
30
|
+
if (value.format !== DONATION_ENVELOPE_FORMAT) return null;
|
|
31
|
+
if (!exactKeys(value.encryption, ["algorithm", "authTag", "iv", "keyId", "wrappedKey"])) return null;
|
|
32
|
+
if (value.encryption.algorithm !== DONATION_ENCRYPTION_ALGORITHM || value.encryption.keyId !== DONATION_KEY_ID) return null;
|
|
33
|
+
if (typeof value.encryption.wrappedKey !== "string" || value.encryption.wrappedKey.length < 480 || value.encryption.wrappedKey.length > 700 || !base64url.test(value.encryption.wrappedKey)) return null;
|
|
34
|
+
if (typeof value.encryption.iv !== "string" || value.encryption.iv.length !== 16 || !base64url.test(value.encryption.iv)) return null;
|
|
35
|
+
if (typeof value.encryption.authTag !== "string" || value.encryption.authTag.length !== 22 || !base64url.test(value.encryption.authTag)) return null;
|
|
36
|
+
if (typeof value.ciphertext !== "string" || !value.ciphertext.length || value.ciphertext.length > Math.ceil(MAX_DONATION_BYTES / 3) * 4 || !base64url.test(value.ciphertext)) return null;
|
|
37
|
+
if (!exactKeys(value.metadata, ["automatedDetections", "consentVersion", "consentedAt", "createdAt", "messages", "redactionMode", "reportId", "sessions", "unredactedData"])) return null;
|
|
38
|
+
const metadata = value.metadata;
|
|
39
|
+
if (!/^[A-Za-z0-9_-]{8,32}$/.test(metadata.reportId || "")) return null;
|
|
40
|
+
if (!new Set(["standard", "custom", "unredacted"]).has(metadata.redactionMode)) return null;
|
|
41
|
+
if (!timestamp.test(metadata.createdAt || "") || !timestamp.test(metadata.consentedAt || "")) return null;
|
|
42
|
+
if (metadata.consentVersion !== 1 || typeof metadata.unredactedData !== "boolean" || metadata.unredactedData !== (metadata.redactionMode === "unredacted")) return null;
|
|
43
|
+
if (!boundedInteger(metadata.automatedDetections, 1_000_000) || !boundedInteger(metadata.sessions, 250) || metadata.sessions < 1 || !boundedInteger(metadata.messages, 50_000) || metadata.messages < 1) return null;
|
|
44
|
+
return value;
|
|
45
|
+
}
|
package/server/launcher.mjs
CHANGED
|
@@ -7,8 +7,8 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
8
|
import { discoverAllSessionsAsync, readRecordsAsync, defaultDateRange, DEFAULT_WINDOW_DAYS } from "./discovery.mjs";
|
|
9
9
|
import { makeDonationPreview } from "./analysis.mjs";
|
|
10
|
-
import { getOrCreateClientId, loadReport } from "./store.mjs";
|
|
11
|
-
import { RESEARCH_DONATION_URL, submitResearchDonation } from "./research-donation.mjs";
|
|
10
|
+
import { deleteDonationReceipt, getOrCreateClientId, loadDonationReceipt, loadReport, saveDonationReceipt } from "./store.mjs";
|
|
11
|
+
import { deleteResearchDonation, RESEARCH_DONATION_URL, submitResearchDonation } from "./research-donation.mjs";
|
|
12
12
|
|
|
13
13
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
const root = path.dirname(here);
|
|
@@ -121,7 +121,8 @@ const server = http.createServer(async (request, response) => {
|
|
|
121
121
|
const labels = new Map(publicCatalog().sessions.map((session) => [session.id, { ...session, summary: summaries.get(session.id) }]));
|
|
122
122
|
const disabledRedactions = Array.isArray(body.disabledRedactions) ? body.disabledRedactions.filter((kind) => typeof kind === "string" && /^[a-z0-9-]{1,64}$/.test(kind)).slice(0, 20) : [];
|
|
123
123
|
const disabledMatches = Array.isArray(body.disabledMatches) ? body.disabledMatches.filter((id) => typeof id === "string" && /^[a-f0-9]{24}$/.test(id)).slice(0, 5_000) : [];
|
|
124
|
-
|
|
124
|
+
const unredacted = body.previewMode === "unredacted";
|
|
125
|
+
return json(response, 200, makeDonationPreview(records, labels, { disabledRedactions, disabledMatches, unredacted }));
|
|
125
126
|
}
|
|
126
127
|
if (request.method === "POST" && url.pathname === "/api/research-donations") {
|
|
127
128
|
const body = await readBody(request, 4_200_000);
|
|
@@ -132,7 +133,17 @@ const server = http.createServer(async (request, response) => {
|
|
|
132
133
|
clientId: getOrCreateClientId(),
|
|
133
134
|
endpoint: process.env.BEHAVIOR_WRAPPED_DONATION_URL || RESEARCH_DONATION_URL,
|
|
134
135
|
});
|
|
135
|
-
|
|
136
|
+
saveDonationReceipt(result);
|
|
137
|
+
return json(response, 201, { accepted: true, donation_id: result.donation_id, encrypted: true });
|
|
138
|
+
}
|
|
139
|
+
const donationMatch = url.pathname.match(/^\/api\/research-donations\/([0-9a-f-]{36})$/);
|
|
140
|
+
if (request.method === "DELETE" && donationMatch) {
|
|
141
|
+
const receipt = loadDonationReceipt(donationMatch[1]);
|
|
142
|
+
if (!receipt) return json(response, 404, { error: "Local deletion receipt not found." });
|
|
143
|
+
if (demo) { deleteDonationReceipt(donationMatch[1]); return json(response, 200, { deleted: true, demo: true }); }
|
|
144
|
+
const result = await deleteResearchDonation(receipt.donationId, receipt.deletionToken, { endpoint: process.env.BEHAVIOR_WRAPPED_DONATION_URL || RESEARCH_DONATION_URL });
|
|
145
|
+
deleteDonationReceipt(donationMatch[1]);
|
|
146
|
+
return json(response, 200, result);
|
|
136
147
|
}
|
|
137
148
|
if (request.method !== "GET" && request.method !== "HEAD") return json(response, 405, { error: "Method not allowed" });
|
|
138
149
|
const requested = url.pathname === "/" ? "index.html" : url.pathname.slice(1);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { DONATION_ENCRYPTION_ALGORITHM, DONATION_ENVELOPE_FORMAT, DONATION_KEY_ID, encryptedDonationAAD, sanitizeEncryptedDonationEnvelope } from "./encrypted-donation-schema.mjs";
|
|
3
|
+
import { sanitizeResearchDonation } from "./research-donation-schema.mjs";
|
|
4
|
+
|
|
5
|
+
export const RESEARCH_DONATION_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
|
|
6
|
+
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEA0RaXFQBixAmtwKRz2I7Y
|
|
7
|
+
pq0TlQPAZ72gHbyV2RJ9dRiDNwlwaKbHHVLYHG0QjxFanktNi/ms6NoV9slBQhjJ
|
|
8
|
+
Rb3kMzg5xEJYYy8TzyQKpY28f5/srGpSL2ziWRb9TSsgrOJPNk9LFPKLuJhty1+x
|
|
9
|
+
Gh9+I3UW+JPj+To4VY7GVU46jptP2MDtROK5v/p9PLP+QoKhjTBuDqgu5T78wTv5
|
|
10
|
+
/C34ZZD4ACIKvIQ8dtAZM6CPY0sWWVN84VO5etr1rYZg7DWczy2ZsX2StiKmuZ8b
|
|
11
|
+
kSqZr/mn6+PC5sthPCt0B+Tk1pxPv6LuwiNYubst4EKQDpFbP2e4h3KIaNxTnVRx
|
|
12
|
+
AHzd9XfF9rN86+Cjf55YlBuT9GeYXLttGBfoT6Llr4Xw370WIHabo7A57/atLOgw
|
|
13
|
+
YKX6TcNe6TOHuCTHM7LDmTPGNZdMi9cXXBUzohvTxm7O8qduIekFg4emxIiduVY2
|
|
14
|
+
3pHhzoP9p0kwO+L0BE1ELIHF9dk0p3s/NDIDfbiDxn5XAgMBAAE=
|
|
15
|
+
-----END PUBLIC KEY-----`;
|
|
16
|
+
|
|
17
|
+
function base64url(value) {
|
|
18
|
+
return Buffer.from(value).toString("base64url");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function encryptResearchDonation(value, publicKey = RESEARCH_DONATION_PUBLIC_KEY) {
|
|
22
|
+
const donation = sanitizeResearchDonation(value);
|
|
23
|
+
if (!donation) throw new Error("The reviewed donation does not match the research schema.");
|
|
24
|
+
const plaintext = Buffer.from(JSON.stringify(donation));
|
|
25
|
+
const contentKey = crypto.randomBytes(32);
|
|
26
|
+
const iv = crypto.randomBytes(12);
|
|
27
|
+
const envelope = {
|
|
28
|
+
format: DONATION_ENVELOPE_FORMAT,
|
|
29
|
+
encryption: { algorithm: DONATION_ENCRYPTION_ALGORITHM, keyId: DONATION_KEY_ID },
|
|
30
|
+
metadata: {
|
|
31
|
+
reportId: donation.reportId,
|
|
32
|
+
redactionMode: donation.redactionMode,
|
|
33
|
+
createdAt: donation.createdAt,
|
|
34
|
+
consentedAt: donation.consent.consentedAt,
|
|
35
|
+
consentVersion: 1,
|
|
36
|
+
unredactedData: donation.redactionMode === "unredacted",
|
|
37
|
+
automatedDetections: donation.redactionSummary.automatedDetections,
|
|
38
|
+
sessions: donation.redactionSummary.sessions,
|
|
39
|
+
messages: donation.redactionSummary.messages,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", contentKey, iv);
|
|
43
|
+
cipher.setAAD(Buffer.from(encryptedDonationAAD(envelope)));
|
|
44
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
45
|
+
return sanitizeEncryptedDonationEnvelope({
|
|
46
|
+
...envelope,
|
|
47
|
+
encryption: {
|
|
48
|
+
...envelope.encryption,
|
|
49
|
+
wrappedKey: base64url(crypto.publicEncrypt({ key: publicKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" }, contentKey)),
|
|
50
|
+
iv: base64url(iv),
|
|
51
|
+
authTag: base64url(cipher.getAuthTag()),
|
|
52
|
+
},
|
|
53
|
+
ciphertext: base64url(ciphertext),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function decryptResearchDonation(value, privateKey, passphrase) {
|
|
58
|
+
const envelope = sanitizeEncryptedDonationEnvelope(value);
|
|
59
|
+
if (!envelope) throw new Error("The encrypted donation envelope is invalid.");
|
|
60
|
+
const contentKey = crypto.privateDecrypt({ key: privateKey, passphrase, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" }, Buffer.from(envelope.encryption.wrappedKey, "base64url"));
|
|
61
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", contentKey, Buffer.from(envelope.encryption.iv, "base64url"));
|
|
62
|
+
decipher.setAAD(Buffer.from(encryptedDonationAAD(envelope)));
|
|
63
|
+
decipher.setAuthTag(Buffer.from(envelope.encryption.authTag, "base64url"));
|
|
64
|
+
const plaintext = Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, "base64url")), decipher.final()]);
|
|
65
|
+
const donation = sanitizeResearchDonation(JSON.parse(plaintext.toString("utf8")));
|
|
66
|
+
if (!donation) throw new Error("The decrypted donation is invalid.");
|
|
67
|
+
return donation;
|
|
68
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const MAX_DONATION_BYTES =
|
|
1
|
+
const MAX_DONATION_BYTES = 1_800_000;
|
|
2
2
|
const MAX_SESSIONS = 250;
|
|
3
3
|
const MAX_MESSAGES = 50_000;
|
|
4
4
|
const MAX_MESSAGE_LENGTH = 20_000;
|
|
@@ -11,7 +11,9 @@ export function sanitizeResearchDonation(value) {
|
|
|
11
11
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
12
12
|
if (value.consent?.researchDonation !== true) return null;
|
|
13
13
|
if (!/^[A-Za-z0-9_-]{8,32}$/.test(value.reportId || "")) return null;
|
|
14
|
-
if (!new Set(["standard", "custom"]).has(value.redactionMode)) return null;
|
|
14
|
+
if (!new Set(["standard", "custom", "unredacted"]).has(value.redactionMode)) return null;
|
|
15
|
+
const unredacted = value.redactionMode === "unredacted";
|
|
16
|
+
if (unredacted && value.consent?.unredactedData !== true) return null;
|
|
15
17
|
if (!Array.isArray(value.sessions) || !value.sessions.length || value.sessions.length > MAX_SESSIONS) return null;
|
|
16
18
|
let messageCount = 0;
|
|
17
19
|
const sessions = value.sessions.flatMap((session, sessionIndex) => {
|
|
@@ -33,18 +35,21 @@ export function sanitizeResearchDonation(value) {
|
|
|
33
35
|
redactionMode: value.redactionMode,
|
|
34
36
|
createdAt: /^\d{4}-\d{2}-\d{2}T/.test(value.createdAt || "") ? value.createdAt : new Date().toISOString(),
|
|
35
37
|
redactionSummary: {
|
|
36
|
-
automatedDetections: Math.round(Math.max(0, Math.min(Number(value.redactionSummary?.automatedDetections) || 0, 1_000_000))),
|
|
38
|
+
automatedDetections: unredacted ? 0 : Math.round(Math.max(0, Math.min(Number(value.redactionSummary?.automatedDetections) || 0, 1_000_000))),
|
|
37
39
|
sessions: sessions.length,
|
|
38
40
|
messages: messageCount,
|
|
39
41
|
},
|
|
40
42
|
sessions,
|
|
41
43
|
consent: {
|
|
42
44
|
researchDonation: true,
|
|
43
|
-
|
|
45
|
+
...(unredacted ? { unredactedData: true } : {}),
|
|
46
|
+
statement: unredacted
|
|
47
|
+
? "I understand this donation is not automatically redacted and may contain credentials, personal details, private code, URLs, and file paths. I consent to transmit it for research."
|
|
48
|
+
: "I consent for this reviewed data to be transmitted and used for research.",
|
|
44
49
|
consentedAt: /^\d{4}-\d{2}-\d{2}T/.test(value.consent.consentedAt || "") ? value.consent.consentedAt : new Date().toISOString(),
|
|
45
50
|
},
|
|
46
51
|
};
|
|
47
|
-
return JSON.stringify(donation).
|
|
52
|
+
return new TextEncoder().encode(JSON.stringify(donation)).byteLength <= MAX_DONATION_BYTES ? donation : null;
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
export { MAX_DONATION_BYTES };
|
|
@@ -1,22 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { encryptResearchDonation } from "./research-donation-crypto.mjs";
|
|
2
2
|
|
|
3
3
|
export const RESEARCH_DONATION_URL = "https://agent-behavior-wrapped-judge.haoxingdu.workers.dev/v1/research-donations";
|
|
4
4
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
5
5
|
|
|
6
6
|
export async function submitResearchDonation(value, { clientId, endpoint = RESEARCH_DONATION_URL, fetchImpl = fetch } = {}) {
|
|
7
|
-
const
|
|
8
|
-
if (!donation) throw new Error("The reviewed donation does not match the research schema.");
|
|
7
|
+
const encryptedDonation = encryptResearchDonation(value);
|
|
9
8
|
let response;
|
|
10
9
|
try {
|
|
11
10
|
response = await fetchImpl(endpoint, {
|
|
12
11
|
method: "POST",
|
|
13
12
|
headers: {
|
|
14
13
|
"content-type": "application/json",
|
|
15
|
-
"x-behavior-wrapped-protocol": "
|
|
14
|
+
"x-behavior-wrapped-protocol": "2",
|
|
16
15
|
...(clientId ? { "x-behavior-wrapped-client": clientId } : {}),
|
|
17
16
|
},
|
|
18
17
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
19
|
-
body: JSON.stringify({
|
|
18
|
+
body: JSON.stringify({ encryptedDonation }),
|
|
20
19
|
});
|
|
21
20
|
} catch (error) {
|
|
22
21
|
if (error?.name === "TimeoutError" || error?.name === "AbortError") throw new Error("The research donation timed out. Your data was not confirmed as received.");
|
|
@@ -26,3 +25,16 @@ export async function submitResearchDonation(value, { clientId, endpoint = RESEA
|
|
|
26
25
|
if (!response.ok) throw new Error(body?.error || "The research donation could not be accepted.");
|
|
27
26
|
return body;
|
|
28
27
|
}
|
|
28
|
+
|
|
29
|
+
export async function deleteResearchDonation(id, deletionToken, { endpoint = RESEARCH_DONATION_URL, fetchImpl = fetch } = {}) {
|
|
30
|
+
if (!/^[0-9a-f-]{36}$/.test(id || "") || !/^[A-Za-z0-9_-]{43}$/.test(deletionToken || "")) throw new Error("The local deletion receipt is invalid.");
|
|
31
|
+
const response = await fetchImpl(`${endpoint}/${id}`, {
|
|
32
|
+
method: "DELETE",
|
|
33
|
+
headers: { "x-behavior-wrapped-protocol": "2", "x-behavior-wrapped-deletion-token": deletionToken },
|
|
34
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
35
|
+
}).catch(() => null);
|
|
36
|
+
if (!response) throw new Error("The research donation service is temporarily unavailable.");
|
|
37
|
+
const body = await response.json().catch(() => ({}));
|
|
38
|
+
if (!response.ok) throw new Error(body?.error || "The research donation could not be deleted.");
|
|
39
|
+
return body;
|
|
40
|
+
}
|
package/server/store.mjs
CHANGED
|
@@ -5,10 +5,34 @@ import crypto from "node:crypto";
|
|
|
5
5
|
|
|
6
6
|
export const storeRoot = process.env.BEHAVIOR_WRAPPED_STORE_ROOT || path.join(os.homedir(), ".agent-behavior-wrapped");
|
|
7
7
|
export const reportsRoot = path.join(storeRoot, "reports");
|
|
8
|
+
export const donationReceiptsRoot = path.join(storeRoot, "donation-receipts");
|
|
8
9
|
const clientIdFile = path.join(storeRoot, "client-id");
|
|
9
10
|
|
|
10
11
|
function ensureStore() {
|
|
11
12
|
fs.mkdirSync(reportsRoot, { recursive: true, mode: 0o700 });
|
|
13
|
+
fs.mkdirSync(donationReceiptsRoot, { recursive: true, mode: 0o700 });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function saveDonationReceipt(value) {
|
|
17
|
+
if (!/^[0-9a-f-]{36}$/.test(value?.donation_id || "") || !/^[A-Za-z0-9_-]{43}$/.test(value?.deletion_token || "")) throw new Error("The donation service returned an invalid deletion receipt.");
|
|
18
|
+
ensureStore();
|
|
19
|
+
const receipt = { donationId: value.donation_id, deletionToken: value.deletion_token, savedAt: new Date().toISOString() };
|
|
20
|
+
fs.writeFileSync(path.join(donationReceiptsRoot, `${receipt.donationId}.json`), `${JSON.stringify(receipt)}\n`, { mode: 0o600, flag: "wx" });
|
|
21
|
+
return receipt;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function loadDonationReceipt(id) {
|
|
25
|
+
if (!/^[0-9a-f-]{36}$/.test(id || "")) return null;
|
|
26
|
+
try { return JSON.parse(fs.readFileSync(path.join(donationReceiptsRoot, `${id}.json`), "utf8")); }
|
|
27
|
+
catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function deleteDonationReceipt(id) {
|
|
31
|
+
if (!/^[0-9a-f-]{36}$/.test(id || "")) return false;
|
|
32
|
+
const file = path.join(donationReceiptsRoot, `${id}.json`);
|
|
33
|
+
if (!fs.existsSync(file)) return false;
|
|
34
|
+
fs.unlinkSync(file);
|
|
35
|
+
return true;
|
|
12
36
|
}
|
|
13
37
|
|
|
14
38
|
export function createReportId() {
|