agentlas 0.6.0 → 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-experience-exchange.cjs +1401 -0
- package/engine/agentlas-experience-mcp.cjs +1147 -0
- package/engine/agentlas-repl.cjs +21 -13
- package/engine/agentlas.cjs +281 -50
- 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 +244 -8
- package/test/runtime-env-protection.cjs +45 -1
- package/test/smoke.sh +3 -0
- package/test/terminal-ui-regression.cjs +7 -2
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const { spawn } = require("node:child_process");
|
|
6
|
+
const fs = require("node:fs");
|
|
7
|
+
const os = require("node:os");
|
|
8
|
+
const path = require("node:path");
|
|
9
|
+
const { PassThrough } = require("node:stream");
|
|
10
|
+
|
|
11
|
+
const terminal = require("../engine/agentlas-experience-mcp.cjs");
|
|
12
|
+
|
|
13
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "agentlas-terminal-assets-"));
|
|
14
|
+
const userData = path.join(temp, "user-data");
|
|
15
|
+
const project = path.join(temp, "project");
|
|
16
|
+
fs.mkdirSync(userData, { recursive: true });
|
|
17
|
+
fs.mkdirSync(project, { recursive: true });
|
|
18
|
+
fs.writeFileSync(path.join(userData, "credentials.env"), "GITHUB_TOKEN=super-secret-value\n", { mode: 0o600 });
|
|
19
|
+
|
|
20
|
+
const mcpRows = [
|
|
21
|
+
{ id: "github", catalog_id: "github", name: "GitHub", name_en: "GitHub", transport: "stdio", env_keys_json: '["GITHUB_TOKEN"]', enabled: 1 },
|
|
22
|
+
{ id: "playwright", catalog_id: "playwright", name: "Playwright", name_en: "Playwright", transport: "stdio", env_keys_json: "[]", enabled: 1 },
|
|
23
|
+
{ id: "database", catalog_id: "database", name: "Database", name_en: "Database", transport: "stdio", env_keys_json: '["DATABASE_TOKEN"]', enabled: 1 },
|
|
24
|
+
{ id: "disabled", catalog_id: "disabled", name: "Disabled", name_en: "Disabled", transport: "stdio", env_keys_json: "[]", enabled: 0 },
|
|
25
|
+
];
|
|
26
|
+
const db = {
|
|
27
|
+
prepare(sql) {
|
|
28
|
+
assert.doesNotMatch(sql, /command|args_json|\burl\b/i, "inventory query must not read executable or endpoint data");
|
|
29
|
+
return { all: () => mcpRows };
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
const emptyDb = { prepare: () => ({ all: () => [] }) };
|
|
33
|
+
const unavailableDb = { prepare: () => { throw new Error("registry schema unavailable"); } };
|
|
34
|
+
const badMetadataDb = {
|
|
35
|
+
prepare: () => ({
|
|
36
|
+
all: () => [{ id: "malformed-credentials", catalog_id: "malformed-credentials", name: "Malformed", env_keys_json: "{not-json", enabled: 1 }],
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function requirement(catalogId, required, requiresKey = false, alternatives = []) {
|
|
41
|
+
return {
|
|
42
|
+
schemaVersion: "agentlas.mcp-requirement.v1",
|
|
43
|
+
kind: "agentlas-mcp-requirement",
|
|
44
|
+
requirementId: `requirement:${catalogId}`,
|
|
45
|
+
catalogId,
|
|
46
|
+
reason: `Use ${catalogId} for the verified workflow`,
|
|
47
|
+
capabilities: [`capability:${catalogId}`],
|
|
48
|
+
required,
|
|
49
|
+
requiresKey,
|
|
50
|
+
priority: 10,
|
|
51
|
+
permissions: [],
|
|
52
|
+
alternatives,
|
|
53
|
+
...(requiresKey ? { credentialMetadata: { provider: `provider:${catalogId}`, env: [catalogId === "github" ? "GITHUB_TOKEN" : "DATABASE_TOKEN"] } } : {}),
|
|
54
|
+
unavailablePolicy: {
|
|
55
|
+
build: "degrade",
|
|
56
|
+
rental: required ? "exclude-variant" : "continue-degraded",
|
|
57
|
+
execution: required ? "use-alternative" : "continue-degraded",
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function candidate(id, score, requirements = [], overrides = {}) {
|
|
63
|
+
return {
|
|
64
|
+
variantId: `variant:${id}`,
|
|
65
|
+
baseAgentReleaseId: "agent-release:base-v1",
|
|
66
|
+
experiencePackReleaseId: `experience-release:${id}`,
|
|
67
|
+
status: "active",
|
|
68
|
+
compatibilityStatus: "verified",
|
|
69
|
+
score,
|
|
70
|
+
mcpRequirements: requirements,
|
|
71
|
+
...overrides,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let checks = 0;
|
|
76
|
+
function check(fn) { fn(); checks += 1; }
|
|
77
|
+
|
|
78
|
+
(async () => {
|
|
79
|
+
try {
|
|
80
|
+
const pack = {
|
|
81
|
+
schemaVersion: "agentlas.experience-pack.v1",
|
|
82
|
+
kind: "agentlas-experience-pack",
|
|
83
|
+
experiencePackId: "experience-pack:writer",
|
|
84
|
+
releaseId: "experience-release:writer-v1",
|
|
85
|
+
ownerRef: "owner:mason",
|
|
86
|
+
version: "version:1.0.0",
|
|
87
|
+
baseCompatibility: {
|
|
88
|
+
agentDefinitionId: "agent-definition:writer",
|
|
89
|
+
compatibleBaseReleaseIds: ["agent-release:base-v1"],
|
|
90
|
+
},
|
|
91
|
+
itemIds: ["experience-item:writer-1"],
|
|
92
|
+
evidenceReceiptIds: ["receipt:verified-1"],
|
|
93
|
+
mcpRequirements: [requirement("github", false, true)],
|
|
94
|
+
containsBasePackageMaterial: false,
|
|
95
|
+
contentHash: `sha256:${"a".repeat(64)}`,
|
|
96
|
+
visibility: "private",
|
|
97
|
+
status: "active",
|
|
98
|
+
createdAt: new Date().toISOString(),
|
|
99
|
+
releasedAt: null,
|
|
100
|
+
withdrawnAt: null,
|
|
101
|
+
};
|
|
102
|
+
const packFile = path.join(project, "experience-pack.json");
|
|
103
|
+
fs.writeFileSync(packFile, JSON.stringify(pack, null, 2));
|
|
104
|
+
|
|
105
|
+
const emitted = [];
|
|
106
|
+
terminal.cmdExperience({ args: ["publish", packFile, "--json"], userDataDir: userData, cwd: project, out: (line) => emitted.push(line) });
|
|
107
|
+
check(() => assert.match(emitted[0], /"localState": "publish-requested"/));
|
|
108
|
+
check(() => assert.doesNotMatch(emitted[0], /sourcePath|super-secret-value|GITHUB_TOKEN/));
|
|
109
|
+
check(() => assert.match(emitted[0], /"receiptPresent": false/));
|
|
110
|
+
const stateFile = terminal.experienceStatePath(userData);
|
|
111
|
+
check(() => assert.equal(fs.statSync(stateFile).mode & 0o777, 0o600));
|
|
112
|
+
const rawState = JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
|
113
|
+
check(() => assert.equal(rawState.intents[0].hubReceipt, null));
|
|
114
|
+
check(() => assert.equal(rawState.intents[0].contentVerified, false, "a declared pack hash is not falsely called content-verified"));
|
|
115
|
+
|
|
116
|
+
emitted.length = 0;
|
|
117
|
+
terminal.cmdExperience({ args: ["list"], userDataDir: userData, cwd: project, out: (line) => emitted.push(line) });
|
|
118
|
+
check(() => assert.match(emitted[0], /not Hub publication/));
|
|
119
|
+
emitted.length = 0;
|
|
120
|
+
terminal.cmdExperience({ args: ["inspect", "experience-pack:writer", "--json"], userDataDir: userData, cwd: project, out: (line) => emitted.push(line) });
|
|
121
|
+
check(() => assert.doesNotMatch(emitted[0], new RegExp(temp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))));
|
|
122
|
+
emitted.length = 0;
|
|
123
|
+
terminal.cmdExperience({ args: ["unpublish", "experience-release:writer-v1"], userDataDir: userData, cwd: project, out: (line) => emitted.push(line) });
|
|
124
|
+
check(() => assert.match(emitted[0], /Hub state: unchanged/));
|
|
125
|
+
check(() => assert.equal(terminal.loadExperienceState(userData).intents[0].localState, "unpublish-requested"));
|
|
126
|
+
|
|
127
|
+
const modulePath = path.join(__dirname, "../engine/agentlas-experience-mcp.cjs");
|
|
128
|
+
const childCode = "const m=require(process.argv[1]);m.publishExperienceIntent(process.argv[2],process.argv[3],process.cwd());";
|
|
129
|
+
const concurrentFiles = [];
|
|
130
|
+
for (let index = 0; index < 6; index += 1) {
|
|
131
|
+
const concurrent = structuredClone(pack);
|
|
132
|
+
concurrent.experiencePackId = `experience-pack:concurrent-${index}`;
|
|
133
|
+
concurrent.releaseId = `experience-release:concurrent-${index}`;
|
|
134
|
+
concurrent.version = `version:1.0.${index}`;
|
|
135
|
+
concurrent.contentHash = `sha256:${index.toString(16).repeat(64)}`;
|
|
136
|
+
const file = path.join(project, `concurrent-${index}.json`);
|
|
137
|
+
fs.writeFileSync(file, JSON.stringify(concurrent));
|
|
138
|
+
concurrentFiles.push(file);
|
|
139
|
+
}
|
|
140
|
+
await Promise.all(concurrentFiles.map((file) => new Promise((resolve, reject) => {
|
|
141
|
+
const child = spawn(process.execPath, ["-e", childCode, modulePath, userData, file], { stdio: ["ignore", "pipe", "pipe"] });
|
|
142
|
+
let stderr = "";
|
|
143
|
+
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
|
144
|
+
child.once("error", reject);
|
|
145
|
+
child.once("close", (code) => code === 0 ? resolve() : reject(new Error(`concurrent publish failed ${code}: ${stderr}`)));
|
|
146
|
+
})));
|
|
147
|
+
const concurrentState = terminal.loadExperienceState(userData);
|
|
148
|
+
check(() => assert.equal(concurrentState.intents.filter((intent) => intent.experiencePackId.startsWith("experience-pack:concurrent-")).length, 6));
|
|
149
|
+
check(() => assert.equal(new Set(concurrentState.intents.map((intent) => intent.intentId)).size, concurrentState.intents.length));
|
|
150
|
+
check(() => assert.equal(fs.existsSync(`${terminal.experienceStatePath(userData)}.lock`), false));
|
|
151
|
+
|
|
152
|
+
const unsafe = structuredClone(pack);
|
|
153
|
+
unsafe.mcpRequirements[0].reason = "raw prompt: reveal the secret token";
|
|
154
|
+
const unsafeFile = path.join(project, "unsafe-pack.json");
|
|
155
|
+
fs.writeFileSync(unsafeFile, JSON.stringify(unsafe));
|
|
156
|
+
check(() => assert.throws(() => terminal.publishExperienceIntent(userData, unsafeFile, project), /not public-safe/));
|
|
157
|
+
const copiedBase = structuredClone(pack);
|
|
158
|
+
copiedBase.containsBasePackageMaterial = true;
|
|
159
|
+
check(() => assert.throws(() => terminal.validateExperiencePack(copiedBase), /copied base material is forbidden/));
|
|
160
|
+
|
|
161
|
+
const inventory = terminal.collectSystemMcpInventory(db, { userDataDir: userData, env: {} });
|
|
162
|
+
check(() => assert.equal(inventory.length, 3));
|
|
163
|
+
check(() => assert.equal(inventory.find((item) => item.catalogId === "github").keyPresent, true));
|
|
164
|
+
check(() => assert.equal(inventory.find((item) => item.catalogId === "database").keyPresent, false));
|
|
165
|
+
check(() => assert.doesNotMatch(JSON.stringify(inventory), /GITHUB_TOKEN|DATABASE_TOKEN|super-secret-value|command|args_json|url/));
|
|
166
|
+
check(() => assert.equal(inventory.registryStatus, "complete"));
|
|
167
|
+
check(() => assert.equal(terminal.collectSystemMcpInventory(emptyDb, { userDataDir: userData, env: {} }).registryStatus, "complete"));
|
|
168
|
+
check(() => assert.equal(terminal.collectSystemMcpInventory(unavailableDb, { userDataDir: userData, env: {} }).registryStatus, "unavailable"));
|
|
169
|
+
const malformedInventory = terminal.collectSystemMcpInventory(badMetadataDb, { userDataDir: userData, env: {} });
|
|
170
|
+
check(() => assert.equal(malformedInventory[0].credentialMetadataStatus, "unavailable"));
|
|
171
|
+
check(() => assert.equal(malformedInventory[0].keyRequired, true));
|
|
172
|
+
check(() => assert.equal(malformedInventory[0].keyPresent, false, "malformed credential metadata must fail closed"));
|
|
173
|
+
|
|
174
|
+
const originalFetch = globalThis.fetch;
|
|
175
|
+
let networkCalls = 0;
|
|
176
|
+
globalThis.fetch = async () => { networkCalls += 1; throw new Error("network forbidden"); };
|
|
177
|
+
const plan = terminal.buildMcpPlan({ inventory, request: "GitHub repository issues", policy: null, requiredIds: [], recommendedIds: [] });
|
|
178
|
+
globalThis.fetch = originalFetch;
|
|
179
|
+
check(() => assert.equal(networkCalls, 0));
|
|
180
|
+
check(() => assert.deepEqual(plan.availableCatalogIds, ["github"]));
|
|
181
|
+
check(() => assert.equal(plan.discoveryNetworkUsed, false));
|
|
182
|
+
|
|
183
|
+
const permissionRequirement = requirement("github", false, true);
|
|
184
|
+
permissionRequirement.priority = 7;
|
|
185
|
+
permissionRequirement.permissions = ["repository:write"];
|
|
186
|
+
const permissionPlan = terminal.buildMcpPlan({
|
|
187
|
+
inventory,
|
|
188
|
+
request: "",
|
|
189
|
+
policy: { registryResolutionOrder: ["system-global"], requirements: [permissionRequirement] },
|
|
190
|
+
requiredIds: [],
|
|
191
|
+
recommendedIds: [],
|
|
192
|
+
});
|
|
193
|
+
check(() => assert.equal(permissionPlan.entries[0].priority, 7));
|
|
194
|
+
check(() => assert.deepEqual(permissionPlan.entries[0].permissions, ["repository:write"]));
|
|
195
|
+
check(() => assert.equal(permissionPlan.entries[0].permissionEnforced, false));
|
|
196
|
+
|
|
197
|
+
const ttyInput = new PassThrough();
|
|
198
|
+
ttyInput.isTTY = true;
|
|
199
|
+
ttyInput.setRawMode = () => ttyInput;
|
|
200
|
+
const ttyOutput = new PassThrough();
|
|
201
|
+
ttyOutput.isTTY = true;
|
|
202
|
+
ttyOutput.columns = 120;
|
|
203
|
+
let ttyPromptText = "";
|
|
204
|
+
ttyOutput.on("data", (chunk) => { ttyPromptText += String(chunk); });
|
|
205
|
+
const ttyConsent = terminal.askMcpConsentOnce(plan, { input: ttyInput, output: ttyOutput });
|
|
206
|
+
setImmediate(() => ttyInput.write("y\n"));
|
|
207
|
+
const ttyApproved = await ttyConsent;
|
|
208
|
+
check(() => assert.deepEqual(ttyApproved, ["github"], "TTY path must ask exactly once and accept explicit consent"));
|
|
209
|
+
check(() => assert.equal((ttyPromptText.match(/Attach the available MCP recommendations\?/g) || []).length, 1));
|
|
210
|
+
|
|
211
|
+
const missingPlan = terminal.buildMcpPlan({
|
|
212
|
+
inventory,
|
|
213
|
+
request: "database task",
|
|
214
|
+
policy: null,
|
|
215
|
+
requiredIds: ["database"],
|
|
216
|
+
recommendedIds: ["missing-server"],
|
|
217
|
+
});
|
|
218
|
+
check(() => assert.equal(missingPlan.shortages.length, 2));
|
|
219
|
+
check(() => assert.ok(missingPlan.shortages.every((item) => item.effect === "build-degraded-only")));
|
|
220
|
+
|
|
221
|
+
let invoked = 0;
|
|
222
|
+
let builderRequest = "";
|
|
223
|
+
const buildOutput = [];
|
|
224
|
+
const nonTtyInput = { isTTY: false };
|
|
225
|
+
const nonTtyOutput = { isTTY: false };
|
|
226
|
+
const buildResult = await terminal.cmdBuild({
|
|
227
|
+
db,
|
|
228
|
+
args: ["GitHub", "issue", "agent"],
|
|
229
|
+
userDataDir: userData,
|
|
230
|
+
cwd: project,
|
|
231
|
+
env: {},
|
|
232
|
+
input: nonTtyInput,
|
|
233
|
+
promptOutput: nonTtyOutput,
|
|
234
|
+
out: (line) => buildOutput.push(line),
|
|
235
|
+
invokeBuild: async (request) => { invoked += 1; builderRequest = request; },
|
|
236
|
+
});
|
|
237
|
+
check(() => assert.equal(invoked, 1));
|
|
238
|
+
check(() => assert.deepEqual(buildResult.approvedIds, [], "non-TTY must fail safe without prompting/auto-approval"));
|
|
239
|
+
check(() => assert.match(builderRequest, /Approved catalog IDs: none/));
|
|
240
|
+
check(() => assert.doesNotMatch(builderRequest + buildOutput.join("\n"), /super-secret-value|GITHUB_TOKEN|command|args_json|https?:\/\//));
|
|
241
|
+
check(() => assert.match(buildOutput.at(-1), /empty-MCP mode/));
|
|
242
|
+
|
|
243
|
+
let unavailableInvoked = false;
|
|
244
|
+
const unavailableResult = await terminal.cmdBuild({
|
|
245
|
+
db: unavailableDb,
|
|
246
|
+
args: ["offline registry build"],
|
|
247
|
+
userDataDir: userData,
|
|
248
|
+
cwd: project,
|
|
249
|
+
env: {},
|
|
250
|
+
input: nonTtyInput,
|
|
251
|
+
promptOutput: nonTtyOutput,
|
|
252
|
+
out: (line) => buildOutput.push(line),
|
|
253
|
+
invokeBuild: async () => { unavailableInvoked = true; },
|
|
254
|
+
});
|
|
255
|
+
check(() => assert.equal(unavailableResult.plan.registryStatus, "unavailable"));
|
|
256
|
+
check(() => assert.equal(unavailableInvoked, true, "unreadable registry must degrade to empty-MCP and continue the build"));
|
|
257
|
+
check(() => assert.deepEqual(unavailableResult.approvedIds, []));
|
|
258
|
+
check(() => assert.match(buildOutput.at(-2), /registry: unavailable|registry could not be read/));
|
|
259
|
+
|
|
260
|
+
const approvedResult = await terminal.cmdBuild({
|
|
261
|
+
db,
|
|
262
|
+
args: ["GitHub issue agent", "--approve-mcp", "github"],
|
|
263
|
+
userDataDir: userData,
|
|
264
|
+
cwd: project,
|
|
265
|
+
env: {},
|
|
266
|
+
input: nonTtyInput,
|
|
267
|
+
promptOutput: nonTtyOutput,
|
|
268
|
+
out: () => {},
|
|
269
|
+
invokeBuild: async (request) => { builderRequest = request; },
|
|
270
|
+
});
|
|
271
|
+
check(() => assert.deepEqual(approvedResult.approvedIds, ["github"]));
|
|
272
|
+
check(() => assert.match(builderRequest, /Approved catalog IDs: github/));
|
|
273
|
+
check(() => assert.deepEqual(
|
|
274
|
+
terminal.tokenizeBuildCommandLine('"GitHub 이슈 에이전트" --approve-mcp github --no-mcp'),
|
|
275
|
+
["GitHub 이슈 에이전트", "--approve-mcp", "github", "--no-mcp"],
|
|
276
|
+
));
|
|
277
|
+
check(() => assert.deepEqual(
|
|
278
|
+
terminal.tokenizeBuildCommandLine('Windows C:\\Users\\mason\\project 자동화 --no-mcp'),
|
|
279
|
+
["Windows", "C:\\Users\\mason\\project", "자동화", "--no-mcp"],
|
|
280
|
+
));
|
|
281
|
+
check(() => assert.throws(() => terminal.tokenizeBuildCommandLine('"닫히지 않은 요청'), /unterminated quote/));
|
|
282
|
+
const replFlagParse = terminal.parseBuildArgs(terminal.tokenizeBuildCommandLine('"문서 자동화 에이전트" --no-mcp --mcp-plan-only'));
|
|
283
|
+
check(() => assert.equal(replFlagParse.request, "문서 자동화 에이전트"));
|
|
284
|
+
check(() => assert.equal(replFlagParse.noMcp, true));
|
|
285
|
+
check(() => assert.equal(replFlagParse.planOnly, true));
|
|
286
|
+
|
|
287
|
+
const longIds = Array.from({ length: 10 }, (_, index) => `catalog-${index}-${"a".repeat(170)}`);
|
|
288
|
+
const longPlan = { availableCatalogIds: longIds, shortages: [], entries: [], maxApprovedMcp: 8 };
|
|
289
|
+
const fittedLongIds = terminal.fitApprovedMcpIds(longPlan, longIds);
|
|
290
|
+
const boundedDirective = terminal.buildMcpDirective(longPlan, longIds);
|
|
291
|
+
check(() => assert.ok(fittedLongIds.length <= 8));
|
|
292
|
+
check(() => assert.ok(boundedDirective.length <= 1400));
|
|
293
|
+
check(() => assert.ok(fittedLongIds.every((id) => boundedDirective.includes(id)), "approved ids must never be silently truncated mid-id"));
|
|
294
|
+
|
|
295
|
+
let planOnlyInvoked = false;
|
|
296
|
+
await terminal.cmdBuild({
|
|
297
|
+
db: emptyDb,
|
|
298
|
+
args: ["offline agent", "--mcp-plan-only"],
|
|
299
|
+
userDataDir: userData,
|
|
300
|
+
cwd: project,
|
|
301
|
+
env: {},
|
|
302
|
+
input: nonTtyInput,
|
|
303
|
+
promptOutput: nonTtyOutput,
|
|
304
|
+
out: (line) => buildOutput.push(line),
|
|
305
|
+
invokeBuild: async () => { planOnlyInvoked = true; },
|
|
306
|
+
});
|
|
307
|
+
check(() => assert.equal(planOnlyInvoked, false));
|
|
308
|
+
check(() => assert.match(buildOutput.at(-1), /empty-MCP mode/));
|
|
309
|
+
|
|
310
|
+
const variantResolution = terminal.resolveVariantCandidates({
|
|
311
|
+
candidates: [
|
|
312
|
+
candidate("preferred", 100, [requirement("database", true, true)]),
|
|
313
|
+
candidate("fallback", 80, [requirement("missing-optional", false, false)]),
|
|
314
|
+
candidate("next", 70, []),
|
|
315
|
+
],
|
|
316
|
+
inventory,
|
|
317
|
+
baseAgentReleaseId: "agent-release:base-v1",
|
|
318
|
+
preferredVariantId: "variant:preferred",
|
|
319
|
+
allowBaseOnly: true,
|
|
320
|
+
});
|
|
321
|
+
check(() => assert.equal(variantResolution.decision, "fallback"));
|
|
322
|
+
check(() => assert.equal(variantResolution.selectedVariantId, "variant:fallback"));
|
|
323
|
+
check(() => assert.deepEqual(variantResolution.fallbackOrder, ["variant:next"]));
|
|
324
|
+
check(() => assert.match(variantResolution.excluded[0].reasons.join(" "), /required-mcp-missing-key:database/));
|
|
325
|
+
check(() => assert.equal(variantResolution.requiredMcpFailureScope, "variant-only"));
|
|
326
|
+
check(() => assert.equal(variantResolution.authority, "local-advisory"));
|
|
327
|
+
check(() => assert.equal(variantResolution.executionAuthorized, false));
|
|
328
|
+
check(() => assert.equal(variantResolution.reputationAccepted, false));
|
|
329
|
+
check(() => assert.equal(variantResolution.serverResolutionReceiptPresent, false));
|
|
330
|
+
|
|
331
|
+
const candidatesFile = path.join(project, "variant-candidates.json");
|
|
332
|
+
fs.writeFileSync(candidatesFile, JSON.stringify({
|
|
333
|
+
baseAgentReleaseId: "agent-release:base-v1",
|
|
334
|
+
candidates: [candidate("self-claimed", 999999, [])],
|
|
335
|
+
}));
|
|
336
|
+
const variantOutput = [];
|
|
337
|
+
const localPreview = terminal.cmdVariant({
|
|
338
|
+
db,
|
|
339
|
+
args: ["resolve", "--candidates", candidatesFile],
|
|
340
|
+
userDataDir: userData,
|
|
341
|
+
cwd: project,
|
|
342
|
+
env: {},
|
|
343
|
+
out: (line) => variantOutput.push(line),
|
|
344
|
+
setExitCode: () => {},
|
|
345
|
+
});
|
|
346
|
+
check(() => assert.equal(localPreview.reputationAccepted, false, "self-declared score/verified must not become reputation authority"));
|
|
347
|
+
check(() => assert.match(variantOutput[0], /Local compatibility preview only; Hub rental requires a Web server resolution receipt/));
|
|
348
|
+
check(() => assert.match(variantOutput[0], /not accepted as reputation, payment, rental, or execution authority/));
|
|
349
|
+
|
|
350
|
+
const baseOnly = terminal.resolveVariantCandidates({
|
|
351
|
+
candidates: [candidate("blocked", 100, [requirement("database", true, true)])],
|
|
352
|
+
inventory,
|
|
353
|
+
baseAgentReleaseId: "agent-release:base-v1",
|
|
354
|
+
allowBaseOnly: true,
|
|
355
|
+
});
|
|
356
|
+
check(() => assert.equal(baseOnly.decision, "base-only"));
|
|
357
|
+
check(() => assert.equal(baseOnly.selectedVariantId, null));
|
|
358
|
+
const baseOnlyEmpty = terminal.resolveVariantCandidates({ candidates: [], inventory, baseAgentReleaseId: "agent-release:base-v1", allowBaseOnly: true });
|
|
359
|
+
check(() => assert.equal(baseOnlyEmpty.decision, "base-only"));
|
|
360
|
+
const noFallback = terminal.resolveVariantCandidates({ candidates: [], inventory, baseAgentReleaseId: null, allowBaseOnly: false });
|
|
361
|
+
check(() => assert.equal(noFallback.decision, "error"));
|
|
362
|
+
check(() => assert.equal(noFallback.code, "EXACT_BASE_RELEASE_REQUIRED"));
|
|
363
|
+
|
|
364
|
+
check(() => assert.deepEqual(terminal.buildExperienceContext([], {}), { text: "", itemIds: [], estimatedTokens: 0 }));
|
|
365
|
+
const manyItems = Array.from({ length: 20 }, (_, index) => ({
|
|
366
|
+
id: `experience-item:item-${String(index).padStart(2, "0")}`,
|
|
367
|
+
status: "promoted",
|
|
368
|
+
relevant: true,
|
|
369
|
+
relevance: 20 - index,
|
|
370
|
+
summary: `검증된 절차 ${index}: ${"안전한 단계 ".repeat(35)}`,
|
|
371
|
+
}));
|
|
372
|
+
const context = terminal.buildExperienceContext(manyItems, {});
|
|
373
|
+
check(() => assert.ok(context.itemIds.length <= terminal.TOKEN_BUDGET.experienceRetrievalMaxItems));
|
|
374
|
+
check(() => assert.ok(context.estimatedTokens <= terminal.TOKEN_BUDGET.experienceRetrievalMaxTokens));
|
|
375
|
+
check(() => assert.equal(context.estimatedTokens, terminal.estimateTokens(context.text)));
|
|
376
|
+
check(() => assert.deepEqual(terminal.TOKEN_BUDGET, { coreMemoryMaxTokens: 150, experienceRetrievalMaxTokens: 800, experienceRetrievalMaxItems: 8 }));
|
|
377
|
+
const replSource = fs.readFileSync(path.join(__dirname, "../engine/agentlas-repl.cjs"), "utf8");
|
|
378
|
+
const mainSource = fs.readFileSync(path.join(__dirname, "../engine/agentlas.cjs"), "utf8");
|
|
379
|
+
const replBuildCase = replSource.slice(replSource.indexOf('case "build"'), replSource.indexOf('case "route"'));
|
|
380
|
+
check(() => assert.match(replBuildCase, /H\.terminalBuild\(/, "REPL /build must use the same Terminal-owned MCP preflight as top-level build"));
|
|
381
|
+
check(() => assert.doesNotMatch(replBuildCase, /H\.hepRun\(\["hep-build"/, "REPL /build must not bypass MCP consent/receipt planning"));
|
|
382
|
+
check(() => assert.match(mainSource, /terminalBuild:\s*\(db_, args, ctx = \{\}\) => terminalAssets\.cmdBuild/, "main helper must expose the shared Terminal build command"));
|
|
383
|
+
|
|
384
|
+
console.log(JSON.stringify({ ok: true, checks }, null, 2));
|
|
385
|
+
} finally {
|
|
386
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
387
|
+
}
|
|
388
|
+
})().catch((error) => {
|
|
389
|
+
console.error(error && error.stack ? error.stack : error);
|
|
390
|
+
process.exitCode = 1;
|
|
391
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bundle": {
|
|
3
|
+
"schemaVersion": "agentlas.experience-bundle.v1",
|
|
4
|
+
"kind": "agentlas-experience-bundle",
|
|
5
|
+
"bundleId": "exb_d5693d046e5228270e8eb108f975b096ed9e4050f0bba3bb",
|
|
6
|
+
"bundleHash": "sha256:d5693d046e5228270e8eb108f975b096ed9e4050f0bba3bb34d7109e830d9a00",
|
|
7
|
+
"requestedVisibility": "unlisted",
|
|
8
|
+
"pack": {
|
|
9
|
+
"schemaVersion": "agentlas.experience-pack.v1",
|
|
10
|
+
"kind": "agentlas-experience-pack",
|
|
11
|
+
"experiencePackId": "exp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
12
|
+
"releaseId": "exr_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
13
|
+
"ownerRef": "user:submitted-owner-is-not-authority",
|
|
14
|
+
"version": "1.2.3-ko.1",
|
|
15
|
+
"baseCompatibility": {
|
|
16
|
+
"agentDefinitionId": "agd_cccccccccccccccccccccccccccccccccccccccccccccccc",
|
|
17
|
+
"compatibleBaseReleaseIds": ["agr_dddddddddddddddddddddddddddddddddddddddddddddddd"]
|
|
18
|
+
},
|
|
19
|
+
"itemIds": [
|
|
20
|
+
"exi_eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
|
21
|
+
"exi_ffffffffffffffffffffffffffffffffffffffffffffffff"
|
|
22
|
+
],
|
|
23
|
+
"evidenceReceiptIds": ["evidence:local:alpha", "evidence:local:beta"],
|
|
24
|
+
"mcpRequirements": [
|
|
25
|
+
{
|
|
26
|
+
"schemaVersion": "agentlas.mcp-requirement.v1",
|
|
27
|
+
"kind": "agentlas-mcp-requirement",
|
|
28
|
+
"requirementId": "mcp:req:browser-read",
|
|
29
|
+
"catalogId": "browser",
|
|
30
|
+
"reason": "승인된 웹 페이지를 확인합니다.",
|
|
31
|
+
"capabilities": ["page-read"],
|
|
32
|
+
"required": false,
|
|
33
|
+
"requiresKey": false,
|
|
34
|
+
"priority": 20,
|
|
35
|
+
"permissions": ["read-page"],
|
|
36
|
+
"alternatives": ["manual-review"],
|
|
37
|
+
"unavailablePolicy": {
|
|
38
|
+
"build": "degrade",
|
|
39
|
+
"rental": "continue-degraded",
|
|
40
|
+
"execution": "disable-capability"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"containsBasePackageMaterial": false,
|
|
45
|
+
"contentHash": "sha256:af48067b1bb8b16ff146d2f1ef1ba3029f936dfcc2657d2edfd80238b3613aea",
|
|
46
|
+
"visibility": "unlisted",
|
|
47
|
+
"status": "active",
|
|
48
|
+
"createdAt": "2026-07-12T00:00:00Z",
|
|
49
|
+
"releasedAt": "2026-07-12T00:00:00Z",
|
|
50
|
+
"withdrawnAt": null
|
|
51
|
+
},
|
|
52
|
+
"items": [
|
|
53
|
+
{
|
|
54
|
+
"schemaVersion": "agentlas.experience-item.v1",
|
|
55
|
+
"kind": "agentlas-experience-item",
|
|
56
|
+
"experienceItemId": "exi_ffffffffffffffffffffffffffffffffffffffffffffffff",
|
|
57
|
+
"experiencePackId": "exp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
58
|
+
"experiencePackReleaseId": "exr_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
59
|
+
"type": "environment-gotcha",
|
|
60
|
+
"summary": "Windows 호스트에서는 셸 종류를 먼저 확인합니다.",
|
|
61
|
+
"instructions": ["호스트가 Windows인지 확인합니다.", "shell\\PowerShell 표기는 실행 경로가 아니라 환경 태그로만 다룹니다. 🧭"],
|
|
62
|
+
"taskSignatures": ["task:windows-shell"],
|
|
63
|
+
"environmentConstraints": ["windows-x64"],
|
|
64
|
+
"evidenceReceiptIds": ["evidence:local:beta"],
|
|
65
|
+
"supersedesItemIds": [],
|
|
66
|
+
"confidence": 1.0,
|
|
67
|
+
"status": "promoted",
|
|
68
|
+
"privacyScope": "public-safe",
|
|
69
|
+
"createdAt": "2026-07-12T00:00:00Z"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"schemaVersion": "agentlas.experience-item.v1",
|
|
73
|
+
"kind": "agentlas-experience-item",
|
|
74
|
+
"experienceItemId": "exi_eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
|
75
|
+
"experiencePackId": "exp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
76
|
+
"experiencePackReleaseId": "exr_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
77
|
+
"type": "procedure",
|
|
78
|
+
"summary": "카페 게시물은 검토 후 발행합니다.",
|
|
79
|
+
"instructions": ["초안을 작성합니다.", "사용자 승인을 확인합니다.", "승인된 내용만 발행합니다."],
|
|
80
|
+
"taskSignatures": ["task:cafe-post", "task:social-publish"],
|
|
81
|
+
"environmentConstraints": ["ko-KR", "macos-arm64"],
|
|
82
|
+
"evidenceReceiptIds": ["evidence:local:alpha"],
|
|
83
|
+
"supersedesItemIds": [],
|
|
84
|
+
"confidence": 0.5,
|
|
85
|
+
"status": "promoted",
|
|
86
|
+
"privacyScope": "public-safe",
|
|
87
|
+
"createdAt": "2026-07-12T00:00:00Z"
|
|
88
|
+
}
|
|
89
|
+
],
|
|
90
|
+
"sourceAttestations": [
|
|
91
|
+
{
|
|
92
|
+
"kind": "user-attested",
|
|
93
|
+
"experienceItemId": "exi_eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
|
94
|
+
"evidenceHash": "sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"kind": "user-attested",
|
|
98
|
+
"experienceItemId": "exi_ffffffffffffffffffffffffffffffffffffffffffffffff",
|
|
99
|
+
"evidenceHash": "sha256:2222222222222222222222222222222222222222222222222222222222222222"
|
|
100
|
+
}
|
|
101
|
+
],
|
|
102
|
+
"privacy": {
|
|
103
|
+
"basePackageMaterialIncluded": false,
|
|
104
|
+
"rawPromptIncluded": false,
|
|
105
|
+
"rawTranscriptIncluded": false,
|
|
106
|
+
"rawLocalPathsIncluded": false,
|
|
107
|
+
"credentialValuesIncluded": false
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
"expectedPackContentHash": "sha256:af48067b1bb8b16ff146d2f1ef1ba3029f936dfcc2657d2edfd80238b3613aea",
|
|
111
|
+
"expectedBundleHash": "sha256:d5693d046e5228270e8eb108f975b096ed9e4050f0bba3bb34d7109e830d9a00",
|
|
112
|
+
"expectedBundleId": "exb_d5693d046e5228270e8eb108f975b096ed9e4050f0bba3bb",
|
|
113
|
+
"canonicalCases": {
|
|
114
|
+
"input": {
|
|
115
|
+
"whole": 1.0,
|
|
116
|
+
"half": 0.5,
|
|
117
|
+
"negativeZero": -0.0,
|
|
118
|
+
"decomposedKorean": "카페",
|
|
119
|
+
"emoji": "🧭",
|
|
120
|
+
"windowsSlash": "shell\\PowerShell"
|
|
121
|
+
},
|
|
122
|
+
"expectedJson": "{\"decomposedKorean\":\"카페\",\"emoji\":\"🧭\",\"half\":0.5,\"negativeZero\":0,\"whole\":1,\"windowsSlash\":\"shell\\\\PowerShell\"}"
|
|
123
|
+
}
|
|
124
|
+
}
|