agentlas 1.0.66 → 1.0.67
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/CHANGELOG.md +16 -0
- package/README.md +6 -0
- package/bin/agentlas.cjs +17 -4
- package/engine/acp/server.cjs +7 -2
- package/engine/agentlas-workforce.cjs +1 -0
- package/engine/agentlas.cjs +11 -1
- package/engine/cli-output.cjs +16 -2
- package/engine/cloud-assets/state.cjs +375 -77
- package/engine/commands/build.cjs +32 -4
- package/engine/commands/mcp.cjs +144 -37
- package/engine/experience/intents.cjs +6 -0
- package/engine/hub/install.cjs +280 -28
- package/engine/mcp/inventory.cjs +31 -5
- package/engine/mcp/plan.cjs +5 -0
- package/engine/project/credentials.cjs +197 -27
- package/package.json +1 -1
package/engine/mcp/inventory.cjs
CHANGED
|
@@ -185,11 +185,23 @@ function readCredentialNames(userDataDir, env = process.env) {
|
|
|
185
185
|
return names;
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
function consentFingerprintForServer({ registryServerId, catalogId, transport, command, args, credentialKeyNames }) {
|
|
189
|
+
return crypto.createHash("sha256").update(JSON.stringify({
|
|
190
|
+
schemaVersion: "agentlas.terminal-mcp-consent-fingerprint.v1",
|
|
191
|
+
registryServerId,
|
|
192
|
+
catalogId,
|
|
193
|
+
transport,
|
|
194
|
+
command,
|
|
195
|
+
args,
|
|
196
|
+
credentialKeyNames,
|
|
197
|
+
}), "utf8").digest("hex");
|
|
198
|
+
}
|
|
199
|
+
|
|
188
200
|
function collectSystemMcpInventory(db, options = {}) {
|
|
189
201
|
let rows = [];
|
|
190
202
|
let registryStatus = "complete";
|
|
191
203
|
try {
|
|
192
|
-
rows = db.prepare("SELECT id, catalog_id, name, name_en, transport, env_keys_json, enabled FROM mcp_servers ORDER BY installed_at ASC LIMIT 1025").all();
|
|
204
|
+
rows = db.prepare("SELECT id, catalog_id, name, name_en, transport, command, args_json, env_keys_json, enabled FROM mcp_servers ORDER BY installed_at ASC LIMIT 1025").all();
|
|
193
205
|
} catch {
|
|
194
206
|
// 읽을 수 없는 레지스트리는 "읽었더니 비어 있음"과 다른 사실이다.
|
|
195
207
|
// 둘 다 empty-MCP로 fail-closed하지만, 사용자 플랜에는 원인을 보존한다.
|
|
@@ -235,6 +247,20 @@ function collectSystemMcpInventory(db, options = {}) {
|
|
|
235
247
|
value: crypto.createHash("sha256").update(JSON.stringify(keyNames), "utf8").digest("hex"),
|
|
236
248
|
enumerable: false,
|
|
237
249
|
});
|
|
250
|
+
const args = parseRuntimeServerArgs(row.args_json || "[]");
|
|
251
|
+
Object.defineProperty(item, "consentFingerprint", {
|
|
252
|
+
value: typeof row.command === "string" && args
|
|
253
|
+
? consentFingerprintForServer({
|
|
254
|
+
registryServerId: String(row.id),
|
|
255
|
+
catalogId,
|
|
256
|
+
transport: String(row.transport || ""),
|
|
257
|
+
command: row.command,
|
|
258
|
+
args,
|
|
259
|
+
credentialKeyNames: keyNames,
|
|
260
|
+
})
|
|
261
|
+
: null,
|
|
262
|
+
enumerable: false,
|
|
263
|
+
});
|
|
238
264
|
inventory.push(item);
|
|
239
265
|
}
|
|
240
266
|
Object.defineProperty(inventory, "registryStatus", { value: registryStatus, enumerable: false });
|
|
@@ -276,15 +302,14 @@ function materializeTrustedSystemMcpServer(row, options = {}) {
|
|
|
276
302
|
enumerable: false,
|
|
277
303
|
});
|
|
278
304
|
Object.defineProperty(server, "consentFingerprint", {
|
|
279
|
-
value:
|
|
280
|
-
schemaVersion: "agentlas.terminal-mcp-consent-fingerprint.v1",
|
|
305
|
+
value: consentFingerprintForServer({
|
|
281
306
|
registryServerId,
|
|
282
307
|
catalogId,
|
|
283
308
|
transport: "stdio",
|
|
284
309
|
command: row.command,
|
|
285
310
|
args,
|
|
286
311
|
credentialKeyNames,
|
|
287
|
-
}),
|
|
312
|
+
}),
|
|
288
313
|
enumerable: false,
|
|
289
314
|
});
|
|
290
315
|
if (options.createRuntimeHome !== false) {
|
|
@@ -314,7 +339,8 @@ function readApprovedSystemMcpServer(db, entry, options = {}) {
|
|
|
314
339
|
const server = materializeTrustedSystemMcpServer(row, options);
|
|
315
340
|
if (
|
|
316
341
|
!server || String(row.id) !== entry.registryServerId || server.catalog_id !== entry.resolvedCatalogId ||
|
|
317
|
-
!entry.credentialKeyFingerprint || server.credentialKeyFingerprint !== entry.credentialKeyFingerprint
|
|
342
|
+
!entry.credentialKeyFingerprint || server.credentialKeyFingerprint !== entry.credentialKeyFingerprint ||
|
|
343
|
+
!entry.consentFingerprint || server.consentFingerprint !== entry.consentFingerprint
|
|
318
344
|
) return null;
|
|
319
345
|
return server;
|
|
320
346
|
}
|
package/engine/mcp/plan.cjs
CHANGED
|
@@ -209,11 +209,16 @@ function buildMcpPlan(options) {
|
|
|
209
209
|
value: resolution.selected?.credentialKeyFingerprint || null,
|
|
210
210
|
enumerable: false,
|
|
211
211
|
});
|
|
212
|
+
Object.defineProperty(entry, "consentFingerprint", {
|
|
213
|
+
value: resolution.selected?.consentFingerprint || null,
|
|
214
|
+
enumerable: false,
|
|
215
|
+
});
|
|
212
216
|
Object.defineProperty(entry, "runtimeCandidates", {
|
|
213
217
|
value: resolution.candidates.map((candidate) => ({
|
|
214
218
|
resolvedCatalogId: candidate.item.catalogId,
|
|
215
219
|
registryServerId: candidate.item.registryServerId || null,
|
|
216
220
|
credentialKeyFingerprint: candidate.item.credentialKeyFingerprint || null,
|
|
221
|
+
consentFingerprint: candidate.item.consentFingerprint || null,
|
|
217
222
|
})),
|
|
218
223
|
enumerable: false,
|
|
219
224
|
});
|
|
@@ -16,6 +16,144 @@ const { runCwd } = require("./paths.cjs");
|
|
|
16
16
|
|
|
17
17
|
const MAX_MANAGED_CREDENTIAL_METADATA_BYTES = 1024 * 1024;
|
|
18
18
|
const MAX_CREDENTIAL_FILE_BYTES = 16 * 1024 * 1024;
|
|
19
|
+
const CREDENTIAL_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
|
|
20
|
+
const credentialDestinationBindings = new Map();
|
|
21
|
+
|
|
22
|
+
function credentialSameDirectoryIdentity(left, right) {
|
|
23
|
+
return Boolean(
|
|
24
|
+
left && right && left.isDirectory() && right.isDirectory() &&
|
|
25
|
+
!left.isSymbolicLink() && !right.isSymbolicLink() &&
|
|
26
|
+
left.dev === right.dev && left.ino === right.ino,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function credentialDirectoryAnchor(target, label, containedBy = null) {
|
|
31
|
+
const stat = fs.lstatSync(target);
|
|
32
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`${label} must be a real managed directory`);
|
|
33
|
+
const realpath = fs.realpathSync.native(target);
|
|
34
|
+
if (containedBy && !(
|
|
35
|
+
realpath === containedBy.realpath || realpath.startsWith(`${containedBy.realpath}${path.sep}`)
|
|
36
|
+
)) throw new Error(`${label} escaped the managed project`);
|
|
37
|
+
return { path: target, realpath, dev: stat.dev, ino: stat.ino, stat };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function credentialAssertDirectoryAnchor(anchor, label, containedBy = null) {
|
|
41
|
+
const current = credentialDirectoryAnchor(anchor.path, label, containedBy);
|
|
42
|
+
if (!credentialSameDirectoryIdentity(anchor.stat || anchor, current.stat || current) || current.realpath !== anchor.realpath) {
|
|
43
|
+
throw new Error(`${label} changed while it was being used`);
|
|
44
|
+
}
|
|
45
|
+
return current;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function credentialSameFileIdentity(left, right) {
|
|
49
|
+
return Boolean(
|
|
50
|
+
left && right && left.isFile() && right.isFile() &&
|
|
51
|
+
!left.isSymbolicLink() && !right.isSymbolicLink() &&
|
|
52
|
+
left.dev === right.dev && left.ino === right.ino,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function credentialSameFileSnapshot(left, right) {
|
|
57
|
+
return credentialSameFileIdentity(left, right) && left.nlink === right.nlink &&
|
|
58
|
+
left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function credentialFileSnapshot(file, label, { allowMissing = false, allowHardLinks = false } = {}) {
|
|
62
|
+
let stat;
|
|
63
|
+
try { stat = fs.lstatSync(file); }
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (allowMissing && error && error.code === "ENOENT") return null;
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (!allowHardLinks && stat.nlink !== 1) || stat.size > MAX_CREDENTIAL_FILE_BYTES) {
|
|
69
|
+
throw new Error(`${label} must be a bounded regular non-symbolic-link file`);
|
|
70
|
+
}
|
|
71
|
+
return stat;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function credentialRemoveOwnedFile(file, expected, { allowLinked = false } = {}) {
|
|
75
|
+
try {
|
|
76
|
+
const current = fs.lstatSync(file);
|
|
77
|
+
if (credentialSameFileIdentity(current, expected) && (current.nlink === 1 || (allowLinked && current.nlink >= 2))) {
|
|
78
|
+
fs.unlinkSync(file);
|
|
79
|
+
}
|
|
80
|
+
} catch { /* leave unknown successors and recovery artifacts untouched */ }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function credentialRestoreBackup(backup, target, expected, parent) {
|
|
84
|
+
try {
|
|
85
|
+
credentialAssertDirectoryAnchor(parent, "credential destination directory");
|
|
86
|
+
if (credentialFileSnapshot(target, "credential destination successor", { allowMissing: true })) return false;
|
|
87
|
+
const current = credentialFileSnapshot(backup, "credential destination backup");
|
|
88
|
+
if (!current || !credentialSameFileIdentity(current, expected) || current.nlink !== 1) return false;
|
|
89
|
+
fs.linkSync(backup, target);
|
|
90
|
+
const restored = credentialFileSnapshot(target, "credential destination restored", { allowHardLinks: true });
|
|
91
|
+
if (!restored || !credentialSameFileIdentity(restored, expected) || restored.nlink < 2) return false;
|
|
92
|
+
fs.unlinkSync(backup);
|
|
93
|
+
return true;
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function credentialPublishFile(parent, targetName, temporary, expected) {
|
|
100
|
+
credentialAssertDirectoryAnchor(parent, "credential destination directory");
|
|
101
|
+
const target = path.join(parent.realpath, targetName);
|
|
102
|
+
const current = credentialFileSnapshot(target, "credential destination", { allowMissing: true });
|
|
103
|
+
if ((expected && (!current || !credentialSameFileSnapshot(current, expected))) || (!expected && current)) {
|
|
104
|
+
throw new Error("credential destination changed before replacement");
|
|
105
|
+
}
|
|
106
|
+
let backup = null;
|
|
107
|
+
let linked = false;
|
|
108
|
+
try {
|
|
109
|
+
if (expected) {
|
|
110
|
+
backup = `${target}.previous-${process.pid}-${crypto.randomUUID()}`;
|
|
111
|
+
fs.renameSync(target, backup);
|
|
112
|
+
const moved = credentialFileSnapshot(backup, "credential destination backup");
|
|
113
|
+
if (!moved || !credentialSameFileIdentity(moved, expected) || moved.nlink !== 1) {
|
|
114
|
+
credentialRestoreBackup(backup, target, expected, parent);
|
|
115
|
+
throw new Error("credential destination changed before replacement");
|
|
116
|
+
}
|
|
117
|
+
credentialAssertDirectoryAnchor(parent, "credential destination directory");
|
|
118
|
+
if (credentialFileSnapshot(target, "credential destination successor", { allowMissing: true })) {
|
|
119
|
+
throw new Error("credential destination successor appeared during replacement");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
credentialAssertDirectoryAnchor(parent, "credential destination directory");
|
|
123
|
+
if (credentialFileSnapshot(target, "credential destination successor", { allowMissing: true })) {
|
|
124
|
+
throw new Error("credential destination successor appeared during replacement");
|
|
125
|
+
}
|
|
126
|
+
fs.linkSync(temporary.path, target);
|
|
127
|
+
linked = true;
|
|
128
|
+
const linkedTarget = credentialFileSnapshot(target, "credential destination", { allowHardLinks: true });
|
|
129
|
+
if (!linkedTarget || !credentialSameFileIdentity(linkedTarget, temporary.stat) || linkedTarget.nlink < 2) {
|
|
130
|
+
throw new Error("credential publication produced an unsafe target");
|
|
131
|
+
}
|
|
132
|
+
credentialAssertDirectoryAnchor(parent, "credential destination directory");
|
|
133
|
+
credentialRemoveOwnedFile(temporary.path, temporary.stat, { allowLinked: true });
|
|
134
|
+
const installed = credentialFileSnapshot(target, "credential destination");
|
|
135
|
+
if (!installed || !credentialSameFileIdentity(installed, temporary.stat) || installed.nlink !== 1) {
|
|
136
|
+
throw new Error("credential publication identity changed");
|
|
137
|
+
}
|
|
138
|
+
try { fs.chmodSync(target, 0o600); } catch { /* Windows/best-effort */ }
|
|
139
|
+
const final = credentialFileSnapshot(target, "credential destination");
|
|
140
|
+
if (!final || !credentialSameFileIdentity(final, temporary.stat) || final.nlink !== 1 ||
|
|
141
|
+
(process.platform !== "win32" && (final.mode & 0o777) !== 0o600)) {
|
|
142
|
+
throw new Error("credential publication mode or identity changed");
|
|
143
|
+
}
|
|
144
|
+
credentialAssertDirectoryAnchor(parent, "credential destination directory");
|
|
145
|
+
if (backup) {
|
|
146
|
+
const backupStat = credentialFileSnapshot(backup, "credential destination backup", { allowMissing: true });
|
|
147
|
+
if (backupStat && credentialSameFileIdentity(backupStat, expected) && backupStat.nlink === 1) fs.unlinkSync(backup);
|
|
148
|
+
}
|
|
149
|
+
return target;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (linked) credentialRemoveOwnedFile(target, temporary.stat);
|
|
152
|
+
credentialRemoveOwnedFile(temporary.path, temporary.stat);
|
|
153
|
+
if (backup) credentialRestoreBackup(backup, target, expected, parent);
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
19
157
|
|
|
20
158
|
function credentialProjectRootCli(projectPath) {
|
|
21
159
|
const requested = path.resolve(projectPath || "");
|
|
@@ -373,6 +511,7 @@ function safeCredentialDestRelCli(destRel) {
|
|
|
373
511
|
function resolveCredentialDestinationCli(projectPath, destRel) {
|
|
374
512
|
const root = credentialProjectRootCli(projectPath);
|
|
375
513
|
const rel = safeCredentialDestRelCli(destRel);
|
|
514
|
+
const rootAnchor = credentialDirectoryAnchor(root, "credential project root");
|
|
376
515
|
const parentRel = path.dirname(rel);
|
|
377
516
|
const parent = parentRel === "." ? root : ensureManagedDirectoryCli(root, parentRel);
|
|
378
517
|
const realParent = fs.realpathSync.native(parent);
|
|
@@ -380,40 +519,71 @@ function resolveCredentialDestinationCli(projectPath, destRel) {
|
|
|
380
519
|
if (path.isAbsolute(relativeParent) || relativeParent === ".." || relativeParent.startsWith(`..${path.sep}`)) {
|
|
381
520
|
throw new Error("credential destination escaped the project");
|
|
382
521
|
}
|
|
383
|
-
|
|
522
|
+
const parentAnchor = credentialDirectoryAnchor(realParent, "credential destination directory", rootAnchor);
|
|
523
|
+
const destination = path.join(parentAnchor.realpath, path.basename(rel));
|
|
524
|
+
credentialDestinationBindings.set(path.resolve(destination), {
|
|
525
|
+
root: rootAnchor,
|
|
526
|
+
parent: parentAnchor,
|
|
527
|
+
});
|
|
528
|
+
return destination;
|
|
384
529
|
}
|
|
385
530
|
|
|
386
531
|
function copyCredentialFileAtomicCli(sourcePath, destinationPath, options = {}) {
|
|
387
|
-
const
|
|
388
|
-
const
|
|
389
|
-
if (!sourceStat.isFile() || sourceStat.size <= 0 || sourceStat.size > MAX_CREDENTIAL_FILE_BYTES) {
|
|
390
|
-
throw new Error("credential source must be a non-empty regular file no larger than 16 MiB");
|
|
391
|
-
}
|
|
392
|
-
const snapshot = (() => {
|
|
393
|
-
try {
|
|
394
|
-
const stat = fs.lstatSync(destinationPath);
|
|
395
|
-
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("credential destination must be a regular non-symbolic-link file");
|
|
396
|
-
if (!options.force) throw new Error("credential destination already exists (use --force to replace)");
|
|
397
|
-
return { exists: true, stat };
|
|
398
|
-
} catch (error) {
|
|
399
|
-
if (error && error.code === "ENOENT") return { exists: false };
|
|
400
|
-
throw error;
|
|
401
|
-
}
|
|
402
|
-
})();
|
|
403
|
-
const temp = path.join(path.dirname(destinationPath), `.${path.basename(destinationPath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
532
|
+
const resolvedDestination = path.resolve(destinationPath);
|
|
533
|
+
const binding = credentialDestinationBindings.get(resolvedDestination) || null;
|
|
404
534
|
try {
|
|
405
|
-
|
|
406
|
-
const
|
|
407
|
-
if (!
|
|
408
|
-
throw new Error("credential
|
|
535
|
+
const source = fs.realpathSync.native(path.resolve(sourcePath));
|
|
536
|
+
const sourceStat = fs.statSync(source);
|
|
537
|
+
if (!sourceStat.isFile() || sourceStat.size <= 0 || sourceStat.size > MAX_CREDENTIAL_FILE_BYTES) {
|
|
538
|
+
throw new Error("credential source must be a non-empty regular file no larger than 16 MiB");
|
|
539
|
+
}
|
|
540
|
+
const root = binding ? credentialAssertDirectoryAnchor(binding.root, "credential project root") : null;
|
|
541
|
+
const parent = binding
|
|
542
|
+
? credentialAssertDirectoryAnchor(binding.parent, "credential destination directory", root)
|
|
543
|
+
: credentialDirectoryAnchor(path.dirname(resolvedDestination), "credential destination directory");
|
|
544
|
+
if (path.dirname(resolvedDestination) !== parent.path || path.basename(resolvedDestination).includes(path.sep)) {
|
|
545
|
+
throw new Error("credential destination path is not anchored to its managed directory");
|
|
409
546
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
547
|
+
const destination = path.join(parent.realpath, path.basename(resolvedDestination));
|
|
548
|
+
const snapshot = (() => {
|
|
549
|
+
try {
|
|
550
|
+
const stat = credentialFileSnapshot(destination, "credential destination");
|
|
551
|
+
if (!options.force) throw new Error("credential destination already exists (use --force to replace)");
|
|
552
|
+
return { exists: true, stat };
|
|
553
|
+
} catch (error) {
|
|
554
|
+
if (error && error.code === "ENOENT") return { exists: false };
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
557
|
+
})();
|
|
558
|
+
const temp = path.join(parent.realpath, `.${path.basename(destination)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
559
|
+
let tempStat = null;
|
|
560
|
+
try {
|
|
561
|
+
credentialAssertDirectoryAnchor(parent, "credential destination directory", root);
|
|
562
|
+
fs.copyFileSync(source, temp, fs.constants.COPYFILE_EXCL);
|
|
563
|
+
tempStat = credentialFileSnapshot(temp, "credential temporary file");
|
|
564
|
+
if (!tempStat || tempStat.size !== sourceStat.size) {
|
|
565
|
+
throw new Error("credential copy did not produce the exact regular file");
|
|
566
|
+
}
|
|
567
|
+
let readFd;
|
|
568
|
+
try {
|
|
569
|
+
readFd = fs.openSync(temp, fs.constants.O_RDONLY | CREDENTIAL_NOFOLLOW);
|
|
570
|
+
const opened = fs.fstatSync(readFd);
|
|
571
|
+
if (!credentialSameFileSnapshot(opened, tempStat) || opened.size !== sourceStat.size) {
|
|
572
|
+
throw new Error("credential temporary file changed while opening");
|
|
573
|
+
}
|
|
574
|
+
} finally {
|
|
575
|
+
if (readFd !== undefined) try { fs.closeSync(readFd); } catch { /* preserve original failure */ }
|
|
576
|
+
}
|
|
577
|
+
try { fs.chmodSync(temp, 0o600); } catch { /* Windows/best-effort */ }
|
|
578
|
+
credentialAssertDirectoryAnchor(parent, "credential destination directory", root);
|
|
579
|
+
credentialPublishFile(parent, path.basename(destination), { path: temp, stat: tempStat }, snapshot.exists ? snapshot.stat : null);
|
|
580
|
+
} finally {
|
|
581
|
+
if (tempStat) credentialRemoveOwnedFile(temp, tempStat);
|
|
582
|
+
}
|
|
583
|
+
return destinationPath;
|
|
413
584
|
} finally {
|
|
414
|
-
|
|
585
|
+
if (binding) credentialDestinationBindings.delete(resolvedDestination);
|
|
415
586
|
}
|
|
416
|
-
return destinationPath;
|
|
417
587
|
}
|
|
418
588
|
|
|
419
589
|
module.exports = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentlas",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.67",
|
|
4
4
|
"description": "Agentlas project terminal — run project controllers and task-scoped agent teams from the terminal. Standalone: no desktop app process required.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"agentlas": "bin/agentlas.cjs"
|