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/commands/mcp.cjs
CHANGED
|
@@ -13,21 +13,112 @@
|
|
|
13
13
|
const { userDataDir } = require("../core/paths.cjs");
|
|
14
14
|
const { materializeTrustedSystemMcpServer } = require("../mcp/inventory.cjs");
|
|
15
15
|
const { probeSystemMcpServerConnection } = require("../mcp/probe.cjs");
|
|
16
|
+
const {
|
|
17
|
+
DEFAULT_OPTIONS,
|
|
18
|
+
list: outputList,
|
|
19
|
+
render,
|
|
20
|
+
single,
|
|
21
|
+
parseOutputFlags,
|
|
22
|
+
displayWidth,
|
|
23
|
+
terminalTextOf,
|
|
24
|
+
} = require("../cli-output.cjs");
|
|
25
|
+
|
|
26
|
+
const OUTPUT_FLAGS = new Set(["--json", "--yaml", "--quiet", "-q", "--no-headers", "--no-color"]);
|
|
27
|
+
|
|
28
|
+
function commandError(message, code = "INVALID_ARGUMENT", details) {
|
|
29
|
+
const error = new Error(message);
|
|
30
|
+
error.code = code;
|
|
31
|
+
if (details !== undefined) error.details = details;
|
|
32
|
+
return error;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function withOutputFlags(ctx, args) {
|
|
36
|
+
if (!args.some((arg) => OUTPUT_FLAGS.has(arg))) return { ctx, args };
|
|
37
|
+
const parsed = parseOutputFlags(args);
|
|
38
|
+
return {
|
|
39
|
+
ctx: { ...ctx, output: { ...(ctx.output || DEFAULT_OPTIONS), ...parsed.options } },
|
|
40
|
+
args: parsed.rest,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function emit(ctx, result) {
|
|
45
|
+
if (typeof ctx.emit === "function") {
|
|
46
|
+
ctx.emit(result);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
ctx.out(render(result, ctx.output || DEFAULT_OPTIONS));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ansi(options, code, value) {
|
|
53
|
+
const text = String(value);
|
|
54
|
+
return options?.noColor ? text : `\u001b[${code}m${text}\u001b[0m`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isMachineOutput(ctx) {
|
|
58
|
+
const output = ctx.output || DEFAULT_OPTIONS;
|
|
59
|
+
return output.quiet || output.format === "json" || output.format === "yaml";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mcpListSchema(en) {
|
|
63
|
+
return Object.freeze({
|
|
64
|
+
idField: "id",
|
|
65
|
+
columns: [
|
|
66
|
+
{ header: "id", field: "id" },
|
|
67
|
+
{ header: en ? "name" : "이름", field: "name" },
|
|
68
|
+
],
|
|
69
|
+
renderHuman(result, options = {}) {
|
|
70
|
+
const rows = Array.isArray(result.data) ? result.data : [];
|
|
71
|
+
if (!rows.length) {
|
|
72
|
+
return options.noHeaders ? "" : (en ? "No MCP servers registered." : "등록된 MCP 서버가 없습니다.");
|
|
73
|
+
}
|
|
74
|
+
const lines = [];
|
|
75
|
+
if (!options.noHeaders) lines.push(ansi(options, 1, en ? "MCP servers" : "MCP 서버"));
|
|
76
|
+
for (const row of rows) {
|
|
77
|
+
const id = terminalTextOf(row.id, 256);
|
|
78
|
+
const name = terminalTextOf(row.name, 4096);
|
|
79
|
+
const padding = " ".repeat(Math.max(1, 28 - displayWidth(id)));
|
|
80
|
+
lines.push(` ${ansi(options, 36, id)}${padding}${name}`);
|
|
81
|
+
}
|
|
82
|
+
return lines.join("\n");
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function mcpProbeSchema(en) {
|
|
88
|
+
return Object.freeze({
|
|
89
|
+
idField: "serverId",
|
|
90
|
+
columns: [
|
|
91
|
+
{ header: en ? "server" : "서버", field: "serverId" },
|
|
92
|
+
{ header: en ? "status" : "상태", field: "status" },
|
|
93
|
+
{ header: en ? "tools" : "툴", field: "toolCount" },
|
|
94
|
+
],
|
|
95
|
+
renderHuman(result, options = {}) {
|
|
96
|
+
const row = result.data || {};
|
|
97
|
+
const serverId = terminalTextOf(row.serverId, 256);
|
|
98
|
+
const toolCount = Number.isSafeInteger(row.toolCount) ? row.toolCount : 0;
|
|
99
|
+
const status = row.connected ? ansi(options, 32, en ? "connected" : "연결됨") : ansi(options, 31, en ? "failed" : "실패");
|
|
100
|
+
const toolLabel = en ? `${toolCount} tool(s) listed` : `툴 ${toolCount}개 확인`;
|
|
101
|
+
const note = en
|
|
102
|
+
? "Preflight only: connection readiness does not imply tool-call success."
|
|
103
|
+
: "프리플라이트일 뿐입니다: 연결 준비됨 ≠ 툴 호출 성공.";
|
|
104
|
+
return [
|
|
105
|
+
`${status} ${ansi(options, 1, serverId)} · ${toolLabel}`,
|
|
106
|
+
ansi(options, 2, note),
|
|
107
|
+
].join("\n");
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
16
111
|
|
|
17
112
|
function list(ctx) {
|
|
18
113
|
const en = ctx.lang === "en";
|
|
19
114
|
const db = ctx.db();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return 0;
|
|
28
|
-
}
|
|
29
|
-
ctx.out(ctx.ui.bold(en ? "MCP servers" : "MCP 서버"));
|
|
30
|
-
for (const r of rows) ctx.out(` ${ctx.ui.accent(String(r.id).padEnd(28))} ${r.name || ""}`);
|
|
115
|
+
const rows = ctx.tableExists(db, "mcp_servers")
|
|
116
|
+
? db.prepare("SELECT id, name FROM mcp_servers ORDER BY name").all().map((row) => ({
|
|
117
|
+
id: String(row.id || ""),
|
|
118
|
+
name: String(row.name || ""),
|
|
119
|
+
}))
|
|
120
|
+
: [];
|
|
121
|
+
emit(ctx, outputList(rows, mcpListSchema(en)));
|
|
31
122
|
return 0;
|
|
32
123
|
}
|
|
33
124
|
|
|
@@ -35,13 +126,11 @@ async function probe(ctx, args) {
|
|
|
35
126
|
const en = ctx.lang === "en";
|
|
36
127
|
const ref = String(args[0] || "").trim();
|
|
37
128
|
if (!ref || args.length !== 1 || ref.startsWith("-")) {
|
|
38
|
-
|
|
39
|
-
return 1;
|
|
129
|
+
throw commandError(en ? "Usage: agentlas mcp probe <server-id|catalog-id>" : "사용법: agentlas mcp probe <server-id|catalog-id>");
|
|
40
130
|
}
|
|
41
131
|
const db = ctx.db();
|
|
42
132
|
if (!ctx.tableExists(db, "mcp_servers")) {
|
|
43
|
-
|
|
44
|
-
return 1;
|
|
133
|
+
throw commandError(en ? "No MCP servers registered." : "등록된 MCP 서버가 없습니다.", "MCP_SERVER_NOT_FOUND");
|
|
45
134
|
}
|
|
46
135
|
// catalog_id는 데스크탑 스키마 열 — 오래된 DB에는 없을 수 있어 방어적으로 조회.
|
|
47
136
|
const byCatalog = ctx.columnExists(db, "mcp_servers", "catalog_id");
|
|
@@ -49,35 +138,55 @@ async function probe(ctx, args) {
|
|
|
49
138
|
? db.prepare("SELECT id, catalog_id, name, name_en, transport, command, args_json, env_keys_json, enabled FROM mcp_servers WHERE id=? OR catalog_id=? LIMIT 1").get(ref, ref)
|
|
50
139
|
: db.prepare("SELECT id, NULL AS catalog_id, name, name_en, transport, command, args_json, env_keys_json, enabled FROM mcp_servers WHERE id=? LIMIT 1").get(ref);
|
|
51
140
|
if (!row) {
|
|
52
|
-
|
|
53
|
-
|
|
141
|
+
throw commandError(
|
|
142
|
+
en ? `MCP server not found: ${ref}` : `MCP 서버를 찾을 수 없습니다: ${ref}`,
|
|
143
|
+
"MCP_SERVER_NOT_FOUND",
|
|
144
|
+
{ serverId: ref },
|
|
145
|
+
);
|
|
54
146
|
}
|
|
55
147
|
const server = materializeTrustedSystemMcpServer(row, { userDataDir: userDataDir() });
|
|
56
148
|
if (!server) {
|
|
57
149
|
// 이유를 지어내지 않는다 — materialize는 비활성/비-stdio/안전하지 않은
|
|
58
150
|
// 정의를 하나의 fail-closed로 접기 때문에 관찰 가능한 사실만 알린다.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
151
|
+
throw commandError(
|
|
152
|
+
en
|
|
153
|
+
? `'${ref}' is not probe-eligible (disabled, non-stdio transport, or an unsafe runtime definition).`
|
|
154
|
+
: `'${ref}' 은(는) probe 대상이 아닙니다 (비활성, stdio가 아닌 transport, 또는 안전하지 않은 실행 정의).`,
|
|
155
|
+
"MCP_PROBE_INELIGIBLE",
|
|
156
|
+
{ serverId: ref },
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (!isMachineOutput(ctx)) {
|
|
160
|
+
const options = ctx.output || DEFAULT_OPTIONS;
|
|
161
|
+
ctx.out(ansi(options, 2, en
|
|
162
|
+
? `Probing ${server.catalog_id} (isolated child env, handshake only)…`
|
|
163
|
+
: `${server.catalog_id} 연결 확인 중 (격리 자식 env, 핸드셰이크만)…`));
|
|
63
164
|
}
|
|
64
|
-
ctx.out(ctx.ui.dim(en
|
|
65
|
-
? `Probing ${server.catalog_id} (isolated child env, handshake only)…`
|
|
66
|
-
: `${server.catalog_id} 연결 확인 중 (격리 자식 env, 핸드셰이크만)…`));
|
|
67
165
|
const result = await probeSystemMcpServerConnection(server, { userDataDir: userDataDir(), cwd: process.cwd() });
|
|
68
|
-
if (result.connected) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
166
|
+
if (!result.connected) {
|
|
167
|
+
throw commandError(
|
|
168
|
+
en
|
|
169
|
+
? `MCP probe failed: ${server.catalog_id} · ${result.reason || "connection_failed"}`
|
|
170
|
+
: `MCP 연결 확인 실패: ${server.catalog_id} · ${result.reason || "connection_failed"}`,
|
|
171
|
+
"MCP_PROBE_FAILED",
|
|
172
|
+
{ serverId: server.catalog_id, reason: result.reason || "connection_failed" },
|
|
173
|
+
);
|
|
75
174
|
}
|
|
76
|
-
|
|
77
|
-
|
|
175
|
+
const toolCount = Array.isArray(result.tools) ? result.tools.length : 0;
|
|
176
|
+
emit(ctx, single({
|
|
177
|
+
serverId: server.catalog_id,
|
|
178
|
+
status: "connected",
|
|
179
|
+
connected: true,
|
|
180
|
+
reason: result.reason || "connected",
|
|
181
|
+
toolCount,
|
|
182
|
+
}, mcpProbeSchema(en)));
|
|
183
|
+
return 0;
|
|
78
184
|
}
|
|
79
185
|
|
|
80
186
|
function run(ctx, args = []) {
|
|
187
|
+
const normalized = withOutputFlags(ctx, args);
|
|
188
|
+
ctx = normalized.ctx;
|
|
189
|
+
args = normalized.args;
|
|
81
190
|
const [sub, ...rest] = args;
|
|
82
191
|
if (!sub) return list(ctx);
|
|
83
192
|
// 무인자 기본 동작이 목록인데 이름으로 부르면 거부되는 비대칭이 있었다
|
|
@@ -85,17 +194,15 @@ function run(ctx, args = []) {
|
|
|
85
194
|
// "list를 붙이는" 습관이 여기서만 usage 오류가 됐다.
|
|
86
195
|
if (sub === "list" || sub === "ls") {
|
|
87
196
|
if (rest.length) {
|
|
88
|
-
|
|
89
|
-
return 1;
|
|
197
|
+
throw commandError(ctx.lang === "en" ? "Usage: agentlas mcp list" : "사용법: agentlas mcp list");
|
|
90
198
|
}
|
|
91
199
|
return list(ctx);
|
|
92
200
|
}
|
|
93
201
|
if (sub === "probe") return probe(ctx, rest);
|
|
94
202
|
const en = ctx.lang === "en";
|
|
95
|
-
|
|
203
|
+
throw commandError(en
|
|
96
204
|
? `unknown mcp subcommand: ${sub} (available: list · probe)`
|
|
97
205
|
: `알 수 없는 mcp 하위 명령: ${sub} (사용 가능: list · probe)`);
|
|
98
|
-
return 1;
|
|
99
206
|
}
|
|
100
207
|
|
|
101
208
|
module.exports = { run };
|
|
@@ -192,6 +192,12 @@ function validateExperiencePack(value) {
|
|
|
192
192
|
assertUniqueIds(value.evidenceReceiptIds, "experience pack.evidenceReceiptIds");
|
|
193
193
|
if (!Array.isArray(value.mcpRequirements) || value.mcpRequirements.length > 64) throw new Error("experience pack.mcpRequirements is invalid");
|
|
194
194
|
value.mcpRequirements.forEach((requirement, index) => validateMcpRequirement(requirement, `experience pack.mcpRequirements[${index}]`));
|
|
195
|
+
const requirementIds = value.mcpRequirements.map((requirement) => requirement.requirementId);
|
|
196
|
+
if (new Set(requirementIds).size !== requirementIds.length) {
|
|
197
|
+
const error = new Error("experience pack.mcpRequirements requirementId values must be unique");
|
|
198
|
+
error.code = "duplicate_mcp_requirement_id";
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
195
201
|
// base 패키지 복사 반입 금지 — 참조만 허용(오너 결정, 완화 불가).
|
|
196
202
|
if (value.containsBasePackageMaterial !== false) throw new Error("experience pack must reference the base release; copied base material is forbidden");
|
|
197
203
|
if (!HASH_RE.test(String(value.contentHash || ""))) throw new Error("experience pack.contentHash is invalid");
|
package/engine/hub/install.cjs
CHANGED
|
@@ -255,20 +255,79 @@ function cloudApplyPortableFileMode(filePath, mode, platform = process.platform)
|
|
|
255
255
|
) throw new Error(`cloud restore file mode verification failed: ${filePath}`);
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
function cloudDirectoryAnchor(target, label, { allowMissing = true, containedBy = null } = {}) {
|
|
259
|
+
let stat;
|
|
260
|
+
try { stat = fs.lstatSync(target); } catch (error) {
|
|
261
|
+
if (allowMissing && error && error.code === "ENOENT") return null;
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
265
|
+
throw new Error(`${label} is not a safe managed directory`);
|
|
266
|
+
}
|
|
267
|
+
let realpath;
|
|
268
|
+
try { realpath = fs.realpathSync.native(target); }
|
|
269
|
+
catch (error) { throw new Error(`${label} could not be canonicalized: ${error.message}`); }
|
|
270
|
+
if (containedBy && !(
|
|
271
|
+
realpath === containedBy.realpath || realpath.startsWith(`${containedBy.realpath}${path.sep}`)
|
|
272
|
+
)) {
|
|
273
|
+
throw new Error(`${label} escapes its managed root`);
|
|
274
|
+
}
|
|
275
|
+
return { path: target, realpath, dev: stat.dev, ino: stat.ino, nlink: stat.nlink, stat };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function cloudAssertDirectoryAnchor(anchor, label, containedBy = null) {
|
|
279
|
+
const current = cloudDirectoryAnchor(anchor.path, label, { allowMissing: false, containedBy });
|
|
280
|
+
if (
|
|
281
|
+
current.realpath !== anchor.realpath || current.dev !== anchor.dev ||
|
|
282
|
+
current.ino !== anchor.ino || current.nlink !== anchor.nlink
|
|
283
|
+
) {
|
|
284
|
+
throw new Error(`${label} changed while it was being used`);
|
|
285
|
+
}
|
|
286
|
+
return current;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function cloudRefreshDirectoryAnchor(anchor, label, containedBy = null) {
|
|
290
|
+
const current = cloudDirectoryAnchor(anchor.path, label, { allowMissing: false, containedBy });
|
|
291
|
+
if (
|
|
292
|
+
current.realpath !== anchor.realpath || current.dev !== anchor.dev ||
|
|
293
|
+
current.ino !== anchor.ino
|
|
294
|
+
) {
|
|
295
|
+
throw new Error(`${label} changed while it was being used`);
|
|
296
|
+
}
|
|
297
|
+
return current;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function cloudSameRegularFile(left, right) {
|
|
301
|
+
return Boolean(
|
|
302
|
+
left && right && left.isFile() && right.isFile() &&
|
|
303
|
+
!left.isSymbolicLink() && !right.isSymbolicLink() &&
|
|
304
|
+
left.dev === right.dev && left.ino === right.ino && left.nlink === right.nlink,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
258
308
|
function cloudEnsurePrivateSubdirectory(root, directory) {
|
|
259
309
|
const relative = path.relative(root, directory);
|
|
260
310
|
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
261
311
|
throw new Error("cloud restore subdirectory escapes staging");
|
|
262
312
|
}
|
|
263
313
|
let current = root;
|
|
264
|
-
|
|
314
|
+
const rootAnchor = cloudDirectoryAnchor(root, "cloud restore staging", { allowMissing: false });
|
|
315
|
+
const managedPaths = [root];
|
|
265
316
|
for (const part of relative.split(path.sep).filter(Boolean)) {
|
|
266
317
|
current = path.join(current, part);
|
|
267
318
|
try { fs.mkdirSync(current, { recursive: false, mode: 0o700 }); }
|
|
268
319
|
catch (error) { if (!error || error.code !== "EEXIST") throw error; }
|
|
269
|
-
cloudManagedDirectoryState(current, "cloud restore package directory", { allowMissing: false });
|
|
270
320
|
cloudApplyPrivateDirectoryMode(current);
|
|
321
|
+
managedPaths.push(current);
|
|
271
322
|
}
|
|
323
|
+
// Directory nlink changes when a child directory is added, so take every
|
|
324
|
+
// anchor after the complete path is present rather than before a descendant
|
|
325
|
+
// mkdir can legitimately change an ancestor's nlink.
|
|
326
|
+
return managedPaths.map((managedPath, index) => cloudDirectoryAnchor(
|
|
327
|
+
managedPath,
|
|
328
|
+
index === 0 ? "cloud restore staging" : "cloud restore package directory",
|
|
329
|
+
{ allowMissing: false, ...(index === 0 ? {} : { containedBy: rootAnchor }) },
|
|
330
|
+
));
|
|
272
331
|
}
|
|
273
332
|
|
|
274
333
|
// ── 스테이징 스냅샷 전수 검증 (심링크/특수 엔트리/모드/무결성) ──
|
|
@@ -387,18 +446,43 @@ function cloudManagedDirectoryState(target, label, { allowMissing = true } = {})
|
|
|
387
446
|
return stat;
|
|
388
447
|
}
|
|
389
448
|
|
|
390
|
-
function cloudRemoveManagedDirectory(target, label) {
|
|
391
|
-
if (
|
|
449
|
+
function cloudRemoveManagedDirectory(target, label, { anchor = null, containedBy = null } = {}) {
|
|
450
|
+
if (anchor) {
|
|
451
|
+
let current;
|
|
452
|
+
try {
|
|
453
|
+
current = cloudDirectoryAnchor(target, label, { allowMissing: true, containedBy });
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (error && error.code === "ENOENT") return false;
|
|
456
|
+
throw error;
|
|
457
|
+
}
|
|
458
|
+
if (!current) return false;
|
|
459
|
+
if (
|
|
460
|
+
current.realpath !== anchor.realpath || current.dev !== anchor.dev ||
|
|
461
|
+
current.ino !== anchor.ino
|
|
462
|
+
) throw new Error(`${label} changed while it was being removed`);
|
|
463
|
+
} else if (containedBy) {
|
|
464
|
+
if (!cloudDirectoryAnchor(target, label, { allowMissing: true, containedBy })) return false;
|
|
465
|
+
} else if (!cloudManagedDirectoryState(target, label)) return false;
|
|
392
466
|
fs.rmSync(target, { recursive: true, force: false });
|
|
393
467
|
return true;
|
|
394
468
|
}
|
|
395
469
|
|
|
396
|
-
function cloudRenameManagedDirectory(source, destination, label) {
|
|
397
|
-
|
|
470
|
+
function cloudRenameManagedDirectory(source, destination, label, { sourceAnchor = null, parentAnchor = null } = {}) {
|
|
471
|
+
if (parentAnchor) cloudRefreshDirectoryAnchor(parentAnchor, `${label} parent`);
|
|
472
|
+
if (sourceAnchor) {
|
|
473
|
+
cloudRefreshDirectoryAnchor(sourceAnchor, `${label} source`, parentAnchor);
|
|
474
|
+
} else {
|
|
475
|
+
cloudManagedDirectoryState(source, `${label} source`, { allowMissing: false });
|
|
476
|
+
}
|
|
398
477
|
if (cloudManagedDirectoryState(destination, `${label} destination`)) {
|
|
399
478
|
throw new Error(`${label} destination already exists`);
|
|
400
479
|
}
|
|
480
|
+
if (parentAnchor) cloudRefreshDirectoryAnchor(parentAnchor, `${label} parent`);
|
|
401
481
|
fs.renameSync(source, destination);
|
|
482
|
+
if (parentAnchor) {
|
|
483
|
+
cloudRefreshDirectoryAnchor(parentAnchor, `${label} parent`);
|
|
484
|
+
cloudDirectoryAnchor(destination, `${label} destination`, { allowMissing: false, containedBy: parentAnchor });
|
|
485
|
+
}
|
|
402
486
|
}
|
|
403
487
|
|
|
404
488
|
function cloudReadInstallJournal(journalPath) {
|
|
@@ -472,12 +556,36 @@ function cloudFsyncDirectory(directory) {
|
|
|
472
556
|
finally { if (fd !== undefined) try { fs.closeSync(fd); } catch { /* best-effort */ } }
|
|
473
557
|
}
|
|
474
558
|
|
|
475
|
-
function rollbackCloudInstallSwap({
|
|
476
|
-
|
|
559
|
+
function rollbackCloudInstallSwap({
|
|
560
|
+
destination,
|
|
561
|
+
staging,
|
|
562
|
+
backup,
|
|
563
|
+
movedExisting,
|
|
564
|
+
installed,
|
|
565
|
+
parentAnchor = null,
|
|
566
|
+
stagingAnchor = null,
|
|
567
|
+
backupAnchor = null,
|
|
568
|
+
destinationAnchor = null,
|
|
569
|
+
}) {
|
|
570
|
+
const safeParent = parentAnchor
|
|
571
|
+
? cloudRefreshDirectoryAnchor(parentAnchor, "cloud install parent rollback")
|
|
572
|
+
: null;
|
|
573
|
+
if (installed) {
|
|
574
|
+
cloudRemoveManagedDirectory(destination, "cloud install destination", {
|
|
575
|
+
anchor: destinationAnchor,
|
|
576
|
+
containedBy: safeParent,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
477
579
|
if (movedExisting && cloudManagedDirectoryState(backup, "cloud install backup")) {
|
|
478
|
-
cloudRenameManagedDirectory(backup, destination, "cloud install rollback"
|
|
580
|
+
cloudRenameManagedDirectory(backup, destination, "cloud install rollback", {
|
|
581
|
+
sourceAnchor: backupAnchor,
|
|
582
|
+
parentAnchor: safeParent,
|
|
583
|
+
});
|
|
479
584
|
}
|
|
480
|
-
cloudRemoveManagedDirectory(staging, "cloud install staging"
|
|
585
|
+
cloudRemoveManagedDirectory(staging, "cloud install staging", {
|
|
586
|
+
anchor: stagingAnchor,
|
|
587
|
+
containedBy: safeParent,
|
|
588
|
+
});
|
|
481
589
|
cloudFsyncDirectory(path.dirname(destination));
|
|
482
590
|
}
|
|
483
591
|
|
|
@@ -660,17 +768,33 @@ function materializeCloudListing(agentId, slug, listing, options = {}) {
|
|
|
660
768
|
if (pathConflict) throw new Error(pathConflict.message);
|
|
661
769
|
const layout = cloudInstallLayout(slug, { createParent: true });
|
|
662
770
|
const { destination: dir, parent, journalPath: journal } = layout;
|
|
771
|
+
let parentAnchor = cloudDirectoryAnchor(parent, "cloud install parent", { allowMissing: false });
|
|
663
772
|
const nonce = `${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
|
|
664
773
|
const staging = path.join(parent, `.${path.basename(dir)}.installing-${nonce}`);
|
|
665
774
|
const backup = path.join(parent, `.${path.basename(dir)}.backup-${nonce}`);
|
|
775
|
+
const managedAnchors = new Map();
|
|
666
776
|
const seen = new Set();
|
|
667
777
|
const verifiedFiles = [];
|
|
668
778
|
let verifiedTotalBytes = 0;
|
|
669
779
|
let movedExisting = false;
|
|
670
780
|
let installed = false;
|
|
781
|
+
let stagingAnchor = null;
|
|
782
|
+
let destinationAnchor = null;
|
|
783
|
+
let backupAnchor = null;
|
|
784
|
+
let installedDestinationAnchor = null;
|
|
671
785
|
try {
|
|
786
|
+
cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
|
|
672
787
|
fs.mkdirSync(staging, { recursive: false, mode: 0o700 });
|
|
788
|
+
// Creating the staging directory legitimately changes the parent's nlink;
|
|
789
|
+
// refresh that field while retaining the original dev/ino/realpath anchor.
|
|
790
|
+
parentAnchor = cloudRefreshDirectoryAnchor(parentAnchor, "cloud install parent");
|
|
673
791
|
cloudApplyPrivateDirectoryMode(staging);
|
|
792
|
+
stagingAnchor = cloudDirectoryAnchor(staging, "cloud install staging", {
|
|
793
|
+
allowMissing: false,
|
|
794
|
+
containedBy: parentAnchor,
|
|
795
|
+
});
|
|
796
|
+
managedAnchors.set(staging, stagingAnchor);
|
|
797
|
+
cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
|
|
674
798
|
for (const file of pkg.files) {
|
|
675
799
|
const target = resolveCloudInstallPath(staging, file.path);
|
|
676
800
|
const normalizedPath = path.relative(staging, target).split(path.sep).join("/");
|
|
@@ -700,19 +824,62 @@ function materializeCloudListing(agentId, slug, listing, options = {}) {
|
|
|
700
824
|
});
|
|
701
825
|
verifiedTotalBytes += bytes.length;
|
|
702
826
|
if (verifiedTotalBytes > CLOUD_MAX_TOTAL_BYTES) throw new Error("cloud package exceeds total byte limit");
|
|
703
|
-
cloudEnsurePrivateSubdirectory(staging, path.dirname(target))
|
|
827
|
+
for (const anchor of cloudEnsurePrivateSubdirectory(staging, path.dirname(target))) {
|
|
828
|
+
managedAnchors.set(anchor.path, anchor);
|
|
829
|
+
}
|
|
830
|
+
stagingAnchor = managedAnchors.get(staging);
|
|
831
|
+
cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
|
|
832
|
+
for (const anchor of managedAnchors.values()) {
|
|
833
|
+
cloudAssertDirectoryAnchor(anchor, "cloud install managed directory", stagingAnchor);
|
|
834
|
+
}
|
|
704
835
|
const mode = packageHashVersion === CLOUD_PACKAGE_HASH_V2 && file.executable ? 0o700 : 0o600;
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW || 0),
|
|
708
|
-
mode,
|
|
709
|
-
);
|
|
836
|
+
let fileFd;
|
|
837
|
+
let fileWritten = false;
|
|
710
838
|
try {
|
|
839
|
+
fileFd = fs.openSync(
|
|
840
|
+
target,
|
|
841
|
+
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW || 0),
|
|
842
|
+
mode,
|
|
843
|
+
);
|
|
844
|
+
// A parent swap can happen during open. Do not write until the
|
|
845
|
+
// directory anchors and the opened file identity still agree.
|
|
846
|
+
cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
|
|
847
|
+
// O_CREAT may legitimately change a directory's nlink on some
|
|
848
|
+
// filesystems, so refresh that field after the open while retaining
|
|
849
|
+
// the dev/ino/realpath identity and containment checks.
|
|
850
|
+
stagingAnchor = cloudRefreshDirectoryAnchor(stagingAnchor, "cloud install staging", parentAnchor);
|
|
851
|
+
managedAnchors.set(staging, stagingAnchor);
|
|
852
|
+
for (const [anchorPath, anchor] of managedAnchors.entries()) {
|
|
853
|
+
if (anchorPath === staging) continue;
|
|
854
|
+
managedAnchors.set(anchorPath, cloudRefreshDirectoryAnchor(
|
|
855
|
+
anchor,
|
|
856
|
+
"cloud install managed directory",
|
|
857
|
+
stagingAnchor,
|
|
858
|
+
));
|
|
859
|
+
}
|
|
860
|
+
const opened = fs.fstatSync(fileFd);
|
|
861
|
+
const listed = fs.lstatSync(target);
|
|
862
|
+
if (!cloudSameRegularFile(opened, listed) || opened.size !== 0) {
|
|
863
|
+
throw new Error(`cloud package target changed while opening: ${file.path}`);
|
|
864
|
+
}
|
|
711
865
|
fs.writeFileSync(fileFd, bytes);
|
|
712
866
|
if (process.platform !== "win32") fs.fchmodSync(fileFd, mode);
|
|
713
867
|
fs.fsyncSync(fileFd);
|
|
868
|
+
fileWritten = true;
|
|
714
869
|
} finally {
|
|
715
|
-
|
|
870
|
+
if (fileFd !== undefined) {
|
|
871
|
+
if (!fileWritten) {
|
|
872
|
+
// O_EXCL creates a zero-byte file before the final anchor check
|
|
873
|
+
// can reject a swapped parent. Remove it only when the path still
|
|
874
|
+
// names the exact descriptor we opened; never unlink a successor.
|
|
875
|
+
try {
|
|
876
|
+
const opened = fs.fstatSync(fileFd);
|
|
877
|
+
const listed = fs.lstatSync(target);
|
|
878
|
+
if (cloudSameRegularFile(opened, listed)) fs.unlinkSync(target);
|
|
879
|
+
} catch { /* outer rollback retains any unknown successor */ }
|
|
880
|
+
}
|
|
881
|
+
fs.closeSync(fileFd);
|
|
882
|
+
}
|
|
716
883
|
}
|
|
717
884
|
}
|
|
718
885
|
const expectedPackageHash = String(pkg.packageHash || "").toLowerCase().replace(/^sha256:/, "");
|
|
@@ -732,12 +899,33 @@ function materializeCloudListing(agentId, slug, listing, options = {}) {
|
|
|
732
899
|
if (verifiedTotalBytes !== pkg.totalBytes) throw new Error("cloud package total byte count does not match its files");
|
|
733
900
|
const restoredAt = new Date().toISOString();
|
|
734
901
|
const markerPath = path.join(staging, CLOUD_RESTORE_MARKER_PATH);
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
);
|
|
902
|
+
stagingAnchor = cloudDirectoryAnchor(staging, "cloud install staging", {
|
|
903
|
+
allowMissing: false,
|
|
904
|
+
containedBy: parentAnchor,
|
|
905
|
+
});
|
|
906
|
+
managedAnchors.set(staging, stagingAnchor);
|
|
907
|
+
cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
|
|
908
|
+
for (const anchor of managedAnchors.values()) {
|
|
909
|
+
cloudAssertDirectoryAnchor(anchor, "cloud install managed directory", stagingAnchor);
|
|
910
|
+
}
|
|
911
|
+
let markerFd;
|
|
912
|
+
let markerWritten = false;
|
|
740
913
|
try {
|
|
914
|
+
markerFd = fs.openSync(
|
|
915
|
+
markerPath,
|
|
916
|
+
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW || 0),
|
|
917
|
+
0o600,
|
|
918
|
+
);
|
|
919
|
+
cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
|
|
920
|
+
// As with package files, creating the marker can update the staging
|
|
921
|
+
// directory nlink; refresh it before writing and keep identity strict.
|
|
922
|
+
stagingAnchor = cloudRefreshDirectoryAnchor(stagingAnchor, "cloud install staging", parentAnchor);
|
|
923
|
+
managedAnchors.set(staging, stagingAnchor);
|
|
924
|
+
const opened = fs.fstatSync(markerFd);
|
|
925
|
+
const listed = fs.lstatSync(markerPath);
|
|
926
|
+
if (!cloudSameRegularFile(opened, listed) || opened.size !== 0) {
|
|
927
|
+
throw new Error("cloud restore marker changed while opening");
|
|
928
|
+
}
|
|
741
929
|
fs.writeFileSync(markerFd, JSON.stringify({
|
|
742
930
|
schemaVersion: 1,
|
|
743
931
|
source: "agentlas-cloud",
|
|
@@ -761,8 +949,22 @@ function materializeCloudListing(agentId, slug, listing, options = {}) {
|
|
|
761
949
|
}, null, 2) + "\n", "utf8");
|
|
762
950
|
if (process.platform !== "win32") fs.fchmodSync(markerFd, 0o600);
|
|
763
951
|
fs.fsyncSync(markerFd);
|
|
952
|
+
markerWritten = true;
|
|
764
953
|
} finally {
|
|
765
|
-
|
|
954
|
+
if (markerFd !== undefined) {
|
|
955
|
+
if (!markerWritten) {
|
|
956
|
+
try {
|
|
957
|
+
const opened = fs.fstatSync(markerFd);
|
|
958
|
+
const listed = fs.lstatSync(markerPath);
|
|
959
|
+
if (cloudSameRegularFile(opened, listed)) fs.unlinkSync(markerPath);
|
|
960
|
+
} catch { /* outer rollback retains any unknown successor */ }
|
|
961
|
+
}
|
|
962
|
+
fs.closeSync(markerFd);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
|
|
966
|
+
for (const anchor of managedAnchors.values()) {
|
|
967
|
+
cloudAssertDirectoryAnchor(anchor, "cloud install managed directory", stagingAnchor);
|
|
766
968
|
}
|
|
767
969
|
cloudVerifyRestoredSnapshot(staging, verifiedFiles, {
|
|
768
970
|
slug,
|
|
@@ -773,6 +975,7 @@ function materializeCloudListing(agentId, slug, listing, options = {}) {
|
|
|
773
975
|
});
|
|
774
976
|
|
|
775
977
|
if (options.deferCommit) {
|
|
978
|
+
cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
|
|
776
979
|
writeCloudInstallJournal(journal, {
|
|
777
980
|
schemaVersion: 1,
|
|
778
981
|
slug,
|
|
@@ -783,15 +986,42 @@ function materializeCloudListing(agentId, slug, listing, options = {}) {
|
|
|
783
986
|
hadExisting: Boolean(cloudManagedDirectoryState(dir, "cloud install destination")),
|
|
784
987
|
dbExpected: options.dbExpected || {},
|
|
785
988
|
});
|
|
989
|
+
// The recovery journal is a file in the managed parent and may update
|
|
990
|
+
// that directory's nlink; refresh the field before publication checks.
|
|
991
|
+
parentAnchor = cloudRefreshDirectoryAnchor(parentAnchor, "cloud install parent");
|
|
786
992
|
}
|
|
787
993
|
|
|
788
994
|
// A Cloud agent is an immutable asset snapshot. Replace the managed install
|
|
789
995
|
// as a whole so removed files and local mutations cannot leak across versions.
|
|
996
|
+
cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
|
|
997
|
+
for (const anchor of managedAnchors.values()) {
|
|
998
|
+
cloudAssertDirectoryAnchor(anchor, "cloud install managed directory", stagingAnchor);
|
|
999
|
+
}
|
|
790
1000
|
if (cloudManagedDirectoryState(dir, "cloud install destination")) {
|
|
791
|
-
|
|
1001
|
+
destinationAnchor = cloudDirectoryAnchor(dir, "cloud install destination", {
|
|
1002
|
+
allowMissing: false,
|
|
1003
|
+
containedBy: parentAnchor,
|
|
1004
|
+
});
|
|
1005
|
+
cloudRenameManagedDirectory(dir, backup, "cloud install snapshot swap", {
|
|
1006
|
+
sourceAnchor: destinationAnchor,
|
|
1007
|
+
parentAnchor,
|
|
1008
|
+
});
|
|
792
1009
|
movedExisting = true;
|
|
1010
|
+
backupAnchor = cloudDirectoryAnchor(backup, "cloud install backup", {
|
|
1011
|
+
allowMissing: false,
|
|
1012
|
+
containedBy: parentAnchor,
|
|
1013
|
+
});
|
|
793
1014
|
}
|
|
794
|
-
|
|
1015
|
+
cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
|
|
1016
|
+
cloudRenameManagedDirectory(staging, dir, "cloud install staging swap", {
|
|
1017
|
+
sourceAnchor: stagingAnchor,
|
|
1018
|
+
parentAnchor,
|
|
1019
|
+
});
|
|
1020
|
+
cloudAssertDirectoryAnchor(parentAnchor, "cloud install parent");
|
|
1021
|
+
installedDestinationAnchor = cloudDirectoryAnchor(dir, "cloud install destination", {
|
|
1022
|
+
allowMissing: false,
|
|
1023
|
+
containedBy: parentAnchor,
|
|
1024
|
+
});
|
|
795
1025
|
cloudFsyncDirectory(parent);
|
|
796
1026
|
installed = true;
|
|
797
1027
|
if (options.deferCommit) {
|
|
@@ -807,12 +1037,34 @@ function materializeCloudListing(agentId, slug, listing, options = {}) {
|
|
|
807
1037
|
});
|
|
808
1038
|
}
|
|
809
1039
|
} catch (error) {
|
|
810
|
-
rollbackCloudInstallSwap({
|
|
1040
|
+
rollbackCloudInstallSwap({
|
|
1041
|
+
destination: dir,
|
|
1042
|
+
staging,
|
|
1043
|
+
backup,
|
|
1044
|
+
movedExisting,
|
|
1045
|
+
installed,
|
|
1046
|
+
parentAnchor,
|
|
1047
|
+
stagingAnchor,
|
|
1048
|
+
backupAnchor,
|
|
1049
|
+
destinationAnchor: installedDestinationAnchor,
|
|
1050
|
+
});
|
|
811
1051
|
try { if (fs.existsSync(journal)) cloudUnlinkInstallJournal(journal); } catch { /* best-effort */ }
|
|
812
1052
|
throw error;
|
|
813
1053
|
} finally {
|
|
814
|
-
try {
|
|
815
|
-
|
|
1054
|
+
try {
|
|
1055
|
+
cloudRemoveManagedDirectory(staging, "cloud install staging", {
|
|
1056
|
+
anchor: stagingAnchor,
|
|
1057
|
+
containedBy: parentAnchor,
|
|
1058
|
+
});
|
|
1059
|
+
} catch { /* best-effort */ }
|
|
1060
|
+
try {
|
|
1061
|
+
if (!options.deferCommit && installed) {
|
|
1062
|
+
cloudRemoveManagedDirectory(backup, "cloud install backup", {
|
|
1063
|
+
anchor: backupAnchor,
|
|
1064
|
+
containedBy: parentAnchor,
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
} catch { /* best-effort */ }
|
|
816
1068
|
}
|
|
817
1069
|
if (!options.deferCommit) return dir;
|
|
818
1070
|
let settled = false;
|