agentlas 0.5.5 → 0.7.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/README.md +73 -0
- package/engine/agentlas-capabilities.cjs +34 -3
- package/engine/agentlas-composer.cjs +3 -0
- package/engine/agentlas-experience-exchange.cjs +1401 -0
- package/engine/agentlas-experience-mcp.cjs +1147 -0
- package/engine/agentlas-i18n.cjs +16 -0
- package/engine/agentlas-input.cjs +2 -0
- package/engine/agentlas-repl.cjs +161 -38
- package/engine/agentlas-ui.cjs +5 -1
- package/engine/agentlas.cjs +348 -53
- package/package.json +1 -1
- package/test/cloud-save-publish.cjs +34 -0
- package/test/engine-hardening-regression.cjs +74 -0
- package/test/experience-exchange-contract.cjs +569 -0
- package/test/experience-mcp-contract.cjs +391 -0
- package/test/fixtures/portable-experience-bundle-v1-golden.json +124 -0
- package/test/route-regression.cjs +357 -0
- package/test/runtime-env-protection.cjs +45 -1
- package/test/smoke.sh +4 -0
- package/test/terminal-ui-regression.cjs +26 -3
|
@@ -0,0 +1,1401 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Portable Experience Bundle v1 for the independent Agentlas Terminal.
|
|
5
|
+
*
|
|
6
|
+
* This module owns deterministic, model-free bundle validation, a private
|
|
7
|
+
* local cache, and authenticated Web API exchange. It does not activate a
|
|
8
|
+
* public Experience, create a Variant, accept evaluator authority, or turn a
|
|
9
|
+
* local item into reputation evidence.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const crypto = require("node:crypto");
|
|
13
|
+
const fs = require("node:fs");
|
|
14
|
+
const path = require("node:path");
|
|
15
|
+
|
|
16
|
+
const BUNDLE_SCHEMA = "agentlas.experience-bundle.v1";
|
|
17
|
+
const RECEIPT_SCHEMA = "agentlas.experience-upload-receipt.v1";
|
|
18
|
+
const BASE_RESOLUTION_SCHEMA = "agentlas.experience-base-resolution.v1";
|
|
19
|
+
const STATE_SCHEMA = "agentlas.terminal-experience-exchange.v1";
|
|
20
|
+
const MAX_BUNDLE_CANONICAL_BYTES = 3 * 1024 * 1024;
|
|
21
|
+
const MAX_BUNDLE_FILE_BYTES = 4 * 1024 * 1024;
|
|
22
|
+
const MAX_STATE_BYTES = 4 * 1024 * 1024;
|
|
23
|
+
const MAX_STORED_ITEMS = 256;
|
|
24
|
+
const MAX_MCP_REQUIREMENTS = 64;
|
|
25
|
+
const MAX_EVIDENCE_REFS_PER_ITEM = 24;
|
|
26
|
+
const MAX_INSTRUCTIONS_PER_ITEM = 8;
|
|
27
|
+
const MAX_TASK_SIGNATURES_PER_ITEM = 32;
|
|
28
|
+
const MAX_SOURCE_ATTESTATIONS = MAX_STORED_ITEMS * MAX_EVIDENCE_REFS_PER_ITEM;
|
|
29
|
+
const EXPERIENCE_RETRIEVAL_MAX_ITEMS = 8;
|
|
30
|
+
const EXPERIENCE_RETRIEVAL_MAX_TOKENS = 800;
|
|
31
|
+
const EXCHANGE_LOCK_STALE_MS = 30_000;
|
|
32
|
+
const EXCHANGE_LOCK_WAIT_MS = 2_000;
|
|
33
|
+
|
|
34
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{2,255}$/;
|
|
35
|
+
const HASH_RE = /^sha256:[0-9a-f]{64}$/;
|
|
36
|
+
const BUNDLE_ID_RE = /^exb_[0-9a-f]{48}$/;
|
|
37
|
+
const UPLOAD_ID_RE = /^exu_[0-9a-f]{48}$/;
|
|
38
|
+
const SEMVER_RE = /^v?[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/;
|
|
39
|
+
const ENV_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
40
|
+
const SAFE_IDEMPOTENCY_RE = /^[A-Za-z0-9._:-]{8,200}$/;
|
|
41
|
+
|
|
42
|
+
const SECRET_PATTERNS = [
|
|
43
|
+
/\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/i,
|
|
44
|
+
/\bxox[baprs]-[A-Za-z0-9-]{20,}\b/i,
|
|
45
|
+
/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/,
|
|
46
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
47
|
+
/-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/i,
|
|
48
|
+
/\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|passwd|private[_-]?key|cookie)\b\s*[:=]\s*['"]?[^\s'"]{8,}/i,
|
|
49
|
+
/\bauthorization\b\s*[:=]\s*['"]?(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/i,
|
|
50
|
+
];
|
|
51
|
+
const PII_PATTERNS = [
|
|
52
|
+
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i,
|
|
53
|
+
/(?<!\w)(?:\+?\d[\d ().-]{8,}\d)(?!\w)/,
|
|
54
|
+
/\b(?:account|customer|client|tenant|workspace|user)[ _-]?(?:id|key|number|no)\s*[:=#]?\s*[A-Za-z0-9_-]{4,}\b|(?:계정|고객|사용자)[ _-]?(?:id|아이디|번호)\s*[:=#]?\s*[A-Za-z0-9_-]{4,}/i,
|
|
55
|
+
];
|
|
56
|
+
const LOCAL_PATH_PATTERNS = [
|
|
57
|
+
/(?:file:\/\/|\/(?:Users|home|private|Volumes|var\/folders)\/)/i,
|
|
58
|
+
/\b[A-Za-z]:\\(?:Users|Documents|Desktop|Downloads)\\/i,
|
|
59
|
+
/(?:^|[\s'"`])~\/(?:Desktop|Documents|Downloads|Library|\.ssh|\.config)\//i,
|
|
60
|
+
];
|
|
61
|
+
const RAW_INTERACTION_PATTERNS = [
|
|
62
|
+
/(?:^|\n)\s*(?:system|assistant|user|tool|customer|agent)\s*:\s+/i,
|
|
63
|
+
/['"]role['"]\s*:\s*['"](?:system|assistant|user|tool)['"]/i,
|
|
64
|
+
/<\|(?:system|assistant|user|im_start|im_end)[^>]*\|>/i,
|
|
65
|
+
/BEGIN[ _-]?(?:SYSTEM[ _-]?PROMPT|BASE[ _-]?PROMPT|AGENT[ _-]?PACKAGE)/i,
|
|
66
|
+
/\b(?:AGENTS|CLAUDE|GEMINI)\.md\b|\.agentlas[/\\]/i,
|
|
67
|
+
];
|
|
68
|
+
const PROMPT_INJECTION_PATTERNS = [
|
|
69
|
+
/\b(?:ignore|disregard|override)[\s_-]+(?:all[\s_-]+)?(?:previous|prior|system|developer|hidden)[\s_-]+(?:instructions?|prompts?|rules?)\b/i,
|
|
70
|
+
/\b(?:reveal|show|print|dump|expose|leak)[\s_-]+(?:(?:the|all)[\s_-]+)?(?:(?:hidden|system|developer)[\s_-]+)?(?:prompts?|instructions?|credentials?|secrets?|tokens?|api[\s_-]?keys?)\b/i,
|
|
71
|
+
/\b(?:exfiltrate|steal|upload|send)[^\n]{0,120}\b(?:secrets?|credentials?|tokens?|api[\s_-]?keys?|\.env)\b/i,
|
|
72
|
+
/\b(?:disable|bypass|skip|remove|turn[\s_-]+off)[\s_-]+(?:safety|guardrails?|approval|permission|security)\b/i,
|
|
73
|
+
];
|
|
74
|
+
const BASE_PACKAGE_PATTERNS = [
|
|
75
|
+
/\bcontentBase64\b|\bcloudPackage\b\s*[:=]/i,
|
|
76
|
+
/\b(?:full|raw)\s+(?:system prompt|agent package|base package)\b/i,
|
|
77
|
+
/\bBEGIN AGENTLAS (?:AGENT|PACKAGE)\b/i,
|
|
78
|
+
];
|
|
79
|
+
const OPAQUE_BLOB_RE = /(?:[A-Fa-f0-9]{128,}|[A-Za-z0-9+/]{124,}={0,2})/;
|
|
80
|
+
const FORBIDDEN_KEYS = new Set([
|
|
81
|
+
"basepackage", "basepackagefiles", "baseprompt", "cloudpackage", "contentbase64",
|
|
82
|
+
"files", "fulltranscript", "rawsource", "systemprompt", "transcript", "messages",
|
|
83
|
+
"command", "args", "cwd", "endpoint", "executable", "headers", "serverurl", "transportendpoint",
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
class ExperienceBundleValidationError extends Error {
|
|
87
|
+
constructor(issues) {
|
|
88
|
+
const unique = [...new Set((issues || []).map(String).filter(Boolean))];
|
|
89
|
+
super(unique.join("; ") || "invalid Portable Experience Bundle");
|
|
90
|
+
this.name = "ExperienceBundleValidationError";
|
|
91
|
+
this.code = "invalid_experience_bundle";
|
|
92
|
+
this.issues = unique;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function compareCodePoints(left, right) {
|
|
97
|
+
const a = Array.from(String(left));
|
|
98
|
+
const b = Array.from(String(right));
|
|
99
|
+
const length = Math.min(a.length, b.length);
|
|
100
|
+
for (let index = 0; index < length; index += 1) {
|
|
101
|
+
const delta = a[index].codePointAt(0) - b[index].codePointAt(0);
|
|
102
|
+
if (delta) return delta;
|
|
103
|
+
}
|
|
104
|
+
return a.length - b.length;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeJson(value, seen = new Set()) {
|
|
108
|
+
if (value == null || typeof value === "boolean") return value;
|
|
109
|
+
if (typeof value === "string") return value.normalize("NFC");
|
|
110
|
+
if (typeof value === "number") {
|
|
111
|
+
if (!Number.isFinite(value)) throw new ExperienceBundleValidationError(["canonical JSON forbids non-finite numbers"]);
|
|
112
|
+
return Object.is(value, -0) ? 0 : value;
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(value)) {
|
|
115
|
+
if (seen.has(value)) throw new ExperienceBundleValidationError(["canonical JSON forbids cyclic values"]);
|
|
116
|
+
seen.add(value);
|
|
117
|
+
const result = value.map((child) => normalizeJson(child, seen));
|
|
118
|
+
seen.delete(value);
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
if (!value || typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype) {
|
|
122
|
+
throw new ExperienceBundleValidationError([`canonical JSON forbids ${typeof value}`]);
|
|
123
|
+
}
|
|
124
|
+
if (seen.has(value)) throw new ExperienceBundleValidationError(["canonical JSON forbids cyclic values"]);
|
|
125
|
+
seen.add(value);
|
|
126
|
+
const normalized = Object.create(null);
|
|
127
|
+
for (const rawKey of Object.keys(value)) {
|
|
128
|
+
const key = rawKey.normalize("NFC");
|
|
129
|
+
if (Object.prototype.hasOwnProperty.call(normalized, key)) {
|
|
130
|
+
throw new ExperienceBundleValidationError([`NFC-normalized object key collision: ${key}`]);
|
|
131
|
+
}
|
|
132
|
+
normalized[key] = normalizeJson(value[rawKey], seen);
|
|
133
|
+
}
|
|
134
|
+
seen.delete(value);
|
|
135
|
+
return normalized;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function canonicalValue(value) {
|
|
139
|
+
if (Array.isArray(value)) return value.map(canonicalValue);
|
|
140
|
+
if (value && typeof value === "object") {
|
|
141
|
+
const result = Object.create(null);
|
|
142
|
+
for (const key of Object.keys(value).sort(compareCodePoints)) result[key] = canonicalValue(value[key]);
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function canonicalJson(value) {
|
|
149
|
+
return JSON.stringify(canonicalValue(normalizeJson(value)));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function canonicalHash(value) {
|
|
153
|
+
return `sha256:${crypto.createHash("sha256").update(canonicalJson(value), "utf8").digest("hex")}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function sortedUnique(values) {
|
|
157
|
+
const byCanonical = new Map();
|
|
158
|
+
for (const value of values || []) byCanonical.set(canonicalJson(value), value);
|
|
159
|
+
return [...byCanonical.keys()].sort(compareCodePoints).map((key) => byCanonical.get(key));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function normalizeMcpRequirement(raw) {
|
|
163
|
+
const value = normalizeJson(raw);
|
|
164
|
+
for (const key of ["capabilities", "permissions", "alternatives"]) {
|
|
165
|
+
if (Array.isArray(value[key])) value[key] = sortedUnique(value[key]);
|
|
166
|
+
}
|
|
167
|
+
if (value.credentialMetadata && typeof value.credentialMetadata === "object" && !Array.isArray(value.credentialMetadata)) {
|
|
168
|
+
for (const key of ["env", "allowedHosts", "scopes"]) {
|
|
169
|
+
if (Array.isArray(value.credentialMetadata[key])) value.credentialMetadata[key] = sortedUnique(value.credentialMetadata[key]);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function normalizeExperienceItem(raw) {
|
|
176
|
+
const value = normalizeJson(raw);
|
|
177
|
+
for (const key of ["taskSignatures", "environmentConstraints", "evidenceReceiptIds", "supersedesItemIds"]) {
|
|
178
|
+
if (Array.isArray(value[key])) value[key] = sortedUnique(value[key]);
|
|
179
|
+
}
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function normalizeExperienceBundle(payload) {
|
|
184
|
+
const value = normalizeJson(payload);
|
|
185
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new ExperienceBundleValidationError(["ExperienceBundle must be an object"]);
|
|
186
|
+
if (value.pack && typeof value.pack === "object" && !Array.isArray(value.pack)) {
|
|
187
|
+
if (value.pack.baseCompatibility && typeof value.pack.baseCompatibility === "object" && !Array.isArray(value.pack.baseCompatibility)) {
|
|
188
|
+
const ids = value.pack.baseCompatibility.compatibleBaseReleaseIds;
|
|
189
|
+
if (Array.isArray(ids)) value.pack.baseCompatibility.compatibleBaseReleaseIds = sortedUnique(ids);
|
|
190
|
+
}
|
|
191
|
+
for (const key of ["itemIds", "evidenceReceiptIds"]) {
|
|
192
|
+
if (Array.isArray(value.pack[key])) value.pack[key] = sortedUnique(value.pack[key]);
|
|
193
|
+
}
|
|
194
|
+
if (Array.isArray(value.pack.mcpRequirements)) {
|
|
195
|
+
value.pack.mcpRequirements = sortedUnique(value.pack.mcpRequirements.map((row) => normalizeMcpRequirement(row)));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (Array.isArray(value.items)) value.items = sortedUnique(value.items.map((row) => normalizeExperienceItem(row)));
|
|
199
|
+
if (Array.isArray(value.sourceAttestations)) value.sourceAttestations = sortedUnique(value.sourceAttestations);
|
|
200
|
+
return value;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function experiencePackContentPayload(bundle) {
|
|
204
|
+
const value = normalizeExperienceBundle(bundle);
|
|
205
|
+
const pack = value.pack;
|
|
206
|
+
if (!pack || typeof pack !== "object" || !Array.isArray(value.items)) {
|
|
207
|
+
throw new ExperienceBundleValidationError(["ExperienceBundle needs pack and items before hashing"]);
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
schemaVersion: pack.schemaVersion,
|
|
211
|
+
kind: pack.kind,
|
|
212
|
+
experiencePackId: pack.experiencePackId,
|
|
213
|
+
releaseId: pack.releaseId,
|
|
214
|
+
version: pack.version,
|
|
215
|
+
baseCompatibility: pack.baseCompatibility,
|
|
216
|
+
itemIds: pack.itemIds,
|
|
217
|
+
items: value.items,
|
|
218
|
+
evidenceReceiptIds: pack.evidenceReceiptIds,
|
|
219
|
+
mcpRequirements: pack.mcpRequirements,
|
|
220
|
+
containsBasePackageMaterial: pack.containsBasePackageMaterial,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function experiencePackContentHash(bundle) {
|
|
225
|
+
return canonicalHash(experiencePackContentPayload(bundle));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function experienceBundleHashPayload(bundle) {
|
|
229
|
+
const value = normalizeExperienceBundle(bundle);
|
|
230
|
+
return { content: experiencePackContentPayload(value), sourceAttestations: value.sourceAttestations, privacy: value.privacy };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function experienceBundleHash(bundle) {
|
|
234
|
+
return canonicalHash(experienceBundleHashPayload(bundle));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function experienceBundleId(bundle) {
|
|
238
|
+
return `exb_${experienceBundleHash(bundle).slice("sha256:".length, "sha256:".length + 48)}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function strictObject(value, required, allowed, label, issues) {
|
|
242
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
243
|
+
issues.push(`${label} must be an object`);
|
|
244
|
+
return {};
|
|
245
|
+
}
|
|
246
|
+
const keys = Object.keys(value);
|
|
247
|
+
const missing = [...required].filter((key) => !Object.prototype.hasOwnProperty.call(value, key)).sort(compareCodePoints);
|
|
248
|
+
const unknown = keys.filter((key) => !allowed.has(key)).sort(compareCodePoints);
|
|
249
|
+
if (missing.length) issues.push(`${label} missing required fields: ${missing.join(", ")}`);
|
|
250
|
+
if (unknown.length) issues.push(`${label} contains unknown fields: ${unknown.join(", ")}`);
|
|
251
|
+
return value;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function requiredObject(value, required, label, issues) {
|
|
255
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
256
|
+
issues.push(`${label} must be an object`);
|
|
257
|
+
return {};
|
|
258
|
+
}
|
|
259
|
+
const missing = [...required].filter((key) => !Object.prototype.hasOwnProperty.call(value, key)).sort(compareCodePoints);
|
|
260
|
+
if (missing.length) issues.push(`${label} missing required fields: ${missing.join(", ")}`);
|
|
261
|
+
return value;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function checkId(value, label, issues) {
|
|
265
|
+
if (typeof value !== "string" || !ID_RE.test(value)) issues.push(`${label} must be an opaque stable id`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function checkHash(value, label, issues) {
|
|
269
|
+
if (typeof value !== "string" || !HASH_RE.test(value)) issues.push(`${label} must be sha256:<64 lowercase hex>`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function checkString(value, label, minimum, maximum, issues) {
|
|
273
|
+
if (typeof value !== "string" || value.length < minimum || value.length > maximum) issues.push(`${label} must be a ${minimum}..${maximum} character string`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function checkStringList(value, label, minimum, maximum, issues, options = {}) {
|
|
277
|
+
if (!Array.isArray(value) || value.length < minimum || value.some((item) => typeof item !== "string" || !item)) {
|
|
278
|
+
issues.push(`${label} must contain at least ${minimum} non-empty strings`);
|
|
279
|
+
return [];
|
|
280
|
+
}
|
|
281
|
+
if (maximum != null && value.length > maximum) issues.push(`${label} must contain at most ${maximum} values`);
|
|
282
|
+
if (new Set(value).size !== value.length) issues.push(`${label} must not contain duplicates`);
|
|
283
|
+
if (options.ids) value.forEach((item, index) => checkId(item, `${label}[${index}]`, issues));
|
|
284
|
+
return value;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function checkIso(value, label, issues, nullable = false) {
|
|
288
|
+
if (nullable && value == null) return;
|
|
289
|
+
if (typeof value !== "string") {
|
|
290
|
+
issues.push(`${label} must be an RFC3339 date-time${nullable ? " or null" : ""}`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/.exec(value);
|
|
294
|
+
if (!match) {
|
|
295
|
+
issues.push(`${label} must be an RFC3339 date-time${nullable ? " or null" : ""}`);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const [year, month, day, hour, minute, second] = match.slice(1, 7).map(Number);
|
|
299
|
+
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
300
|
+
const days = [0, 31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
301
|
+
const offset = match[7];
|
|
302
|
+
const offsetValid = offset === "Z" || (Number(offset.slice(1, 3)) <= 23 && Number(offset.slice(4, 6)) <= 59);
|
|
303
|
+
if (year < 1 || month < 1 || month > 12 || day < 1 || day > days[month] || hour > 23 || minute > 59 || second > 59 || !offsetValid || !Number.isFinite(Date.parse(value))) {
|
|
304
|
+
issues.push(`${label} must be a valid RFC3339 date-time${nullable ? " or null" : ""}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function validateCredentialMetadata(value, label, issues) {
|
|
309
|
+
const required = new Set(["provider", "env"]);
|
|
310
|
+
const allowed = new Set(["provider", "env", "allowedHosts", "scopes", "setupUrl", "brokerMode"]);
|
|
311
|
+
const data = strictObject(value, required, allowed, label, issues);
|
|
312
|
+
checkId(data.provider, `${label}.provider`, issues);
|
|
313
|
+
const env = checkStringList(data.env, `${label}.env`, 1, 32, issues);
|
|
314
|
+
env.forEach((item, index) => { if (!ENV_RE.test(item)) issues.push(`${label}.env[${index}] must be uppercase environment name`); });
|
|
315
|
+
if (data.allowedHosts != null) {
|
|
316
|
+
const hosts = checkStringList(data.allowedHosts, `${label}.allowedHosts`, 1, 64, issues);
|
|
317
|
+
const hostRe = /^(?:\*\.)?[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/;
|
|
318
|
+
hosts.forEach((host, index) => { if (host.length > 255 || !hostRe.test(host)) issues.push(`${label}.allowedHosts[${index}] is invalid`); });
|
|
319
|
+
}
|
|
320
|
+
if (data.scopes != null) {
|
|
321
|
+
const scopes = checkStringList(data.scopes, `${label}.scopes`, 1, 64, issues);
|
|
322
|
+
scopes.forEach((scope, index) => { if (!/^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,127}$/.test(scope)) issues.push(`${label}.scopes[${index}] is invalid`); });
|
|
323
|
+
}
|
|
324
|
+
if (data.setupUrl != null && !/^https:\/\/[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*(?:\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?$/.test(data.setupUrl)) {
|
|
325
|
+
issues.push(`${label}.setupUrl must be a value-free HTTPS provider page`);
|
|
326
|
+
}
|
|
327
|
+
if (data.brokerMode != null && !["host-bound-broker", "runtime-env-injection", "provider-managed-oauth", "manual-provider-page"].includes(data.brokerMode)) {
|
|
328
|
+
issues.push(`${label}.brokerMode is invalid`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function validateMcpRequirement(value, label, issues) {
|
|
333
|
+
const requiredFields = new Set(["schemaVersion", "kind", "requirementId", "catalogId", "reason", "capabilities", "required", "requiresKey", "priority", "permissions", "alternatives", "unavailablePolicy"]);
|
|
334
|
+
const data = strictObject(value, requiredFields, new Set([...requiredFields, "credentialMetadata"]), label, issues);
|
|
335
|
+
if (data.schemaVersion !== "agentlas.mcp-requirement.v1") issues.push(`${label}.schemaVersion is unsupported`);
|
|
336
|
+
if (data.kind !== "agentlas-mcp-requirement") issues.push(`${label}.kind is unsupported`);
|
|
337
|
+
checkId(data.requirementId, `${label}.requirementId`, issues);
|
|
338
|
+
checkId(data.catalogId, `${label}.catalogId`, issues);
|
|
339
|
+
checkString(data.reason, `${label}.reason`, 1, 300, issues);
|
|
340
|
+
checkStringList(data.capabilities, `${label}.capabilities`, 1, null, issues, { ids: true });
|
|
341
|
+
checkStringList(data.permissions, `${label}.permissions`, 0, null, issues, { ids: true });
|
|
342
|
+
const alternatives = checkStringList(data.alternatives, `${label}.alternatives`, 0, null, issues, { ids: true });
|
|
343
|
+
if (alternatives.includes(data.catalogId)) issues.push(`${label}.alternatives must not contain catalogId`);
|
|
344
|
+
if (typeof data.required !== "boolean" || typeof data.requiresKey !== "boolean") issues.push(`${label}.required/requiresKey must be boolean`);
|
|
345
|
+
if (!Number.isInteger(data.priority) || data.priority < 1 || data.priority > 1000) issues.push(`${label}.priority must be 1..1000`);
|
|
346
|
+
if (data.credentialMetadata != null) validateCredentialMetadata(data.credentialMetadata, `${label}.credentialMetadata`, issues);
|
|
347
|
+
if (data.requiresKey === true && data.credentialMetadata == null) issues.push(`${label}.requiresKey=true requires credentialMetadata`);
|
|
348
|
+
const policy = strictObject(data.unavailablePolicy, new Set(["build", "rental", "execution"]), new Set(["build", "rental", "execution"]), `${label}.unavailablePolicy`, issues);
|
|
349
|
+
if (policy.build !== "degrade") issues.push(`${label}.unavailablePolicy.build must be degrade`);
|
|
350
|
+
const expectedRental = data.required === true ? "exclude-variant" : "continue-degraded";
|
|
351
|
+
if (policy.rental !== expectedRental) issues.push(`${label}.unavailablePolicy.rental must be ${expectedRental}`);
|
|
352
|
+
if (!["use-alternative", "disable-capability", "continue-degraded"].includes(policy.execution)) issues.push(`${label}.unavailablePolicy.execution is invalid`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function validatePack(pack, issues) {
|
|
356
|
+
const required = new Set(["schemaVersion", "kind", "experiencePackId", "releaseId", "ownerRef", "version", "baseCompatibility", "itemIds", "evidenceReceiptIds", "mcpRequirements", "containsBasePackageMaterial", "contentHash", "visibility", "status"]);
|
|
357
|
+
const data = strictObject(pack, required, new Set([...required, "createdAt", "releasedAt", "withdrawnAt"]), "pack", issues);
|
|
358
|
+
if (data.schemaVersion !== "agentlas.experience-pack.v1") issues.push("pack.schemaVersion is unsupported");
|
|
359
|
+
if (data.kind !== "agentlas-experience-pack") issues.push("pack.kind is unsupported");
|
|
360
|
+
for (const key of ["experiencePackId", "releaseId", "ownerRef"]) checkId(data[key], `pack.${key}`, issues);
|
|
361
|
+
if (typeof data.version !== "string" || data.version.length > 64 || !SEMVER_RE.test(data.version)) issues.push("pack.version must be semantic version");
|
|
362
|
+
const base = strictObject(data.baseCompatibility, new Set(["agentDefinitionId", "compatibleBaseReleaseIds"]), new Set(["agentDefinitionId", "compatibleBaseReleaseIds"]), "pack.baseCompatibility", issues);
|
|
363
|
+
checkId(base.agentDefinitionId, "pack.baseCompatibility.agentDefinitionId", issues);
|
|
364
|
+
checkStringList(base.compatibleBaseReleaseIds, "pack.baseCompatibility.compatibleBaseReleaseIds", 1, 64, issues, { ids: true });
|
|
365
|
+
checkStringList(data.itemIds, "pack.itemIds", data.status === "active" ? 1 : 0, MAX_STORED_ITEMS, issues, { ids: true });
|
|
366
|
+
checkStringList(data.evidenceReceiptIds, "pack.evidenceReceiptIds", 0, MAX_SOURCE_ATTESTATIONS, issues, { ids: true });
|
|
367
|
+
if (!Array.isArray(data.mcpRequirements) || data.mcpRequirements.length > MAX_MCP_REQUIREMENTS) issues.push(`pack.mcpRequirements must contain at most ${MAX_MCP_REQUIREMENTS} requirements`);
|
|
368
|
+
else data.mcpRequirements.forEach((row, index) => validateMcpRequirement(row, `pack.mcpRequirements[${index}]`, issues));
|
|
369
|
+
if (data.containsBasePackageMaterial !== false) issues.push("pack.containsBasePackageMaterial must be false");
|
|
370
|
+
checkHash(data.contentHash, "pack.contentHash", issues);
|
|
371
|
+
if (!["private", "unlisted", "public"].includes(data.visibility)) issues.push("pack.visibility is invalid");
|
|
372
|
+
if (!["draft", "active", "suspended", "withdrawn", "deleted"].includes(data.status)) issues.push("pack.status is invalid");
|
|
373
|
+
if (data.createdAt != null) checkIso(data.createdAt, "pack.createdAt", issues);
|
|
374
|
+
if (Object.prototype.hasOwnProperty.call(data, "releasedAt")) checkIso(data.releasedAt, "pack.releasedAt", issues, true);
|
|
375
|
+
if (Object.prototype.hasOwnProperty.call(data, "withdrawnAt")) checkIso(data.withdrawnAt, "pack.withdrawnAt", issues, true);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function validateItem(item, index, issues) {
|
|
379
|
+
const label = `items[${index}]`;
|
|
380
|
+
const required = new Set(["schemaVersion", "kind", "experienceItemId", "experiencePackId", "experiencePackReleaseId", "type", "summary", "instructions", "taskSignatures", "environmentConstraints", "evidenceReceiptIds", "supersedesItemIds", "confidence", "status", "privacyScope"]);
|
|
381
|
+
const data = strictObject(item, required, new Set([...required, "createdAt"]), label, issues);
|
|
382
|
+
if (data.schemaVersion !== "agentlas.experience-item.v1") issues.push(`${label}.schemaVersion is unsupported`);
|
|
383
|
+
if (data.kind !== "agentlas-experience-item") issues.push(`${label}.kind is unsupported`);
|
|
384
|
+
for (const key of ["experienceItemId", "experiencePackId", "experiencePackReleaseId"]) checkId(data[key], `${label}.${key}`, issues);
|
|
385
|
+
if (!["procedure", "failure-recovery", "environment-gotcha", "tool-affordance", "warning", "supersedes"].includes(data.type)) issues.push(`${label}.type is invalid`);
|
|
386
|
+
checkString(data.summary, `${label}.summary`, 1, 320, issues);
|
|
387
|
+
if (!Array.isArray(data.instructions) || data.instructions.length < 1 || data.instructions.length > MAX_INSTRUCTIONS_PER_ITEM) issues.push(`${label}.instructions must contain 1..${MAX_INSTRUCTIONS_PER_ITEM} values`);
|
|
388
|
+
else data.instructions.forEach((step, stepIndex) => checkString(step, `${label}.instructions[${stepIndex}]`, 1, 600, issues));
|
|
389
|
+
checkStringList(data.taskSignatures, `${label}.taskSignatures`, 1, MAX_TASK_SIGNATURES_PER_ITEM, issues);
|
|
390
|
+
checkStringList(data.environmentConstraints, `${label}.environmentConstraints`, 0, 32, issues);
|
|
391
|
+
checkStringList(data.evidenceReceiptIds, `${label}.evidenceReceiptIds`, 1, MAX_EVIDENCE_REFS_PER_ITEM, issues, { ids: true });
|
|
392
|
+
checkStringList(data.supersedesItemIds, `${label}.supersedesItemIds`, 0, MAX_STORED_ITEMS, issues, { ids: true });
|
|
393
|
+
if (typeof data.confidence !== "number" || !Number.isFinite(data.confidence) || data.confidence < 0 || data.confidence > 1) issues.push(`${label}.confidence must be 0..1`);
|
|
394
|
+
if (!["candidate", "promoted", "deprecated", "rejected"].includes(data.status)) issues.push(`${label}.status is invalid`);
|
|
395
|
+
if (!["private", "public-safe"].includes(data.privacyScope)) issues.push(`${label}.privacyScope is invalid`);
|
|
396
|
+
if (data.createdAt != null) checkIso(data.createdAt, `${label}.createdAt`, issues);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function metadataString(value) {
|
|
400
|
+
return HASH_RE.test(value) || BUNDLE_ID_RE.test(value) || UPLOAD_ID_RE.test(value) || /^[a-z]{3}_[0-9a-f]{32,64}$/.test(value) || /^\d{4}-\d{2}-\d{2}T\S+$/.test(value);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function validateBundleSecurity(value, issues) {
|
|
404
|
+
const strings = [];
|
|
405
|
+
function walk(node, prefix = "") {
|
|
406
|
+
if (Array.isArray(node)) return node.forEach((child, index) => walk(child, `${prefix}[${index}]`));
|
|
407
|
+
if (node && typeof node === "object") {
|
|
408
|
+
for (const [key, child] of Object.entries(node)) {
|
|
409
|
+
const normalizedKey = key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
410
|
+
const next = prefix ? `${prefix}.${key}` : key;
|
|
411
|
+
if (FORBIDDEN_KEYS.has(normalizedKey)) issues.push(`ExperienceBundle forbids executable/raw field ${next}`);
|
|
412
|
+
walk(child, next);
|
|
413
|
+
}
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (typeof node === "string") strings.push(node);
|
|
417
|
+
}
|
|
418
|
+
walk(value);
|
|
419
|
+
const nonMetadata = strings.filter((text) => !metadataString(text));
|
|
420
|
+
const checks = [
|
|
421
|
+
[SECRET_PATTERNS, strings, "secret or credential value"],
|
|
422
|
+
[PII_PATTERNS, nonMetadata, "personal/customer identifier"],
|
|
423
|
+
[LOCAL_PATH_PATTERNS, strings, "absolute local path or file URL"],
|
|
424
|
+
[RAW_INTERACTION_PATTERNS, strings, "raw prompt, transcript, or base package marker"],
|
|
425
|
+
[PROMPT_INJECTION_PATTERNS, strings, "prompt-injection instruction"],
|
|
426
|
+
[BASE_PACKAGE_PATTERNS, strings, "base package material"],
|
|
427
|
+
];
|
|
428
|
+
for (const [patterns, candidates, label] of checks) {
|
|
429
|
+
if (patterns.some((pattern) => candidates.some((candidate) => pattern.test(candidate)))) issues.push(`ExperienceBundle contains ${label}`);
|
|
430
|
+
}
|
|
431
|
+
if (nonMetadata.some((text) => OPAQUE_BLOB_RE.test(text))) issues.push("ExperienceBundle contains a long opaque encoded blob");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function validateExperienceBundle(payload) {
|
|
435
|
+
const value = normalizeExperienceBundle(payload);
|
|
436
|
+
const issues = [];
|
|
437
|
+
const required = new Set(["schemaVersion", "kind", "bundleId", "bundleHash", "requestedVisibility", "pack", "items", "sourceAttestations", "privacy"]);
|
|
438
|
+
strictObject(value, required, required, "ExperienceBundle", issues);
|
|
439
|
+
if (value.schemaVersion !== BUNDLE_SCHEMA) issues.push(`schemaVersion must equal ${BUNDLE_SCHEMA}`);
|
|
440
|
+
if (value.kind !== "agentlas-experience-bundle") issues.push("kind must equal agentlas-experience-bundle");
|
|
441
|
+
if (!["private", "unlisted", "public"].includes(value.requestedVisibility)) issues.push("requestedVisibility is invalid");
|
|
442
|
+
validatePack(value.pack, issues);
|
|
443
|
+
|
|
444
|
+
let items = value.items;
|
|
445
|
+
if (!Array.isArray(items) || items.length < 1 || items.length > MAX_STORED_ITEMS) {
|
|
446
|
+
issues.push(`items must contain 1..${MAX_STORED_ITEMS} items`);
|
|
447
|
+
items = [];
|
|
448
|
+
}
|
|
449
|
+
const itemIds = [];
|
|
450
|
+
const evidenceIds = [];
|
|
451
|
+
items.forEach((item, index) => {
|
|
452
|
+
validateItem(item, index, issues);
|
|
453
|
+
if (typeof item.experienceItemId === "string") itemIds.push(item.experienceItemId);
|
|
454
|
+
if (item.experiencePackId !== value.pack?.experiencePackId) issues.push(`items[${index}].experiencePackId does not match pack`);
|
|
455
|
+
if (item.experiencePackReleaseId !== value.pack?.releaseId) issues.push(`items[${index}].experiencePackReleaseId does not match pack release`);
|
|
456
|
+
if (Array.isArray(item.evidenceReceiptIds)) evidenceIds.push(...item.evidenceReceiptIds.filter((entry) => typeof entry === "string"));
|
|
457
|
+
});
|
|
458
|
+
if (new Set(itemIds).size !== itemIds.length) issues.push("items must have unique experienceItemId values");
|
|
459
|
+
if (canonicalJson(value.pack?.itemIds || []) !== canonicalJson(sortedUnique(itemIds))) issues.push("pack.itemIds must exactly equal submitted item ids");
|
|
460
|
+
if (canonicalJson(value.pack?.evidenceReceiptIds || []) !== canonicalJson(sortedUnique(evidenceIds))) issues.push("pack.evidenceReceiptIds must exactly equal item evidence ids");
|
|
461
|
+
|
|
462
|
+
let attestations = value.sourceAttestations;
|
|
463
|
+
if (!Array.isArray(attestations) || attestations.length > MAX_SOURCE_ATTESTATIONS) {
|
|
464
|
+
issues.push(`sourceAttestations must contain at most ${MAX_SOURCE_ATTESTATIONS} entries`);
|
|
465
|
+
attestations = [];
|
|
466
|
+
}
|
|
467
|
+
const attestationFields = new Set(["kind", "experienceItemId", "evidenceHash"]);
|
|
468
|
+
attestations.forEach((row, index) => {
|
|
469
|
+
const data = strictObject(row, attestationFields, attestationFields, `sourceAttestations[${index}]`, issues);
|
|
470
|
+
if (data.kind !== "user-attested") issues.push(`sourceAttestations[${index}].kind must be user-attested`);
|
|
471
|
+
if (!itemIds.includes(data.experienceItemId)) issues.push(`sourceAttestations[${index}] references missing item`);
|
|
472
|
+
checkHash(data.evidenceHash, `sourceAttestations[${index}].evidenceHash`, issues);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const privacyFields = new Set(["basePackageMaterialIncluded", "rawPromptIncluded", "rawTranscriptIncluded", "rawLocalPathsIncluded", "credentialValuesIncluded"]);
|
|
476
|
+
const privacy = strictObject(value.privacy, privacyFields, privacyFields, "privacy", issues);
|
|
477
|
+
for (const flag of privacyFields) if (privacy[flag] !== false) issues.push(`privacy.${flag} must be false`);
|
|
478
|
+
validateBundleSecurity(value, issues);
|
|
479
|
+
|
|
480
|
+
const canonicalBytes = Buffer.byteLength(canonicalJson(value), "utf8");
|
|
481
|
+
if (canonicalBytes > MAX_BUNDLE_CANONICAL_BYTES) issues.push(`canonical ExperienceBundle exceeds ${MAX_BUNDLE_CANONICAL_BYTES} bytes`);
|
|
482
|
+
let expectedPackHash = null;
|
|
483
|
+
let expectedBundleHash = null;
|
|
484
|
+
try {
|
|
485
|
+
expectedPackHash = experiencePackContentHash(value);
|
|
486
|
+
expectedBundleHash = experienceBundleHash(value);
|
|
487
|
+
if (value.pack?.contentHash !== expectedPackHash) issues.push("pack.contentHash does not match canonical Experience content");
|
|
488
|
+
if (value.bundleHash !== expectedBundleHash) issues.push("bundleHash does not match canonical bundle content");
|
|
489
|
+
const expectedId = `exb_${expectedBundleHash.slice(7, 55)}`;
|
|
490
|
+
if (value.bundleId !== expectedId || !BUNDLE_ID_RE.test(String(value.bundleId || ""))) issues.push("bundleId must be derived from bundleHash");
|
|
491
|
+
} catch (error) {
|
|
492
|
+
if (error instanceof ExperienceBundleValidationError) issues.push(...error.issues);
|
|
493
|
+
else throw error;
|
|
494
|
+
}
|
|
495
|
+
if (issues.length) throw new ExperienceBundleValidationError(issues);
|
|
496
|
+
return { bundle: value, canonicalJson: canonicalJson(value), canonicalBytes, expectedPackHash, expectedBundleHash, expectedBundleId: experienceBundleId(value) };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function readBundleFile(filePath, cwd = process.cwd()) {
|
|
500
|
+
const absolute = path.resolve(cwd, filePath);
|
|
501
|
+
const stat = fs.lstatSync(absolute);
|
|
502
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Experience bundle must be a regular file; symlinks are forbidden");
|
|
503
|
+
if (stat.size < 1 || stat.size > MAX_BUNDLE_FILE_BYTES) throw new Error(`Experience bundle file must be 1..${MAX_BUNDLE_FILE_BYTES} bytes`);
|
|
504
|
+
let payload;
|
|
505
|
+
try { payload = JSON.parse(fs.readFileSync(absolute, "utf8")); } catch (error) { throw new Error(`Experience bundle is invalid JSON: ${error.message}`); }
|
|
506
|
+
return { absolute, ...validateExperienceBundle(payload) };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function recoverPrivateAtomicTarget(filePath, options = {}) {
|
|
510
|
+
const fsImpl = options.fs || fs;
|
|
511
|
+
const backup = `${filePath}.previous`;
|
|
512
|
+
if (!fsImpl.existsSync(backup)) return;
|
|
513
|
+
const backupStat = fsImpl.lstatSync(backup);
|
|
514
|
+
if (!backupStat.isFile() || backupStat.isSymbolicLink()) throw new Error("private atomic backup is unsafe");
|
|
515
|
+
if (fsImpl.existsSync(filePath)) {
|
|
516
|
+
const targetStat = fsImpl.lstatSync(filePath);
|
|
517
|
+
if (!targetStat.isFile() || targetStat.isSymbolicLink()) throw new Error("private atomic target is unsafe");
|
|
518
|
+
fsImpl.rmSync(backup, { force: true });
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
fsImpl.renameSync(backup, filePath);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function replacePrivateFileAtomic(temp, filePath, options = {}) {
|
|
525
|
+
const fsImpl = options.fs || fs;
|
|
526
|
+
const platform = options.platform || process.platform;
|
|
527
|
+
recoverPrivateAtomicTarget(filePath, { fs: fsImpl });
|
|
528
|
+
try {
|
|
529
|
+
fsImpl.renameSync(temp, filePath);
|
|
530
|
+
return;
|
|
531
|
+
} catch (error) {
|
|
532
|
+
if (platform !== "win32" || !["EEXIST", "EPERM", "EACCES"].includes(error?.code) || !fsImpl.existsSync(filePath)) throw error;
|
|
533
|
+
}
|
|
534
|
+
const backup = `${filePath}.previous`;
|
|
535
|
+
fsImpl.renameSync(filePath, backup);
|
|
536
|
+
try {
|
|
537
|
+
fsImpl.renameSync(temp, filePath);
|
|
538
|
+
} catch (error) {
|
|
539
|
+
try {
|
|
540
|
+
if (!fsImpl.existsSync(filePath) && fsImpl.existsSync(backup)) fsImpl.renameSync(backup, filePath);
|
|
541
|
+
} catch (rollbackError) {
|
|
542
|
+
error.rollbackError = rollbackError;
|
|
543
|
+
}
|
|
544
|
+
throw error;
|
|
545
|
+
}
|
|
546
|
+
try { fsImpl.rmSync(backup, { force: true }); } catch { /* target is committed; recover/cleanup on the next access */ }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function writePrivateJsonAtomic(filePath, value) {
|
|
550
|
+
const dir = path.dirname(filePath);
|
|
551
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
552
|
+
try { fs.chmodSync(dir, 0o700); } catch { /* best effort on Windows */ }
|
|
553
|
+
const temp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`);
|
|
554
|
+
try {
|
|
555
|
+
fs.writeFileSync(temp, JSON.stringify(value, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
556
|
+
replacePrivateFileAtomic(temp, filePath);
|
|
557
|
+
try { fs.chmodSync(filePath, 0o600); } catch { /* best effort on Windows */ }
|
|
558
|
+
} finally {
|
|
559
|
+
try { fs.rmSync(temp, { force: true }); } catch { /* noop */ }
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function writePrivateTextAtomic(filePath, text) {
|
|
564
|
+
const dir = path.dirname(filePath);
|
|
565
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
566
|
+
try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
|
|
567
|
+
const temp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`);
|
|
568
|
+
try {
|
|
569
|
+
fs.writeFileSync(temp, text.endsWith("\n") ? text : `${text}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
570
|
+
replacePrivateFileAtomic(temp, filePath);
|
|
571
|
+
try { fs.chmodSync(filePath, 0o600); } catch { /* best effort */ }
|
|
572
|
+
} finally {
|
|
573
|
+
try { fs.rmSync(temp, { force: true }); } catch { /* noop */ }
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function exchangeStatePath(userDataDir) {
|
|
578
|
+
return path.join(userDataDir, "terminal", "experience-exchange-v1.json");
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function bundleStorePath(userDataDir, bundleId) {
|
|
582
|
+
if (!BUNDLE_ID_RE.test(String(bundleId || ""))) throw new Error("invalid bundle id");
|
|
583
|
+
return path.join(userDataDir, "terminal", "experience-bundles-v1", `${bundleId}.agentlas-experience.json`);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function emptyState() {
|
|
587
|
+
return { schemaVersion: STATE_SCHEMA, updatedAt: null, bundles: [] };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function waitSync(milliseconds) {
|
|
591
|
+
const signal = new Int32Array(new SharedArrayBuffer(4));
|
|
592
|
+
Atomics.wait(signal, 0, 0, milliseconds);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function withExchangeStateLock(userDataDir, action) {
|
|
596
|
+
const stateFile = exchangeStatePath(userDataDir);
|
|
597
|
+
const dir = path.dirname(stateFile);
|
|
598
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
599
|
+
try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
|
|
600
|
+
const lockFile = `${stateFile}.lock`;
|
|
601
|
+
const deadline = Date.now() + EXCHANGE_LOCK_WAIT_MS;
|
|
602
|
+
let descriptor = null;
|
|
603
|
+
while (descriptor == null) {
|
|
604
|
+
try {
|
|
605
|
+
descriptor = fs.openSync(lockFile, "wx", 0o600);
|
|
606
|
+
fs.writeFileSync(descriptor, `${process.pid}\n${new Date().toISOString()}\n`, "utf8");
|
|
607
|
+
} catch (error) {
|
|
608
|
+
if (!error || error.code !== "EEXIST") throw error;
|
|
609
|
+
try {
|
|
610
|
+
const stat = fs.lstatSync(lockFile);
|
|
611
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Terminal Experience exchange lock is unsafe");
|
|
612
|
+
if (Date.now() - stat.mtimeMs > EXCHANGE_LOCK_STALE_MS) {
|
|
613
|
+
fs.unlinkSync(lockFile);
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
} catch (statError) {
|
|
617
|
+
if (statError?.code === "ENOENT") continue;
|
|
618
|
+
throw statError;
|
|
619
|
+
}
|
|
620
|
+
if (Date.now() >= deadline) throw new Error("Terminal Experience exchange state is busy; retry the command");
|
|
621
|
+
waitSync(25);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
try {
|
|
625
|
+
return action();
|
|
626
|
+
} finally {
|
|
627
|
+
try { fs.closeSync(descriptor); } catch { /* noop */ }
|
|
628
|
+
try { fs.unlinkSync(lockFile); } catch { /* stale recovery handles leftovers */ }
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function loadExchangeState(userDataDir) {
|
|
633
|
+
const file = exchangeStatePath(userDataDir);
|
|
634
|
+
recoverPrivateAtomicTarget(file);
|
|
635
|
+
if (!fs.existsSync(file)) return emptyState();
|
|
636
|
+
const stat = fs.lstatSync(file);
|
|
637
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_STATE_BYTES) throw new Error("Terminal Experience exchange state is unsafe or too large");
|
|
638
|
+
const state = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
639
|
+
const allowed = new Set(["schemaVersion", "updatedAt", "bundles"]);
|
|
640
|
+
const issues = [];
|
|
641
|
+
strictObject(state, allowed, allowed, "exchange state", issues);
|
|
642
|
+
if (state.schemaVersion !== STATE_SCHEMA || !Array.isArray(state.bundles)) issues.push("exchange state schema is invalid");
|
|
643
|
+
if (state.updatedAt != null) checkIso(state.updatedAt, "exchange state.updatedAt", issues, true);
|
|
644
|
+
if (state.bundles?.length > 2048) issues.push("exchange state has too many bundle records");
|
|
645
|
+
for (const [index, row] of (state.bundles || []).entries()) {
|
|
646
|
+
const required = new Set(["bundleId", "bundleHash", "experiencePackId", "experiencePackReleaseId", "agentDefinitionId", "compatibleBaseReleaseIds", "projectScopeHash", "storedAt", "remote"]);
|
|
647
|
+
strictObject(row, required, required, `exchange state.bundles[${index}]`, issues);
|
|
648
|
+
if (!BUNDLE_ID_RE.test(String(row.bundleId || ""))) issues.push(`exchange state.bundles[${index}].bundleId is invalid`);
|
|
649
|
+
checkHash(row.bundleHash, `exchange state.bundles[${index}].bundleHash`, issues);
|
|
650
|
+
for (const key of ["experiencePackId", "experiencePackReleaseId", "agentDefinitionId"]) checkId(row[key], `exchange state.bundles[${index}].${key}`, issues);
|
|
651
|
+
checkStringList(row.compatibleBaseReleaseIds, `exchange state.bundles[${index}].compatibleBaseReleaseIds`, 1, 64, issues, { ids: true });
|
|
652
|
+
checkHash(row.projectScopeHash, `exchange state.bundles[${index}].projectScopeHash`, issues);
|
|
653
|
+
checkIso(row.storedAt, `exchange state.bundles[${index}].storedAt`, issues);
|
|
654
|
+
if (row.remote != null) {
|
|
655
|
+
const remoteLabel = `exchange state.bundles[${index}].remote`;
|
|
656
|
+
if (typeof row.remote !== "object" || Array.isArray(row.remote)) issues.push(`${remoteLabel} is invalid`);
|
|
657
|
+
else {
|
|
658
|
+
const allowedRemote = new Set(["uploadId", "status", "requestedVisibility", "revision", "serverCheckedAt", "receipt", "baseResolution"]);
|
|
659
|
+
strictObject(row.remote, new Set(["uploadId", "status", "requestedVisibility", "revision", "serverCheckedAt", "receipt"]), allowedRemote, remoteLabel, issues);
|
|
660
|
+
if (!UPLOAD_ID_RE.test(String(row.remote.uploadId || ""))) issues.push(`${remoteLabel}.uploadId is invalid`);
|
|
661
|
+
if (!/^rev_[0-9a-f]{32}$/.test(String(row.remote.revision || ""))) issues.push(`${remoteLabel}.revision is invalid`);
|
|
662
|
+
checkIso(row.remote.serverCheckedAt, `${remoteLabel}.serverCheckedAt`, issues);
|
|
663
|
+
try {
|
|
664
|
+
const receipt = validateUploadReceipt(row.remote.receipt, null);
|
|
665
|
+
if (receipt.uploadId !== row.remote.uploadId || receipt.revision !== row.remote.revision || receipt.status !== row.remote.status || receipt.requestedVisibility !== row.remote.requestedVisibility) {
|
|
666
|
+
issues.push(`${remoteLabel} projection does not match its receipt`);
|
|
667
|
+
}
|
|
668
|
+
} catch (error) {
|
|
669
|
+
issues.push(...(error.issues || [`${remoteLabel}.receipt is invalid`]));
|
|
670
|
+
}
|
|
671
|
+
if (row.remote.baseResolution != null) {
|
|
672
|
+
const base = row.remote.baseResolution;
|
|
673
|
+
const requiredBase = new Set(["schema", "cloudId", "slug", "agentDefinitionId", "agentReleaseId", "packageHash", "packageHashVersion"]);
|
|
674
|
+
strictObject(base, requiredBase, requiredBase, `${remoteLabel}.baseResolution`, issues);
|
|
675
|
+
if (base.schema !== BASE_RESOLUTION_SCHEMA) issues.push(`${remoteLabel}.baseResolution schema is invalid`);
|
|
676
|
+
for (const key of ["cloudId", "agentDefinitionId", "agentReleaseId"]) checkId(base[key], `${remoteLabel}.baseResolution.${key}`, issues);
|
|
677
|
+
if (typeof base.slug !== "string" || !/^[a-z0-9][a-z0-9._-]{0,95}$/.test(base.slug)) issues.push(`${remoteLabel}.baseResolution.slug is invalid`);
|
|
678
|
+
checkHash(base.packageHash, `${remoteLabel}.baseResolution.packageHash`, issues);
|
|
679
|
+
if (!["path-sha256-v1", "path-sha256-executable-v2"].includes(base.packageHashVersion)) issues.push(`${remoteLabel}.baseResolution.packageHashVersion is invalid`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (issues.length) throw new ExperienceBundleValidationError(issues);
|
|
685
|
+
return state;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function saveExchangeState(userDataDir, state) {
|
|
689
|
+
state.updatedAt = new Date().toISOString();
|
|
690
|
+
writePrivateJsonAtomic(exchangeStatePath(userDataDir), state);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function projectScopeHash(cwd) {
|
|
694
|
+
let resolved = path.resolve(cwd || process.cwd());
|
|
695
|
+
try { resolved = fs.realpathSync.native(resolved); } catch { /* resolved path is still deterministic locally */ }
|
|
696
|
+
return canonicalHash({ kind: "terminal-project-scope", path: resolved.normalize("NFC") });
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function saveLocalBundle(userDataDir, validation, options = {}) {
|
|
700
|
+
const bundle = validation.bundle;
|
|
701
|
+
return withExchangeStateLock(userDataDir, () => {
|
|
702
|
+
const storedPath = bundleStorePath(userDataDir, bundle.bundleId);
|
|
703
|
+
writePrivateTextAtomic(storedPath, validation.canonicalJson);
|
|
704
|
+
const state = loadExchangeState(userDataDir);
|
|
705
|
+
const now = new Date().toISOString();
|
|
706
|
+
const previous = state.bundles.find((row) => row.bundleId === bundle.bundleId);
|
|
707
|
+
const row = {
|
|
708
|
+
bundleId: bundle.bundleId,
|
|
709
|
+
bundleHash: bundle.bundleHash,
|
|
710
|
+
experiencePackId: bundle.pack.experiencePackId,
|
|
711
|
+
experiencePackReleaseId: bundle.pack.releaseId,
|
|
712
|
+
agentDefinitionId: bundle.pack.baseCompatibility.agentDefinitionId,
|
|
713
|
+
compatibleBaseReleaseIds: [...bundle.pack.baseCompatibility.compatibleBaseReleaseIds],
|
|
714
|
+
projectScopeHash: projectScopeHash(options.cwd),
|
|
715
|
+
storedAt: now,
|
|
716
|
+
remote: previous?.remote || null,
|
|
717
|
+
};
|
|
718
|
+
const index = state.bundles.findIndex((item) => item.bundleId === row.bundleId);
|
|
719
|
+
if (index >= 0) state.bundles[index] = row;
|
|
720
|
+
else state.bundles.push(row);
|
|
721
|
+
saveExchangeState(userDataDir, state);
|
|
722
|
+
return row;
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function readStoredBundle(userDataDir, bundleId) {
|
|
727
|
+
const file = bundleStorePath(userDataDir, bundleId);
|
|
728
|
+
recoverPrivateAtomicTarget(file);
|
|
729
|
+
return readBundleFile(file, "/");
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function resolveBundleInput(userDataDir, sourceOrRef, cwd) {
|
|
733
|
+
if (!sourceOrRef) throw new Error("an Experience bundle file or saved bundle id is required");
|
|
734
|
+
if (BUNDLE_ID_RE.test(sourceOrRef)) return readStoredBundle(userDataDir, sourceOrRef);
|
|
735
|
+
return readBundleFile(sourceOrRef, cwd);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function parseFlags(args) {
|
|
739
|
+
const flags = { _: [] };
|
|
740
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
741
|
+
const token = String(args[index]);
|
|
742
|
+
if (!token.startsWith("--")) { flags._.push(token); continue; }
|
|
743
|
+
const equal = token.indexOf("=");
|
|
744
|
+
if (equal > 2) { flags[token.slice(2, equal)] = token.slice(equal + 1); continue; }
|
|
745
|
+
const key = token.slice(2);
|
|
746
|
+
if (index + 1 < args.length && !String(args[index + 1]).startsWith("--")) flags[key] = String(args[++index]);
|
|
747
|
+
else flags[key] = true;
|
|
748
|
+
}
|
|
749
|
+
return flags;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function idempotencyKeyForBundle(bundle, explicit, operation = "save") {
|
|
753
|
+
const defaultDigest = crypto.createHash("sha256")
|
|
754
|
+
.update(canonicalJson({ bundleHash: bundle.bundleHash, operation, requestedVisibility: bundle.requestedVisibility }), "utf8")
|
|
755
|
+
.digest("hex");
|
|
756
|
+
const key = explicit || `exb-${defaultDigest}`;
|
|
757
|
+
if (!SAFE_IDEMPOTENCY_RE.test(key)) throw new Error("Idempotency-Key must be 8..200 safe ASCII characters");
|
|
758
|
+
return key;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function idempotencyKeyHash(key) {
|
|
762
|
+
return `sha256:${crypto.createHash("sha256").update(key, "utf8").digest("hex")}`;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function trustedExperienceOrigin(rawValue, options = {}) {
|
|
766
|
+
let parsed;
|
|
767
|
+
try { parsed = new URL(String(rawValue || "https://agentlas.cloud")); }
|
|
768
|
+
catch { throw new Error("Agentlas Experience Web origin is invalid"); }
|
|
769
|
+
if (parsed.username || parsed.password || parsed.search || parsed.hash || (parsed.pathname !== "/" && parsed.pathname !== "")) {
|
|
770
|
+
throw new Error("Agentlas Experience Web origin must not contain userinfo, path, query, or fragment");
|
|
771
|
+
}
|
|
772
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
773
|
+
const loopback = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
|
|
774
|
+
const loopbackOptIn = options.allowLoopback === true || options.env?.AGENTLAS_EXPERIENCE_ALLOW_LOOPBACK === "1";
|
|
775
|
+
if (loopback) {
|
|
776
|
+
if (!loopbackOptIn || !["http:", "https:"].includes(parsed.protocol)) {
|
|
777
|
+
throw new Error("Loopback Experience Web origin requires explicit AGENTLAS_EXPERIENCE_ALLOW_LOOPBACK=1 opt-in");
|
|
778
|
+
}
|
|
779
|
+
} else {
|
|
780
|
+
const agentlasHost = hostname === "agentlas.cloud" || hostname.endsWith(".agentlas.cloud");
|
|
781
|
+
if (parsed.protocol !== "https:" || !agentlasHost || (parsed.port && parsed.port !== "443")) {
|
|
782
|
+
throw new Error("Authenticated Experience exchange is restricted to an HTTPS Agentlas origin");
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return parsed.origin;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function parseResponseJson(response, label) {
|
|
789
|
+
try { return JSON.parse(response.text || "null"); } catch { throw new Error(`${label} returned invalid JSON`); }
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function responseError(response, label) {
|
|
793
|
+
let data = null;
|
|
794
|
+
try { data = JSON.parse(response.text || "null"); } catch { /* generic below */ }
|
|
795
|
+
const error = new Error(`${label} failed (${response.status})${data?.error ? `: ${String(data.error).slice(0, 300)}` : ""}`);
|
|
796
|
+
error.status = response.status;
|
|
797
|
+
error.code = data?.code || (response.status === 401 || response.status === 403 ? "authentication_refused" : "experience_exchange_failed");
|
|
798
|
+
error.details = data;
|
|
799
|
+
return error;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async function authenticatedContext(options, dryRun) {
|
|
803
|
+
if (dryRun) return null;
|
|
804
|
+
const configured = options.baseUrl || options.env?.AGENTLAS_WEB_BASE_URL || process.env.AGENTLAS_WEB_BASE_URL || "https://agentlas.cloud";
|
|
805
|
+
const root = trustedExperienceOrigin(configured, options);
|
|
806
|
+
const getSessionCookie = options.getSessionCookie;
|
|
807
|
+
const cookie = typeof getSessionCookie === "function" ? await getSessionCookie() : options.sessionCookie;
|
|
808
|
+
if (!cookie) {
|
|
809
|
+
const error = new Error("Agentlas Cloud login is required; no Experience request was sent");
|
|
810
|
+
error.code = "authentication_required";
|
|
811
|
+
throw error;
|
|
812
|
+
}
|
|
813
|
+
if (typeof cookie !== "string" || cookie.length > 4096 || !/^agentlas_session=[A-Za-z0-9._~+/=%-]{1,4070}$/.test(cookie)) {
|
|
814
|
+
const error = new Error("Agentlas Cloud session cookie is malformed; no Experience request was sent");
|
|
815
|
+
error.code = "invalid_session_cookie";
|
|
816
|
+
throw error;
|
|
817
|
+
}
|
|
818
|
+
if (typeof options.fetchHub !== "function") throw new Error("authenticated Hub fetch boundary is unavailable");
|
|
819
|
+
return { cookie, origin: root, base: `${root}/api/experience/v1` };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function validateBaseResolution(value, bundle, requestedDescriptor) {
|
|
823
|
+
const required = new Set(["schema", "cloudId", "slug", "agentDefinitionId", "agentReleaseId", "packageHash", "packageHashVersion"]);
|
|
824
|
+
const issues = [];
|
|
825
|
+
const data = requiredObject(value, required, "ExperienceBaseResolution", issues);
|
|
826
|
+
if (data.schema !== BASE_RESOLUTION_SCHEMA) issues.push("base resolution schema is invalid");
|
|
827
|
+
for (const key of ["cloudId", "agentDefinitionId", "agentReleaseId"]) checkId(data[key], `base resolution.${key}`, issues);
|
|
828
|
+
if (typeof data.slug !== "string" || !/^[a-z0-9][a-z0-9._-]{0,95}$/.test(data.slug)) issues.push("base resolution.slug is invalid");
|
|
829
|
+
checkHash(data.packageHash, "base resolution.packageHash", issues);
|
|
830
|
+
if (!["path-sha256-v1", "path-sha256-executable-v2"].includes(data.packageHashVersion)) issues.push("base resolution.packageHashVersion is invalid");
|
|
831
|
+
if (data.agentDefinitionId !== bundle.pack.baseCompatibility.agentDefinitionId || !bundle.pack.baseCompatibility.compatibleBaseReleaseIds.includes(data.agentReleaseId)) {
|
|
832
|
+
issues.push("base resolution does not match the exact bundle base ids");
|
|
833
|
+
}
|
|
834
|
+
if (requestedDescriptor.cloudId && data.cloudId !== requestedDescriptor.cloudId) issues.push("base resolution.cloudId mismatches request");
|
|
835
|
+
if (requestedDescriptor.slug && data.slug !== requestedDescriptor.slug) issues.push("base resolution.slug mismatches request");
|
|
836
|
+
if (data.packageHash !== requestedDescriptor.packageHash) issues.push("base resolution.packageHash mismatches request");
|
|
837
|
+
if (requestedDescriptor.packageHashVersion && data.packageHashVersion !== requestedDescriptor.packageHashVersion) issues.push("base resolution.packageHashVersion mismatches request");
|
|
838
|
+
if (issues.length) throw new ExperienceBundleValidationError(issues);
|
|
839
|
+
return {
|
|
840
|
+
schema: data.schema,
|
|
841
|
+
cloudId: data.cloudId,
|
|
842
|
+
slug: data.slug,
|
|
843
|
+
agentDefinitionId: data.agentDefinitionId,
|
|
844
|
+
agentReleaseId: data.agentReleaseId,
|
|
845
|
+
packageHash: data.packageHash,
|
|
846
|
+
packageHashVersion: data.packageHashVersion,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function normalizeBaseDescriptor(options, existing) {
|
|
851
|
+
const descriptor = {
|
|
852
|
+
...(existing || {}),
|
|
853
|
+
...(options.baseDescriptor || {}),
|
|
854
|
+
};
|
|
855
|
+
const request = {
|
|
856
|
+
...(descriptor.slug ? { slug: String(descriptor.slug) } : {}),
|
|
857
|
+
...(descriptor.cloudId ? { cloudId: String(descriptor.cloudId) } : {}),
|
|
858
|
+
packageHash: String(descriptor.packageHash || ""),
|
|
859
|
+
...(descriptor.packageHashVersion ? { packageHashVersion: String(descriptor.packageHashVersion) } : {}),
|
|
860
|
+
};
|
|
861
|
+
if (!request.slug && !request.cloudId) throw new Error("exact base preflight requires --base-slug or --base-cloud-id");
|
|
862
|
+
if (!HASH_RE.test(request.packageHash)) throw new Error("exact base preflight requires --base-package-hash sha256:<64 hex>");
|
|
863
|
+
if (request.packageHashVersion && !["path-sha256-v1", "path-sha256-executable-v2"].includes(request.packageHashVersion)) {
|
|
864
|
+
throw new Error("--base-package-hash-version is invalid");
|
|
865
|
+
}
|
|
866
|
+
return request;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
async function resolveBaseRelease(bundle, options, auth, existingDescriptor) {
|
|
870
|
+
const descriptor = normalizeBaseDescriptor(options, existingDescriptor);
|
|
871
|
+
const response = await options.fetchHub(`${auth.base}/base-releases/resolve`, {
|
|
872
|
+
method: "POST",
|
|
873
|
+
headers: { "content-type": "application/json", cookie: auth.cookie, origin: auth.origin },
|
|
874
|
+
body: JSON.stringify(descriptor),
|
|
875
|
+
});
|
|
876
|
+
if (!response.ok) throw responseError(response, "exact base release preflight");
|
|
877
|
+
const body = parseResponseJson(response, "exact base release preflight");
|
|
878
|
+
return validateBaseResolution(body.baseResolution || body, bundle, descriptor);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function validateUploadReceipt(receipt, bundle) {
|
|
882
|
+
const required = new Set(["schema", "uploadId", "bundleId", "bundleHash", "experiencePackId", "experienceReleaseId", "ownerWorkspaceRef", "status", "requestedVisibility", "revision", "createdAt", "updatedAt"]);
|
|
883
|
+
const issues = [];
|
|
884
|
+
const data = requiredObject(receipt, required, "ExperienceUploadReceipt", issues);
|
|
885
|
+
if (data.schema !== RECEIPT_SCHEMA) issues.push("ExperienceUploadReceipt schema is invalid");
|
|
886
|
+
if (!UPLOAD_ID_RE.test(String(data.uploadId || ""))) issues.push("uploadId is invalid");
|
|
887
|
+
if (!BUNDLE_ID_RE.test(String(data.bundleId || ""))) issues.push("receipt.bundleId is invalid");
|
|
888
|
+
checkHash(data.bundleHash, "receipt.bundleHash", issues);
|
|
889
|
+
for (const key of ["experiencePackId", "experienceReleaseId", "ownerWorkspaceRef"]) checkId(data[key], `receipt.${key}`, issues);
|
|
890
|
+
if (!["draft-saved", "verification-requested", "verification-pending", "verified-private", "public-active", "conflict", "withdrawn", "rejected"].includes(data.status)) issues.push("receipt.status is invalid");
|
|
891
|
+
if (!["private", "unlisted", "public"].includes(data.requestedVisibility)) issues.push("receipt.requestedVisibility is invalid");
|
|
892
|
+
if (typeof data.revision !== "string" || !/^rev_[0-9a-f]{32}$/.test(data.revision)) issues.push("receipt.revision is invalid");
|
|
893
|
+
checkIso(data.createdAt, "receipt.createdAt", issues);
|
|
894
|
+
checkIso(data.updatedAt, "receipt.updatedAt", issues);
|
|
895
|
+
if (data.errorCode != null && !/^[a-z0-9][a-z0-9._-]{0,95}$/.test(data.errorCode)) issues.push("receipt.errorCode is invalid");
|
|
896
|
+
if (bundle) {
|
|
897
|
+
if (data.bundleId !== bundle.bundleId || data.bundleHash !== bundle.bundleHash || data.experiencePackId !== bundle.pack.experiencePackId || data.experienceReleaseId !== bundle.pack.releaseId || data.requestedVisibility !== bundle.requestedVisibility) {
|
|
898
|
+
issues.push("server receipt does not match the submitted bundle");
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
if (issues.length) throw new ExperienceBundleValidationError(issues);
|
|
902
|
+
return {
|
|
903
|
+
schema: data.schema,
|
|
904
|
+
uploadId: data.uploadId,
|
|
905
|
+
bundleId: data.bundleId,
|
|
906
|
+
bundleHash: data.bundleHash,
|
|
907
|
+
experiencePackId: data.experiencePackId,
|
|
908
|
+
experienceReleaseId: data.experienceReleaseId,
|
|
909
|
+
ownerWorkspaceRef: data.ownerWorkspaceRef,
|
|
910
|
+
status: data.status,
|
|
911
|
+
requestedVisibility: data.requestedVisibility,
|
|
912
|
+
revision: data.revision,
|
|
913
|
+
createdAt: data.createdAt,
|
|
914
|
+
updatedAt: data.updatedAt,
|
|
915
|
+
...(data.errorCode != null ? { errorCode: data.errorCode } : {}),
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function persistRemoteReceipt(userDataDir, bundle, receipt, baseResolution = null) {
|
|
920
|
+
try {
|
|
921
|
+
return withExchangeStateLock(userDataDir, () => {
|
|
922
|
+
const state = loadExchangeState(userDataDir);
|
|
923
|
+
const row = state.bundles.find((item) => item.bundleId === bundle.bundleId);
|
|
924
|
+
if (!row) throw new Error("local bundle record disappeared before receipt persistence");
|
|
925
|
+
row.remote = {
|
|
926
|
+
uploadId: receipt.uploadId,
|
|
927
|
+
status: receipt.status,
|
|
928
|
+
requestedVisibility: receipt.requestedVisibility,
|
|
929
|
+
revision: receipt.revision,
|
|
930
|
+
serverCheckedAt: new Date().toISOString(),
|
|
931
|
+
receipt,
|
|
932
|
+
...(baseResolution ? { baseResolution } : row.remote?.baseResolution ? { baseResolution: row.remote.baseResolution } : {}),
|
|
933
|
+
};
|
|
934
|
+
saveExchangeState(userDataDir, state);
|
|
935
|
+
return row;
|
|
936
|
+
});
|
|
937
|
+
} catch (error) {
|
|
938
|
+
const stateError = new Error(
|
|
939
|
+
`Experience was accepted by the server as ${receipt.uploadId}, but Terminal could not persist the authoritative receipt. ` +
|
|
940
|
+
"Do not change the bundle or Idempotency-Key; rerun the same save/publish command to reconcile the same receipt.",
|
|
941
|
+
);
|
|
942
|
+
stateError.code = "AGENTLAS_EXPERIENCE_LOCAL_STATE_COMMIT_FAILED";
|
|
943
|
+
stateError.receipt = receipt;
|
|
944
|
+
stateError.bundleId = bundle.bundleId;
|
|
945
|
+
stateError.cause = error;
|
|
946
|
+
throw stateError;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
async function recoverLostUpload(bundle, idempotencyKey, options, auth, originalError) {
|
|
951
|
+
const query = new URLSearchParams({ bundleId: bundle.bundleId });
|
|
952
|
+
let response;
|
|
953
|
+
try {
|
|
954
|
+
response = await options.fetchHub(`${auth.base}/uploads?${query.toString()}`, {
|
|
955
|
+
method: "GET",
|
|
956
|
+
headers: { accept: "application/json", cookie: auth.cookie, origin: auth.origin, "Idempotency-Key": idempotencyKey },
|
|
957
|
+
});
|
|
958
|
+
} catch (recoveryError) {
|
|
959
|
+
originalError.recoveryError = recoveryError;
|
|
960
|
+
throw originalError;
|
|
961
|
+
}
|
|
962
|
+
if (!response.ok) {
|
|
963
|
+
originalError.recoveryStatus = response.status;
|
|
964
|
+
throw originalError;
|
|
965
|
+
}
|
|
966
|
+
const body = parseResponseJson(response, "lost upload recovery");
|
|
967
|
+
const receipt = validateUploadReceipt(body.receipt, bundle);
|
|
968
|
+
const etag = response.headers && typeof response.headers.get === "function" ? response.headers.get("etag") : null;
|
|
969
|
+
if (etag !== `"${receipt.revision}"`) throw new Error("lost upload recovery ETag does not match the receipt revision");
|
|
970
|
+
return { receipt, replayed: true, recovered: true };
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
async function publishBundle(validation, options = {}) {
|
|
974
|
+
const originalBundle = validation.bundle;
|
|
975
|
+
const requestedVisibility = options.operation === "save"
|
|
976
|
+
? "private"
|
|
977
|
+
: String(options.requestedVisibility || originalBundle.requestedVisibility);
|
|
978
|
+
if (options.operation === "publish" && !["unlisted", "public"].includes(requestedVisibility)) {
|
|
979
|
+
throw new Error("experience publish requires requested visibility unlisted or public; use experience save for a private draft");
|
|
980
|
+
}
|
|
981
|
+
const bundle = normalizeExperienceBundle({ ...originalBundle, requestedVisibility });
|
|
982
|
+
const normalizedValidation = validateExperienceBundle(bundle);
|
|
983
|
+
const dryRun = options.dryRun === true;
|
|
984
|
+
const operation = options.operation === "publish" ? "publish" : "save";
|
|
985
|
+
const key = idempotencyKeyForBundle(bundle, options.idempotencyKey, operation);
|
|
986
|
+
if (dryRun) {
|
|
987
|
+
return { dryRun: true, networkUsed: false, bundleId: bundle.bundleId, bundleHash: bundle.bundleHash, requestedVisibility: bundle.requestedVisibility, publicActivation: false, evaluatorAuthority: false };
|
|
988
|
+
}
|
|
989
|
+
const auth = await authenticatedContext(options, false);
|
|
990
|
+
const existingState = loadExchangeState(options.userDataDir);
|
|
991
|
+
const existingRow = findStateRecord(existingState, bundle.bundleId);
|
|
992
|
+
const exactBaseDescriptor = normalizeBaseDescriptor(options, existingRow?.remote?.baseResolution);
|
|
993
|
+
const savedRow = saveLocalBundle(options.userDataDir, normalizedValidation, { cwd: options.cwd });
|
|
994
|
+
const baseRelease = await resolveBaseRelease(bundle, { ...options, baseDescriptor: exactBaseDescriptor }, auth, savedRow.remote?.baseResolution);
|
|
995
|
+
let response;
|
|
996
|
+
try {
|
|
997
|
+
response = await options.fetchHub(`${auth.base}/uploads`, {
|
|
998
|
+
method: "POST",
|
|
999
|
+
headers: { "content-type": "application/json", cookie: auth.cookie, origin: auth.origin, "Idempotency-Key": key, "If-None-Match": "*" },
|
|
1000
|
+
body: JSON.stringify({ bundle }),
|
|
1001
|
+
});
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
const recovered = await recoverLostUpload(bundle, key, options, auth, error);
|
|
1004
|
+
persistRemoteReceipt(options.userDataDir, bundle, recovered.receipt, baseRelease);
|
|
1005
|
+
return { ...recovered, dryRun: false, networkUsed: true, baseRelease, publicActivation: false, evaluatorAuthority: false };
|
|
1006
|
+
}
|
|
1007
|
+
if (!response.ok) throw responseError(response, "Experience draft upload");
|
|
1008
|
+
const body = parseResponseJson(response, "Experience draft upload");
|
|
1009
|
+
if (typeof body.replayed !== "boolean" || (response.status === 201 && body.replayed !== false) || (response.status === 200 && body.replayed !== true)) {
|
|
1010
|
+
throw new Error("Experience upload returned an invalid replay marker");
|
|
1011
|
+
}
|
|
1012
|
+
const receipt = validateUploadReceipt(body.receipt, bundle);
|
|
1013
|
+
const expectedStatus = operation === "publish" ? "verification-requested" : "draft-saved";
|
|
1014
|
+
if (receipt.status !== expectedStatus) throw new Error(`Experience ${operation} receipt must be ${expectedStatus}, never ${receipt.status}`);
|
|
1015
|
+
const etag = response.headers && typeof response.headers.get === "function" ? response.headers.get("etag") : null;
|
|
1016
|
+
if (etag !== `"${receipt.revision}"`) throw new Error("Experience upload ETag does not match the exact receipt revision");
|
|
1017
|
+
persistRemoteReceipt(options.userDataDir, bundle, receipt, baseRelease);
|
|
1018
|
+
return { receipt, replayed: body.replayed, recovered: false, dryRun: false, networkUsed: true, baseRelease, publicActivation: false, evaluatorAuthority: false };
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function findStateRecord(state, ref) {
|
|
1022
|
+
const matches = state.bundles.filter((row) => [row.bundleId, row.bundleHash, row.experiencePackId, row.experiencePackReleaseId, row.remote?.uploadId].includes(ref));
|
|
1023
|
+
return matches.sort((a, b) => String(b.storedAt).localeCompare(String(a.storedAt)))[0] || null;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
async function fetchUploadStatus(ref, options = {}) {
|
|
1027
|
+
const auth = await authenticatedContext(options, false);
|
|
1028
|
+
const state = loadExchangeState(options.userDataDir);
|
|
1029
|
+
const row = findStateRecord(state, ref);
|
|
1030
|
+
const uploadId = UPLOAD_ID_RE.test(String(ref || "")) ? ref : row?.remote?.uploadId;
|
|
1031
|
+
if (!uploadId) throw new Error("no server upload id is known for this local bundle");
|
|
1032
|
+
const response = await options.fetchHub(`${auth.base}/uploads/${encodeURIComponent(uploadId)}`, {
|
|
1033
|
+
method: "GET",
|
|
1034
|
+
headers: { accept: "application/json", cookie: auth.cookie, origin: auth.origin },
|
|
1035
|
+
});
|
|
1036
|
+
if (!response.ok) throw responseError(response, "Experience upload status");
|
|
1037
|
+
const body = parseResponseJson(response, "Experience upload status");
|
|
1038
|
+
const bundle = row ? readStoredBundle(options.userDataDir, row.bundleId).bundle : null;
|
|
1039
|
+
const receipt = validateUploadReceipt(body.receipt, bundle);
|
|
1040
|
+
if (receipt.uploadId !== uploadId) throw new Error("status receipt id mismatch");
|
|
1041
|
+
const etag = response.headers && typeof response.headers.get === "function" ? response.headers.get("etag") : null;
|
|
1042
|
+
if (etag !== `"${receipt.revision}"`) throw new Error("status ETag does not match the exact receipt revision");
|
|
1043
|
+
if (row) persistRemoteReceipt(options.userDataDir, bundle, receipt);
|
|
1044
|
+
return { receipt, authoritative: "server", publicActivation: false, evaluatorAuthority: false };
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function assertSafeExportTarget(filePath, overwrite) {
|
|
1048
|
+
if (!fs.existsSync(filePath)) return;
|
|
1049
|
+
const stat = fs.lstatSync(filePath);
|
|
1050
|
+
if (stat.isSymbolicLink()) throw new Error("Experience export output must not be a symbolic link");
|
|
1051
|
+
if (!stat.isFile()) throw new Error("Experience export output must be an ordinary file path");
|
|
1052
|
+
if (!overwrite) throw new Error("Experience export output already exists; pass --overwrite to replace that exact regular file");
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function writePrivateExportAtomic(filePath, text, overwrite) {
|
|
1056
|
+
const dir = path.dirname(filePath);
|
|
1057
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1058
|
+
try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
|
|
1059
|
+
assertSafeExportTarget(filePath, overwrite);
|
|
1060
|
+
const temp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.export.tmp`);
|
|
1061
|
+
try {
|
|
1062
|
+
fs.writeFileSync(temp, text.endsWith("\n") ? text : `${text}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
1063
|
+
if (overwrite) {
|
|
1064
|
+
replacePrivateFileAtomic(temp, filePath);
|
|
1065
|
+
} else {
|
|
1066
|
+
// Same-directory hard-link publication is atomic and no-clobber: an
|
|
1067
|
+
// output created after the preflight causes EEXIST instead of overwrite.
|
|
1068
|
+
fs.linkSync(temp, filePath);
|
|
1069
|
+
fs.unlinkSync(temp);
|
|
1070
|
+
}
|
|
1071
|
+
try { fs.chmodSync(filePath, 0o600); } catch { /* best effort on Windows */ }
|
|
1072
|
+
} finally {
|
|
1073
|
+
try { fs.rmSync(temp, { force: true }); } catch { /* noop */ }
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
async function fetchUploadExport(ref, options = {}) {
|
|
1078
|
+
const state = loadExchangeState(options.userDataDir);
|
|
1079
|
+
const row = findStateRecord(state, ref);
|
|
1080
|
+
const uploadId = UPLOAD_ID_RE.test(String(ref || "")) ? ref : row?.remote?.uploadId;
|
|
1081
|
+
if (!uploadId) throw new Error("export requires a server upload id");
|
|
1082
|
+
const requestedOutput = options.outputPath
|
|
1083
|
+
? path.resolve(options.cwd || process.cwd(), options.outputPath)
|
|
1084
|
+
: row
|
|
1085
|
+
? path.resolve(options.cwd || process.cwd(), `${row.bundleId}.agentlas-experience.json`)
|
|
1086
|
+
: null;
|
|
1087
|
+
if (requestedOutput) assertSafeExportTarget(requestedOutput, options.overwrite === true);
|
|
1088
|
+
const auth = await authenticatedContext(options, false);
|
|
1089
|
+
const response = await options.fetchHub(`${auth.base}/uploads/${encodeURIComponent(uploadId)}/export`, {
|
|
1090
|
+
method: "GET",
|
|
1091
|
+
headers: { accept: "application/json", cookie: auth.cookie, origin: auth.origin },
|
|
1092
|
+
});
|
|
1093
|
+
if (!response.ok) throw responseError(response, "Experience export");
|
|
1094
|
+
const body = parseResponseJson(response, "Experience export");
|
|
1095
|
+
const validation = validateExperienceBundle(body.bundle);
|
|
1096
|
+
const receipt = validateUploadReceipt(body.receipt, validation.bundle);
|
|
1097
|
+
if (receipt.uploadId !== uploadId) throw new Error("export receipt id mismatch");
|
|
1098
|
+
if (validation.bundle.pack.ownerRef !== receipt.ownerWorkspaceRef) throw new Error("export bundle ownerRef does not match the authenticated receipt owner");
|
|
1099
|
+
if (row && (validation.bundle.bundleId !== row.bundleId || validation.bundle.bundleHash !== row.bundleHash)) {
|
|
1100
|
+
throw new Error("exported Experience semantics do not match the exact local bundle identity");
|
|
1101
|
+
}
|
|
1102
|
+
const etag = response.headers && typeof response.headers.get === "function" ? response.headers.get("etag") : null;
|
|
1103
|
+
if (etag !== `"${receipt.revision}"`) throw new Error("export ETag does not match the exact receipt revision");
|
|
1104
|
+
const outputPath = requestedOutput || path.resolve(options.cwd || process.cwd(), `${validation.bundle.bundleId}.agentlas-experience.json`);
|
|
1105
|
+
assertSafeExportTarget(outputPath, options.overwrite === true);
|
|
1106
|
+
writePrivateExportAtomic(outputPath, validation.canonicalJson, options.overwrite === true);
|
|
1107
|
+
if (row) persistRemoteReceipt(options.userDataDir, validation.bundle, receipt);
|
|
1108
|
+
return {
|
|
1109
|
+
outputPath,
|
|
1110
|
+
bundleId: validation.bundle.bundleId,
|
|
1111
|
+
bundleHash: validation.bundle.bundleHash,
|
|
1112
|
+
canonicalBytes: validation.canonicalBytes,
|
|
1113
|
+
uploadId: receipt.uploadId,
|
|
1114
|
+
status: receipt.status,
|
|
1115
|
+
revision: receipt.revision,
|
|
1116
|
+
authoritative: "server",
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
async function withdrawUpload(ref, options = {}) {
|
|
1121
|
+
const auth = await authenticatedContext(options, false);
|
|
1122
|
+
const state = loadExchangeState(options.userDataDir);
|
|
1123
|
+
const row = findStateRecord(state, ref);
|
|
1124
|
+
const uploadId = UPLOAD_ID_RE.test(String(ref || "")) ? ref : row?.remote?.uploadId;
|
|
1125
|
+
if (!uploadId) throw new Error("withdraw requires a server upload id");
|
|
1126
|
+
if (!row?.remote?.revision) throw new Error("withdraw requires the exact locally observed server revision; run experience status first");
|
|
1127
|
+
const response = await options.fetchHub(`${auth.base}/uploads/${encodeURIComponent(uploadId)}`, {
|
|
1128
|
+
method: "DELETE",
|
|
1129
|
+
headers: { accept: "application/json", cookie: auth.cookie, origin: auth.origin, "If-Match": `"${row.remote.revision}"` },
|
|
1130
|
+
});
|
|
1131
|
+
if (!response.ok) {
|
|
1132
|
+
if (response.status === 412) {
|
|
1133
|
+
const body = parseResponseJson(response, "Experience withdrawal conflict");
|
|
1134
|
+
const current = body.current?.receipt || body.current || body.receipt;
|
|
1135
|
+
if (current) {
|
|
1136
|
+
const bundle = readStoredBundle(options.userDataDir, row.bundleId).bundle;
|
|
1137
|
+
const receipt = validateUploadReceipt(current, bundle);
|
|
1138
|
+
persistRemoteReceipt(options.userDataDir, bundle, receipt);
|
|
1139
|
+
const error = new Error("Experience withdrawal revision is stale; current server receipt was reconciled locally. Review status and retry.");
|
|
1140
|
+
error.code = "experience_revision_conflict";
|
|
1141
|
+
error.status = 412;
|
|
1142
|
+
error.current = receipt;
|
|
1143
|
+
throw error;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
throw responseError(response, "Experience withdrawal (server support may be unavailable)");
|
|
1147
|
+
}
|
|
1148
|
+
const body = parseResponseJson(response, "Experience withdrawal");
|
|
1149
|
+
const bundle = readStoredBundle(options.userDataDir, row.bundleId).bundle;
|
|
1150
|
+
const receipt = validateUploadReceipt(body.receipt || body, bundle);
|
|
1151
|
+
if (receipt.uploadId !== uploadId || receipt.status !== "withdrawn") throw new Error("withdrawal did not return the exact withdrawn server receipt");
|
|
1152
|
+
const etag = response.headers && typeof response.headers.get === "function" ? response.headers.get("etag") : null;
|
|
1153
|
+
if (etag !== `"${receipt.revision}"`) throw new Error("withdrawal ETag does not match the new server revision");
|
|
1154
|
+
if (row) {
|
|
1155
|
+
persistRemoteReceipt(options.userDataDir, bundle, receipt);
|
|
1156
|
+
}
|
|
1157
|
+
return { receipt, authoritative: "server", publicActivation: false };
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function defaultEnvironmentTags(options = {}) {
|
|
1161
|
+
const platform = options.platform || process.platform;
|
|
1162
|
+
const arch = options.arch || process.arch;
|
|
1163
|
+
const platformName = platform === "darwin" ? "macos" : platform === "win32" ? "windows" : platform;
|
|
1164
|
+
const tags = new Set([`${platformName}-${arch}`]);
|
|
1165
|
+
const locale = options.locale || (() => { try { return Intl.DateTimeFormat().resolvedOptions().locale; } catch { return null; } })();
|
|
1166
|
+
if (locale) tags.add(String(locale));
|
|
1167
|
+
for (const tag of options.extra || []) if (tag) tags.add(String(tag));
|
|
1168
|
+
return [...tags];
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function estimateTokens(text) {
|
|
1172
|
+
return Math.ceil(Buffer.byteLength(String(text || ""), "utf8") / 3);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function buildLocalExperienceAdvisory(options = {}) {
|
|
1176
|
+
const empty = { text: "", itemIds: [], estimatedTokens: 0, authority: "local-advisory", serverRentalResolutionReceiptPresent: false };
|
|
1177
|
+
if (!options.userDataDir || !options.cwd || !ID_RE.test(String(options.baseAgentReleaseId || ""))) return empty;
|
|
1178
|
+
const taskSignatures = new Set((options.taskSignatures || []).map(String).filter(Boolean));
|
|
1179
|
+
if (!taskSignatures.size) return empty;
|
|
1180
|
+
const environmentTags = new Set((options.environmentTags || defaultEnvironmentTags(options)).map(String));
|
|
1181
|
+
const state = loadExchangeState(options.userDataDir);
|
|
1182
|
+
const projectHash = projectScopeHash(options.cwd);
|
|
1183
|
+
const candidates = [];
|
|
1184
|
+
for (const row of state.bundles) {
|
|
1185
|
+
if (row.projectScopeHash !== projectHash || !row.compatibleBaseReleaseIds.includes(options.baseAgentReleaseId)) continue;
|
|
1186
|
+
if (options.agentDefinitionId && row.agentDefinitionId !== options.agentDefinitionId) continue;
|
|
1187
|
+
let validation;
|
|
1188
|
+
try { validation = readStoredBundle(options.userDataDir, row.bundleId); } catch { continue; }
|
|
1189
|
+
for (const item of validation.bundle.items) {
|
|
1190
|
+
if (item.status !== "promoted") continue;
|
|
1191
|
+
if (!item.taskSignatures.some((signature) => taskSignatures.has(signature))) continue;
|
|
1192
|
+
if (!item.environmentConstraints.every((constraint) => environmentTags.has(constraint))) continue;
|
|
1193
|
+
candidates.push(item);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
candidates.sort((a, b) => Number(b.confidence) - Number(a.confidence) || compareCodePoints(a.experienceItemId, b.experienceItemId));
|
|
1197
|
+
const header = "[AGENTLAS_LOCAL_EXPERIENCE_ADVISORY v1] NO SERVER RENTAL-RESOLUTION RECEIPT. Local user-attested procedures only; not evaluator-verified and not reputation evidence.";
|
|
1198
|
+
let text = header;
|
|
1199
|
+
const itemIds = [];
|
|
1200
|
+
for (const item of candidates) {
|
|
1201
|
+
if (itemIds.length >= EXPERIENCE_RETRIEVAL_MAX_ITEMS || itemIds.includes(item.experienceItemId)) continue;
|
|
1202
|
+
const line = `\n- [${item.experienceItemId}] ${item.summary}\n Steps: ${item.instructions.join(" | ")}`;
|
|
1203
|
+
const next = `${text}${line}`;
|
|
1204
|
+
if (estimateTokens(next) > EXPERIENCE_RETRIEVAL_MAX_TOKENS) continue;
|
|
1205
|
+
text = next;
|
|
1206
|
+
itemIds.push(item.experienceItemId);
|
|
1207
|
+
}
|
|
1208
|
+
if (!itemIds.length) return empty;
|
|
1209
|
+
return { text, itemIds, estimatedTokens: estimateTokens(text), authority: "local-advisory", serverRentalResolutionReceiptPresent: false };
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function augmentRuntimeSystemWithLocalExperience(systemPrompt, options = {}) {
|
|
1213
|
+
const context = buildLocalExperienceAdvisory(options);
|
|
1214
|
+
return {
|
|
1215
|
+
systemPrompt: context.text ? `${String(systemPrompt || "")}\n\n${context.text}` : String(systemPrompt || ""),
|
|
1216
|
+
experienceContext: context,
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function renderValidation(validation) {
|
|
1221
|
+
return [
|
|
1222
|
+
`Portable Experience valid: ${validation.bundle.bundleId}`,
|
|
1223
|
+
`pack: ${validation.bundle.pack.experiencePackId}@${validation.bundle.pack.version}`,
|
|
1224
|
+
`items: ${validation.bundle.items.length} / ${MAX_STORED_ITEMS} · canonical bytes: ${validation.canonicalBytes} / ${MAX_BUNDLE_CANONICAL_BYTES}`,
|
|
1225
|
+
"Privacy scan: passed · base package/raw prompt/transcript/path/credential material: absent",
|
|
1226
|
+
"Authority: local deterministic validation only; no server receipt, evaluator, reputation, Variant, or public activation.",
|
|
1227
|
+
].join("\n");
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function renderPublish(result) {
|
|
1231
|
+
if (result.dryRun) return `DRY RUN · ${result.bundleId} validated · network used: no · server state unchanged · public activation: not performed`;
|
|
1232
|
+
return [
|
|
1233
|
+
`Server-authoritative Experience upload receipt: ${result.receipt.uploadId}`,
|
|
1234
|
+
`state: ${result.receipt.status} · requested visibility: ${result.receipt.requestedVisibility}`,
|
|
1235
|
+
`idempotency: ${result.replayed ? "same receipt replayed" : "first accepted receipt"}${result.recovered ? " · recovered after lost response" : ""}`,
|
|
1236
|
+
"Public activation/evaluator verification/reputation: NOT performed or claimed.",
|
|
1237
|
+
].join("\n");
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function baseDescriptorFromFlags(flags) {
|
|
1241
|
+
return {
|
|
1242
|
+
...(flags["base-slug"] ? { slug: flags["base-slug"] } : {}),
|
|
1243
|
+
...(flags["base-cloud-id"] ? { cloudId: flags["base-cloud-id"] } : {}),
|
|
1244
|
+
...(flags["base-package-hash"] ? { packageHash: flags["base-package-hash"] } : {}),
|
|
1245
|
+
...(flags["base-package-hash-version"] ? { packageHashVersion: flags["base-package-hash-version"] } : {}),
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
async function cmdExperienceExchange(options = {}) {
|
|
1250
|
+
const args = options.args || [];
|
|
1251
|
+
const sub = args[0] || "list";
|
|
1252
|
+
const flags = parseFlags(args.slice(1));
|
|
1253
|
+
const emit = options.out || console.log;
|
|
1254
|
+
if (!options.userDataDir) throw new Error("Terminal userData path is required");
|
|
1255
|
+
|
|
1256
|
+
if (sub === "help" || sub === "--help" || sub === "-h") {
|
|
1257
|
+
const help = [
|
|
1258
|
+
"agentlas experience validate <bundle.agentlas-experience.json>",
|
|
1259
|
+
"agentlas experience save <bundle> --base-cloud-id <id>|--base-slug <slug> --base-package-hash sha256:<hash>",
|
|
1260
|
+
"agentlas experience publish <bundle> --visibility unlisted|public --base-cloud-id <id>|--base-slug <slug> --base-package-hash sha256:<hash>",
|
|
1261
|
+
"agentlas experience status <bundle-id|upload-id>",
|
|
1262
|
+
"agentlas experience withdraw <bundle-id|upload-id>",
|
|
1263
|
+
"agentlas experience export <bundle-id|upload-id> [--out file] [--overwrite]",
|
|
1264
|
+
"Options: --dry-run (zero network/write), --idempotency-key <safe-key>, save --local-only",
|
|
1265
|
+
"Legacy local intents: list|inspect|unpublish|legacy-publish",
|
|
1266
|
+
"publish requests verification only; Terminal never claims evaluator verification or public activation.",
|
|
1267
|
+
].join("\n");
|
|
1268
|
+
emit(help);
|
|
1269
|
+
return { help: true };
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
if (["list", "ls", "inspect", "show", "unpublish", "legacy-publish", "legacy-unpublish"].includes(sub)) {
|
|
1273
|
+
if (typeof options.legacyCommand !== "function") throw new Error("legacy local-intent Experience handler is unavailable");
|
|
1274
|
+
const mapped = sub === "legacy-publish" ? "publish" : sub === "legacy-unpublish" ? "unpublish" : sub;
|
|
1275
|
+
return options.legacyCommand({ ...options, args: [mapped, ...args.slice(1)] });
|
|
1276
|
+
}
|
|
1277
|
+
if (sub === "validate") {
|
|
1278
|
+
const validation = readBundleFile(flags._[0], options.cwd);
|
|
1279
|
+
const result = { valid: true, bundleId: validation.bundle.bundleId, bundleHash: validation.bundle.bundleHash, packContentHash: validation.bundle.pack.contentHash, items: validation.bundle.items.length, canonicalBytes: validation.canonicalBytes, networkUsed: false, authority: "local-validation" };
|
|
1280
|
+
emit(flags.json ? JSON.stringify(result, null, 2) : renderValidation(validation));
|
|
1281
|
+
return result;
|
|
1282
|
+
}
|
|
1283
|
+
if (sub === "save") {
|
|
1284
|
+
const validation = readBundleFile(flags._[0], options.cwd);
|
|
1285
|
+
if (flags["local-only"] === true) {
|
|
1286
|
+
if (flags["dry-run"] === true) {
|
|
1287
|
+
const result = { dryRun: true, saved: false, networkUsed: false, bundleId: validation.bundle.bundleId };
|
|
1288
|
+
emit(flags.json ? JSON.stringify(result, null, 2) : `DRY RUN · ${validation.bundle.bundleId} validated · no file saved · network used: no`);
|
|
1289
|
+
return result;
|
|
1290
|
+
}
|
|
1291
|
+
const row = saveLocalBundle(options.userDataDir, validation, { cwd: options.cwd });
|
|
1292
|
+
const result = { saved: true, localOnly: true, networkUsed: false, bundleId: row.bundleId, projectScopeHash: row.projectScopeHash, serverReceiptPresent: false };
|
|
1293
|
+
emit(flags.json ? JSON.stringify(result, null, 2) : `Local 0600 Experience bundle saved: ${row.bundleId}\nHub: not contacted · server receipt: none · public activation: none`);
|
|
1294
|
+
return result;
|
|
1295
|
+
}
|
|
1296
|
+
const result = await publishBundle(validation, {
|
|
1297
|
+
...options,
|
|
1298
|
+
operation: "save",
|
|
1299
|
+
dryRun: flags["dry-run"] === true,
|
|
1300
|
+
idempotencyKey: flags["idempotency-key"] || null,
|
|
1301
|
+
baseDescriptor: baseDescriptorFromFlags(flags),
|
|
1302
|
+
});
|
|
1303
|
+
emit(flags.json ? JSON.stringify(result, null, 2) : renderPublish(result));
|
|
1304
|
+
return result;
|
|
1305
|
+
}
|
|
1306
|
+
if (sub === "publish") {
|
|
1307
|
+
// Compatibility: the old pack-only command remains a local-intent alias.
|
|
1308
|
+
const source = flags._[0];
|
|
1309
|
+
if (source && !BUNDLE_ID_RE.test(source)) {
|
|
1310
|
+
const absolute = path.resolve(options.cwd || process.cwd(), source);
|
|
1311
|
+
try {
|
|
1312
|
+
const parsed = JSON.parse(fs.readFileSync(absolute, "utf8"));
|
|
1313
|
+
if (parsed?.schemaVersion === "agentlas.experience-pack.v1") {
|
|
1314
|
+
if (typeof options.legacyCommand !== "function") throw new Error("legacy handler unavailable");
|
|
1315
|
+
return options.legacyCommand({ ...options, args: ["publish", ...args.slice(1)] });
|
|
1316
|
+
}
|
|
1317
|
+
} catch (error) {
|
|
1318
|
+
if (error?.code !== "ENOENT" && !String(error?.message || "").includes("JSON")) throw error;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
const validation = resolveBundleInput(options.userDataDir, source, options.cwd);
|
|
1322
|
+
const result = await publishBundle(validation, {
|
|
1323
|
+
...options,
|
|
1324
|
+
operation: "publish",
|
|
1325
|
+
requestedVisibility: flags.visibility || validation.bundle.requestedVisibility,
|
|
1326
|
+
dryRun: flags["dry-run"] === true,
|
|
1327
|
+
idempotencyKey: flags["idempotency-key"] || null,
|
|
1328
|
+
baseDescriptor: baseDescriptorFromFlags(flags),
|
|
1329
|
+
});
|
|
1330
|
+
emit(flags.json ? JSON.stringify(result, null, 2) : renderPublish(result));
|
|
1331
|
+
return result;
|
|
1332
|
+
}
|
|
1333
|
+
if (sub === "status") {
|
|
1334
|
+
const result = await fetchUploadStatus(flags._[0], options);
|
|
1335
|
+
emit(flags.json ? JSON.stringify(result, null, 2) : `Server-authoritative status: ${result.receipt.status} · ${result.receipt.uploadId}\nrequested visibility: ${result.receipt.requestedVisibility} · Terminal did not assert public activation/evaluator reputation`);
|
|
1336
|
+
return result;
|
|
1337
|
+
}
|
|
1338
|
+
if (sub === "export") {
|
|
1339
|
+
const result = await fetchUploadExport(flags._[0], {
|
|
1340
|
+
...options,
|
|
1341
|
+
outputPath: typeof flags.out === "string" ? flags.out : null,
|
|
1342
|
+
overwrite: flags.overwrite === true,
|
|
1343
|
+
});
|
|
1344
|
+
// Intentionally omit owner/account fields and bundle content from stdout.
|
|
1345
|
+
emit(flags.json ? JSON.stringify(result, null, 2) : `Experience exported: ${result.outputPath}\nbundle hash: ${result.bundleHash}`);
|
|
1346
|
+
return result;
|
|
1347
|
+
}
|
|
1348
|
+
if (sub === "withdraw") {
|
|
1349
|
+
if (flags["dry-run"] === true) {
|
|
1350
|
+
const result = { dryRun: true, withdrawn: false, networkUsed: false, ref: flags._[0] || null };
|
|
1351
|
+
emit(flags.json ? JSON.stringify(result, null, 2) : "DRY RUN · no withdrawal request sent · server state unchanged");
|
|
1352
|
+
return result;
|
|
1353
|
+
}
|
|
1354
|
+
const result = await withdrawUpload(flags._[0], options);
|
|
1355
|
+
emit(flags.json ? JSON.stringify(result, null, 2) : `Server-authoritative withdrawal: ${result.receipt.uploadId} · withdrawn\nExisting receipts/history remain; no public activation claim.`);
|
|
1356
|
+
return result;
|
|
1357
|
+
}
|
|
1358
|
+
throw new Error("unknown experience subcommand (validate|save|publish|status|export|withdraw; legacy: list|inspect|unpublish|legacy-publish)");
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
module.exports = {
|
|
1362
|
+
BUNDLE_SCHEMA,
|
|
1363
|
+
RECEIPT_SCHEMA,
|
|
1364
|
+
MAX_BUNDLE_CANONICAL_BYTES,
|
|
1365
|
+
MAX_STORED_ITEMS,
|
|
1366
|
+
EXPERIENCE_RETRIEVAL_MAX_ITEMS,
|
|
1367
|
+
EXPERIENCE_RETRIEVAL_MAX_TOKENS,
|
|
1368
|
+
ExperienceBundleValidationError,
|
|
1369
|
+
canonicalJson,
|
|
1370
|
+
canonicalHash,
|
|
1371
|
+
normalizeExperienceBundle,
|
|
1372
|
+
experiencePackContentPayload,
|
|
1373
|
+
experiencePackContentHash,
|
|
1374
|
+
experienceBundleHashPayload,
|
|
1375
|
+
experienceBundleHash,
|
|
1376
|
+
experienceBundleId,
|
|
1377
|
+
validateExperienceBundle,
|
|
1378
|
+
validateUploadReceipt,
|
|
1379
|
+
readBundleFile,
|
|
1380
|
+
recoverPrivateAtomicTarget,
|
|
1381
|
+
replacePrivateFileAtomic,
|
|
1382
|
+
exchangeStatePath,
|
|
1383
|
+
bundleStorePath,
|
|
1384
|
+
loadExchangeState,
|
|
1385
|
+
withExchangeStateLock,
|
|
1386
|
+
saveLocalBundle,
|
|
1387
|
+
readStoredBundle,
|
|
1388
|
+
projectScopeHash,
|
|
1389
|
+
idempotencyKeyForBundle,
|
|
1390
|
+
idempotencyKeyHash,
|
|
1391
|
+
trustedExperienceOrigin,
|
|
1392
|
+
publishBundle,
|
|
1393
|
+
fetchUploadStatus,
|
|
1394
|
+
fetchUploadExport,
|
|
1395
|
+
withdrawUpload,
|
|
1396
|
+
defaultEnvironmentTags,
|
|
1397
|
+
estimateTokens,
|
|
1398
|
+
buildLocalExperienceAdvisory,
|
|
1399
|
+
augmentRuntimeSystemWithLocalExperience,
|
|
1400
|
+
cmdExperienceExchange,
|
|
1401
|
+
};
|