agentlas 0.9.10 → 1.0.2
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 +106 -0
- package/README.md +319 -341
- package/bin/agentlas.cjs +32 -9
- package/engine/agentlas-banner.cjs +24 -3
- package/engine/agentlas-composer.cjs +239 -31
- package/engine/agentlas-config.cjs +103 -7
- package/engine/agentlas-core-harness.cjs +48 -4
- package/engine/agentlas-evolution.cjs +3 -4
- package/engine/agentlas-i18n.cjs +88 -32
- package/engine/agentlas-input.cjs +234 -18
- package/engine/agentlas-memory-governance.cjs +3 -5
- package/engine/agentlas-memory-import.cjs +3 -3
- package/engine/agentlas-native-host.cjs +200 -9
- package/engine/agentlas-onboard.cjs +112 -20
- package/engine/agentlas-permissions.cjs +14 -7
- package/engine/agentlas-sqlite-policy.cjs +34 -0
- package/engine/agentlas-ui.cjs +93 -42
- package/engine/agentlas-workforce.cjs +472 -58
- package/engine/agentlas-workload-routing.cjs +8 -3
- package/engine/agentlas.cjs +140 -12367
- package/engine/agents/files.cjs +61 -0
- package/engine/agents/import-local.cjs +237 -0
- package/engine/agents/registry.cjs +158 -0
- package/engine/agents/router.cjs +565 -0
- package/engine/agents/routes.cjs +43 -0
- package/engine/architecture.data.json +2 -2
- package/engine/automation/daemon.cjs +335 -0
- package/engine/automation/schedule.cjs +181 -0
- package/engine/automation/store.cjs +209 -0
- package/engine/cloud/auth.cjs +279 -0
- package/engine/cloud/hub-client.cjs +239 -0
- package/engine/cloud-assets/cargo.cjs +49 -0
- package/engine/cloud-assets/cas.cjs +235 -0
- package/engine/cloud-assets/commands.cjs +273 -0
- package/engine/cloud-assets/package.cjs +936 -0
- package/engine/cloud-assets/restore.cjs +172 -0
- package/engine/cloud-assets/state.cjs +268 -0
- package/engine/commands/automation.cjs +195 -0
- package/engine/commands/billing.cjs +96 -0
- package/engine/commands/browser.cjs +20 -0
- package/engine/commands/build.cjs +33 -0
- package/engine/commands/call.cjs +24 -0
- package/engine/commands/career-graph.cjs +51 -0
- package/engine/commands/cd.cjs +22 -0
- package/engine/commands/chat.cjs +12 -0
- package/engine/commands/chats.cjs +28 -0
- package/engine/commands/cloud.cjs +17 -0
- package/engine/commands/connect.cjs +20 -0
- package/engine/commands/context.cjs +66 -0
- package/engine/commands/creds.cjs +203 -0
- package/engine/commands/doctor.cjs +68 -0
- package/engine/commands/env.cjs +33 -0
- package/engine/commands/evolve.cjs +23 -0
- package/engine/commands/experience.cjs +34 -0
- package/engine/commands/film.cjs +8 -0
- package/engine/commands/firm.cjs +115 -0
- package/engine/commands/help.cjs +65 -0
- package/engine/commands/hep.cjs +23 -0
- package/engine/commands/import.cjs +35 -0
- package/engine/commands/index.cjs +136 -0
- package/engine/commands/install.cjs +27 -0
- package/engine/commands/journal.cjs +31 -0
- package/engine/commands/legacy-network.cjs +29 -0
- package/engine/commands/list.cjs +49 -0
- package/engine/commands/login.cjs +68 -0
- package/engine/commands/logout.cjs +28 -0
- package/engine/commands/mcp.cjs +91 -0
- package/engine/commands/memory.cjs +21 -0
- package/engine/commands/multimodal.cjs +91 -0
- package/engine/commands/native.cjs +34 -0
- package/engine/commands/netadmin.cjs +31 -0
- package/engine/commands/oberon.cjs +70 -0
- package/engine/commands/ontology.cjs +24 -0
- package/engine/commands/open.cjs +48 -0
- package/engine/commands/plugin.cjs +101 -0
- package/engine/commands/project.cjs +47 -0
- package/engine/commands/research.cjs +36 -0
- package/engine/commands/route.cjs +37 -0
- package/engine/commands/run.cjs +149 -0
- package/engine/commands/search.cjs +55 -0
- package/engine/commands/setup.cjs +45 -0
- package/engine/commands/storm.cjs +75 -0
- package/engine/commands/swarm.cjs +75 -0
- package/engine/commands/telegram.cjs +32 -0
- package/engine/commands/uninstall.cjs +68 -0
- package/engine/commands/update.cjs +54 -0
- package/engine/commands/upload.cjs +18 -0
- package/engine/commands/usage.cjs +35 -0
- package/engine/commands/variant.cjs +25 -0
- package/engine/commands/version.cjs +9 -0
- package/engine/commands/whoami.cjs +38 -0
- package/engine/commands/workforce.cjs +103 -0
- package/engine/core/db.cjs +160 -0
- package/engine/core/paths.cjs +35 -0
- package/engine/experience/build.cjs +181 -0
- package/engine/experience/intents.cjs +492 -0
- package/engine/experience/runtime.cjs +242 -0
- package/engine/experience/variant.cjs +196 -0
- package/engine/firms/orchestrate.cjs +333 -0
- package/engine/hephaestus/runtime.cjs +697 -0
- package/engine/hub/install.cjs +872 -0
- package/engine/hub/plugins.cjs +213 -0
- package/engine/mcp/consent.cjs +289 -0
- package/engine/mcp/contract.cjs +202 -0
- package/engine/mcp/index.cjs +43 -0
- package/engine/mcp/inventory.cjs +322 -0
- package/engine/mcp/plan.cjs +286 -0
- package/engine/mcp/probe.cjs +151 -0
- package/engine/memory-cli/curate.cjs +163 -0
- package/engine/oberon/common.cjs +69 -0
- package/engine/oberon/manifest.cjs +164 -0
- package/engine/oberon/outputs.cjs +70 -0
- package/engine/oberon/render.cjs +164 -0
- package/engine/project/career-graph.cjs +249 -0
- package/engine/project/credentials.cjs +262 -0
- package/engine/project/env-file.cjs +46 -0
- package/engine/project/index.cjs +27 -0
- package/engine/project/memory-context.cjs +453 -0
- package/engine/project/ontology.cjs +467 -0
- package/engine/project/paths.cjs +39 -0
- package/engine/project/seed.cjs +200 -0
- package/engine/project/state.cjs +403 -0
- package/engine/project/super-ontology-seed.json +3288 -0
- package/engine/runtimes/detect.cjs +54 -0
- package/engine/runtimes/overrides.cjs +139 -0
- package/engine/runtimes/resolve.cjs +64 -0
- package/engine/sessions/apply-fences.cjs +188 -0
- package/engine/sessions/fences.cjs +362 -0
- package/engine/sessions/orchestrator.cjs +170 -0
- package/engine/sessions/prompt.cjs +212 -0
- package/engine/sessions/session.cjs +245 -0
- package/engine/sessions/sink.cjs +54 -0
- package/engine/sessions/store.cjs +79 -0
- package/engine/storm/deps.cjs +88 -0
- package/engine/storm/storm.cjs +218 -0
- package/engine/storm/swarm.cjs +422 -0
- package/engine/ui/palette.cjs +105 -0
- package/engine/ui/renderer.cjs +85 -0
- package/engine/ui/repl.cjs +444 -0
- package/engine/workforce/capture.cjs +701 -0
- package/engine/workforce/deps.cjs +472 -0
- package/package.json +2 -6
- package/engine/agentlas-experience-mcp.cjs +0 -1709
- package/engine/agentlas-parity.cjs +0 -1499
- package/engine/agentlas-repl.cjs +0 -1780
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* hub/install — Hub 에이전트 소스 설치 (persist + materialize + crash-recovery journal).
|
|
4
|
+
*
|
|
5
|
+
* v1 monolith(cmdCloudInstall/persistCloudListingCli 계열)의 충실한 이식.
|
|
6
|
+
* 핵심 계약(약화 금지):
|
|
7
|
+
* - DB 행과 materialize된 파일은 함께 커밋/롤백된다. materialize는 deferCommit
|
|
8
|
+
* 모드로 스테이징→스왑까지만 하고 저널을 남긴 뒤, DB 트랜잭션이 커밋되면
|
|
9
|
+
* restore.commit(), 실패하면 restore.rollback()으로 디스크를 원상복구한다.
|
|
10
|
+
* - 크래시 창은 저널(phase: prepared / disk-swapped-db-pending / db-committed)로
|
|
11
|
+
* 복구한다. DB 기대값(dbExpected)과 실제 행을 대조해 커밋 여부를 판정한다.
|
|
12
|
+
* - 패키지 무결성: 파일별 sha256 + 집계 해시(정렬 순서 계약) + 바이트 수 3중 검증.
|
|
13
|
+
* 레거시 v1 해시(저장순)와 v2(executable 플래그 포함)를 구분한다.
|
|
14
|
+
* - call_only Hub 자산은 소스 설치 불가 — 정직 거절(로컬 위장 설치 금지).
|
|
15
|
+
*
|
|
16
|
+
* 테스트 주입: installHubAgent(db, slug, { callTool })의 callTool로 네트워크 없이
|
|
17
|
+
* 매니페스트를 공급할 수 있다(hub-client options.fetch와 같은 설계).
|
|
18
|
+
*/
|
|
19
|
+
const fs = require("node:fs");
|
|
20
|
+
const path = require("node:path");
|
|
21
|
+
const crypto = require("node:crypto");
|
|
22
|
+
const { userDataDir } = require("../core/paths.cjs");
|
|
23
|
+
const { columnExists } = require("../core/db.cjs");
|
|
24
|
+
// v1 계약: persist의 트랜잭션은 sqlite-policy의 관용형(runWriteTransaction)이다 —
|
|
25
|
+
// .transaction이 없는 드라이버/주입 DB에서는 트랜잭션 없이 fn을 실행한다(v1 테스트 계약).
|
|
26
|
+
const { runWriteTransaction } = require("../agentlas-sqlite-policy.cjs");
|
|
27
|
+
const { callHubTool } = require("../cloud/hub-client.cjs");
|
|
28
|
+
|
|
29
|
+
// ── 패키지 상한/식별 상수 (서버 package-contract와 동일) ──
|
|
30
|
+
const CLOUD_MAX_TOTAL_BYTES = 3 * 1024 * 1024;
|
|
31
|
+
const CLOUD_MAX_FILE_BYTES = 512 * 1024;
|
|
32
|
+
const CLOUD_MAX_FILES = 400;
|
|
33
|
+
const CLOUD_PACKAGE_HASH_V1 = "path-sha256-v1";
|
|
34
|
+
const CLOUD_PACKAGE_HASH_V2 = "path-sha256-executable-v2";
|
|
35
|
+
const CLOUD_RESTORE_MARKER_PATH = ".agentlas-cloud-package.json";
|
|
36
|
+
const CLOUD_ASSET_SCOPES = new Set(["owner-private", "hub-public"]);
|
|
37
|
+
const CLOUD_LOCAL_EXPERIENCE_LINEAGE_PATH = ".agentlas/experience-relations.jsonl";
|
|
38
|
+
|
|
39
|
+
function sha(value) {
|
|
40
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function cloudSlug(value) {
|
|
44
|
+
return (String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "agentlas-cloud-agent");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function exists(p) { try { fs.accessSync(p); return true; } catch { return false; } }
|
|
48
|
+
|
|
49
|
+
// 팀 감지 — 루트뿐 아니라 .claude/ 중첩 구조도 인식한다 (appbridge 처럼).
|
|
50
|
+
function detectKind(dir) {
|
|
51
|
+
const rootMarkers = ["TEAM.md", "ceo", "hr-departments", "projects"];
|
|
52
|
+
for (const m of rootMarkers) if (exists(path.join(dir, m))) return "team";
|
|
53
|
+
const nestedMarkers = [".claude/ceo", ".claude/hr-departments", ".claude/agents", ".claude/orgspec.yaml"];
|
|
54
|
+
for (const m of nestedMarkers) if (exists(path.join(dir, m))) return "team";
|
|
55
|
+
return "agent";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── 포터블 경로 규칙 ──
|
|
59
|
+
function cloudPortablePathKey(value) {
|
|
60
|
+
return String(value).normalize("NFC").toLowerCase();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function cloudHasUnpairedSurrogate(value) {
|
|
64
|
+
for (let index = 0; index < value.length; index++) {
|
|
65
|
+
const unit = value.charCodeAt(index);
|
|
66
|
+
if (unit >= 0xd800 && unit <= 0xdbff) {
|
|
67
|
+
const next = value.charCodeAt(index + 1);
|
|
68
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) return true;
|
|
69
|
+
index++;
|
|
70
|
+
} else if (unit >= 0xdc00 && unit <= 0xdfff) return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function cloudPortableRelativePath(value) {
|
|
76
|
+
if (typeof value !== "string" || !value || value !== value.normalize("NFC")) return null;
|
|
77
|
+
if (value.includes("\\") || value.includes("\0") || value.startsWith("/") || value.endsWith("/")) return null;
|
|
78
|
+
if (value.includes("//") || value.length > 260) return null;
|
|
79
|
+
const parts = value.split("/");
|
|
80
|
+
for (const part of parts) {
|
|
81
|
+
if (!part || part === "." || part === "..") return null;
|
|
82
|
+
if (part.length > 255 || Buffer.byteLength(part, "utf8") > 255 || cloudHasUnpairedSurrogate(part)) return null;
|
|
83
|
+
if (/[<>:"|?*\u0000-\u001f]/.test(part) || /[ .]$/.test(part)) return null;
|
|
84
|
+
if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)) return null;
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function cloudPortablePathConflict(paths) {
|
|
90
|
+
const files = new Map();
|
|
91
|
+
const directories = new Map();
|
|
92
|
+
for (const value of paths) {
|
|
93
|
+
if (typeof value !== "string" || !value) continue;
|
|
94
|
+
const fileKey = cloudPortablePathKey(value);
|
|
95
|
+
const existingFile = files.get(fileKey);
|
|
96
|
+
if (existingFile) {
|
|
97
|
+
if (existingFile.path === value) {
|
|
98
|
+
return { code: "duplicate-path", message: `Cloud package repeats file path ${JSON.stringify(value)}.` };
|
|
99
|
+
}
|
|
100
|
+
return { code: "path-alias-collision", message: `Cloud package paths ${JSON.stringify(existingFile.path)} and ${JSON.stringify(value)} alias after Unicode NFC normalization and case-folding.` };
|
|
101
|
+
}
|
|
102
|
+
files.set(fileKey, { path: value });
|
|
103
|
+
const parts = value.split("/");
|
|
104
|
+
for (let index = 1; index < parts.length; index++) {
|
|
105
|
+
const directory = parts.slice(0, index).join("/");
|
|
106
|
+
const directoryKey = cloudPortablePathKey(directory);
|
|
107
|
+
const existingDirectory = directories.get(directoryKey);
|
|
108
|
+
if (existingDirectory && existingDirectory.directory !== directory) {
|
|
109
|
+
return {
|
|
110
|
+
code: "path-alias-collision",
|
|
111
|
+
message: `Ancestor directories ${JSON.stringify(existingDirectory.directory)} (from ${JSON.stringify(existingDirectory.sourcePath)}) and ${JSON.stringify(directory)} (from ${JSON.stringify(value)}) alias after Unicode NFC normalization and case-folding.`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (!existingDirectory) directories.set(directoryKey, { directory, sourcePath: value });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const [key, file] of files) {
|
|
118
|
+
const directory = directories.get(key);
|
|
119
|
+
if (!directory) continue;
|
|
120
|
+
if (file.path === directory.directory) {
|
|
121
|
+
return { code: "path-type-collision", message: `Cloud package path ${JSON.stringify(file.path)} is both a file and an ancestor directory.` };
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
code: "path-alias-collision",
|
|
125
|
+
message: `File path ${JSON.stringify(file.path)} aliases ancestor directory ${JSON.stringify(directory.directory)} from ${JSON.stringify(directory.sourcePath)} after Unicode NFC normalization and case-folding.`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function cloudIsLocalExperienceLineagePath(value) {
|
|
132
|
+
const normalized = cloudPortablePathKey(String(value || "").replace(/\\/g, "/"));
|
|
133
|
+
const canonical = cloudPortablePathKey(CLOUD_LOCAL_EXPERIENCE_LINEAGE_PATH);
|
|
134
|
+
return normalized === canonical
|
|
135
|
+
|| normalized.startsWith(`${canonical}.`)
|
|
136
|
+
|| normalized.startsWith(cloudPortablePathKey(".agentlas/.experience-relations.jsonl."));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── 패키지 해시 ──
|
|
140
|
+
function cloudPackageHashVersion(value) {
|
|
141
|
+
if (value === undefined || value === null || value === "") return CLOUD_PACKAGE_HASH_V1;
|
|
142
|
+
if (value === CLOUD_PACKAGE_HASH_V1 || value === CLOUD_PACKAGE_HASH_V2) return value;
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function cloudCodePointPathOrder(a, b) {
|
|
147
|
+
return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function cloudHashPackage(files, version = CLOUD_PACKAGE_HASH_V1) {
|
|
151
|
+
const hashVersion = cloudPackageHashVersion(version);
|
|
152
|
+
if (!hashVersion) throw new Error(`unsupported cloud package hash version: ${version}`);
|
|
153
|
+
const h = crypto.createHash("sha256");
|
|
154
|
+
// 서버 package-contract.ts와 바이트 동일해야 한다: 경로 코드포인트 순 정렬.
|
|
155
|
+
// 정렬 없이 스캔 순서로 해시하면 대소문자 혼합 경로 패키지(AGENTS.md + agents/…)가
|
|
156
|
+
// 전부 package_hash_mismatch로 거절된다(2026-07-02 근본 수정).
|
|
157
|
+
for (const file of [...files].filter((file) => !cloudIsLocalExperienceLineagePath(file.path)).sort(cloudCodePointPathOrder)) {
|
|
158
|
+
h.update(file.path);
|
|
159
|
+
h.update("\0");
|
|
160
|
+
h.update(file.sha256);
|
|
161
|
+
h.update("\0");
|
|
162
|
+
if (hashVersion === CLOUD_PACKAGE_HASH_V2) {
|
|
163
|
+
h.update(file.executable ? "x" : "-");
|
|
164
|
+
h.update("\0");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return h.digest("hex");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function cloudCanonicalBase64(value) {
|
|
171
|
+
if (typeof value !== "string") return false;
|
|
172
|
+
if (value === "") return true;
|
|
173
|
+
if (value.length % 4 !== 0) return false;
|
|
174
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return false;
|
|
175
|
+
return Buffer.from(value, "base64").toString("base64") === value;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── 자산 리비전 디스크립터 ──
|
|
179
|
+
function cloudRevisionEtag(revision) {
|
|
180
|
+
return `"${revision}"`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function normalizeCloudAssetDescriptor(value, label = "cloud asset descriptor") {
|
|
184
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
185
|
+
throw new Error(`${label} is missing`);
|
|
186
|
+
}
|
|
187
|
+
const cloudId = typeof value.cloudId === "string" ? value.cloudId.trim() : "";
|
|
188
|
+
const slug = typeof value.slug === "string" ? value.slug.trim() : "";
|
|
189
|
+
const scope = value.scope;
|
|
190
|
+
const packageHash = String(value.packageHash || "").replace(/^sha256:/i, "").toLowerCase();
|
|
191
|
+
const packageHashVersion = cloudPackageHashVersion(value.packageHashVersion);
|
|
192
|
+
const revision = typeof value.revision === "string" ? value.revision : "";
|
|
193
|
+
const etag = typeof value.etag === "string" ? value.etag : cloudRevisionEtag(revision);
|
|
194
|
+
const updatedAt = typeof value.updatedAt === "string"
|
|
195
|
+
? value.updatedAt
|
|
196
|
+
: typeof value.savedAt === "string"
|
|
197
|
+
? value.savedAt
|
|
198
|
+
: typeof value.registeredAt === "string"
|
|
199
|
+
? value.registeredAt
|
|
200
|
+
: "";
|
|
201
|
+
if (!/^[A-Za-z0-9_-]{8,128}$/.test(cloudId)) {
|
|
202
|
+
throw new Error(`${label} cloudId is invalid`);
|
|
203
|
+
}
|
|
204
|
+
if (!slug || cloudSlug(slug) !== slug) throw new Error(`${label} slug is invalid`);
|
|
205
|
+
if (!CLOUD_ASSET_SCOPES.has(scope)) throw new Error(`${label} scope is invalid`);
|
|
206
|
+
if (!/^[a-f0-9]{64}$/.test(packageHash) || !packageHashVersion) {
|
|
207
|
+
throw new Error(`${label} package identity is invalid`);
|
|
208
|
+
}
|
|
209
|
+
if (!revision || revision.length > 512 || /["\\\u0000-\u001f\u007f]/.test(revision)) {
|
|
210
|
+
throw new Error(`${label} revision is invalid`);
|
|
211
|
+
}
|
|
212
|
+
if (etag !== cloudRevisionEtag(revision)) throw new Error(`${label} ETag does not authenticate revision`);
|
|
213
|
+
if (!updatedAt || !Number.isFinite(Date.parse(updatedAt))) throw new Error(`${label} updatedAt is invalid`);
|
|
214
|
+
return { cloudId, slug, scope, packageHash, packageHashVersion, revision, etag, updatedAt };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── 파일 모드 강제 (restore 스냅샷은 소유자 전용) ──
|
|
218
|
+
function cloudApplyPrivateDirectoryMode(directoryPath, platform = process.platform) {
|
|
219
|
+
if (platform === "win32") return;
|
|
220
|
+
fs.chmodSync(directoryPath, 0o700);
|
|
221
|
+
const actual = fs.statSync(directoryPath).mode & 0o777;
|
|
222
|
+
if (actual !== 0o700) throw new Error(`cloud restore directory mode verification failed: ${directoryPath}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function cloudApplyPortableFileMode(filePath, mode, platform = process.platform) {
|
|
226
|
+
if (platform === "win32") return;
|
|
227
|
+
fs.chmodSync(filePath, mode);
|
|
228
|
+
const actual = fs.statSync(filePath).mode & 0o777;
|
|
229
|
+
if (actual !== mode) throw new Error(`cloud restore file mode verification failed: ${filePath}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── 스테이징 스냅샷 전수 검증 (심링크/특수 엔트리/모드/무결성) ──
|
|
233
|
+
function cloudVerifyRestoredSnapshot(root, files, expected) {
|
|
234
|
+
const expectedByPath = new Map(files.map((file) => [file.path, file]));
|
|
235
|
+
const seen = new Set();
|
|
236
|
+
function walk(dir) {
|
|
237
|
+
const dirStat = fs.lstatSync(dir);
|
|
238
|
+
if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) throw new Error("cloud restore staging contains an unsafe directory");
|
|
239
|
+
if (process.platform !== "win32" && (dirStat.mode & 0o777) !== 0o700) throw new Error("cloud restore staging directory mode mismatch");
|
|
240
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
241
|
+
const absolute = path.join(dir, entry.name);
|
|
242
|
+
const relative = path.relative(root, absolute).split(path.sep).join("/");
|
|
243
|
+
if (relative === CLOUD_RESTORE_MARKER_PATH) continue;
|
|
244
|
+
const stat = fs.lstatSync(absolute);
|
|
245
|
+
if (stat.isSymbolicLink()) throw new Error("cloud restore staging contains a symbolic link");
|
|
246
|
+
if (stat.isDirectory()) { walk(absolute); continue; }
|
|
247
|
+
if (!stat.isFile()) throw new Error("cloud restore staging contains a special filesystem entry");
|
|
248
|
+
const expectedFile = expectedByPath.get(relative);
|
|
249
|
+
if (!expectedFile || seen.has(relative)) throw new Error(`cloud restore staging has an unexpected file: ${relative}`);
|
|
250
|
+
const bytes = fs.readFileSync(absolute);
|
|
251
|
+
if (bytes.length !== expectedFile.bytes || sha(bytes) !== expectedFile.sha256) {
|
|
252
|
+
throw new Error(`cloud restore staging file integrity mismatch: ${relative}`);
|
|
253
|
+
}
|
|
254
|
+
if (process.platform !== "win32") {
|
|
255
|
+
const mode = expected.packageHashVersion === CLOUD_PACKAGE_HASH_V2 && expectedFile.executable ? 0o700 : 0o600;
|
|
256
|
+
if ((stat.mode & 0o777) !== mode) throw new Error(`cloud restore staging file mode mismatch: ${relative}`);
|
|
257
|
+
}
|
|
258
|
+
seen.add(relative);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
walk(root);
|
|
262
|
+
if (seen.size !== expectedByPath.size) throw new Error("cloud restore staging is missing package files");
|
|
263
|
+
const markerPath = path.join(root, CLOUD_RESTORE_MARKER_PATH);
|
|
264
|
+
const markerStat = fs.lstatSync(markerPath);
|
|
265
|
+
if (!markerStat.isFile() || markerStat.isSymbolicLink()) throw new Error("cloud restore marker is unsafe");
|
|
266
|
+
if (process.platform !== "win32" && (markerStat.mode & 0o777) !== 0o600) throw new Error("cloud restore marker mode mismatch");
|
|
267
|
+
const marker = JSON.parse(fs.readFileSync(markerPath, "utf8"));
|
|
268
|
+
const expectedExecutablePaths = expected.packageHashVersion === CLOUD_PACKAGE_HASH_V2
|
|
269
|
+
? files.filter((file) => file.executable).map((file) => file.path).sort()
|
|
270
|
+
: undefined;
|
|
271
|
+
if (
|
|
272
|
+
marker.schemaVersion !== 1 || marker.source !== "agentlas-cloud" || marker.slug !== expected.slug ||
|
|
273
|
+
String(marker.packageHash).replace(/^sha256:/i, "").toLowerCase() !== expected.packageHash ||
|
|
274
|
+
marker.packageHashVersion !== expected.packageHashVersion || marker.fileCount !== files.length ||
|
|
275
|
+
marker.totalBytes !== expected.totalBytes || typeof marker.restoredAt !== "string" ||
|
|
276
|
+
!Number.isFinite(Date.parse(marker.restoredAt)) ||
|
|
277
|
+
JSON.stringify(marker.executablePaths) !== JSON.stringify(expectedExecutablePaths)
|
|
278
|
+
) {
|
|
279
|
+
throw new Error("cloud restore marker contract mismatch");
|
|
280
|
+
}
|
|
281
|
+
if (expected.assetDescriptor) {
|
|
282
|
+
const descriptor = normalizeCloudAssetDescriptor(marker, "cloud restore marker");
|
|
283
|
+
const nested = normalizeCloudAssetDescriptor(marker.cloudAssets?.[descriptor.scope], "cloud restore marker scope");
|
|
284
|
+
if (
|
|
285
|
+
JSON.stringify(descriptor) !== JSON.stringify(expected.assetDescriptor) ||
|
|
286
|
+
JSON.stringify(nested) !== JSON.stringify(expected.assetDescriptor)
|
|
287
|
+
) {
|
|
288
|
+
throw new Error("cloud restore marker revision contract mismatch");
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function resolveCloudInstallPath(root, relPath) {
|
|
294
|
+
const normalized = cloudPortableRelativePath(relPath);
|
|
295
|
+
if (!normalized || cloudPortablePathKey(normalized) === cloudPortablePathKey(CLOUD_RESTORE_MARKER_PATH)) {
|
|
296
|
+
throw new Error(`unsafe cloud package path: ${relPath}`);
|
|
297
|
+
}
|
|
298
|
+
const parts = normalized.split("/");
|
|
299
|
+
const target = path.resolve(root, ...parts);
|
|
300
|
+
const relative = path.relative(root, target);
|
|
301
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
302
|
+
throw new Error(`cloud package path escapes install folder: ${relPath}`);
|
|
303
|
+
}
|
|
304
|
+
return target;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── 설치 저널 (crash-recovery 계약) ──
|
|
308
|
+
function writeCloudInstallJournal(journalPath, value) {
|
|
309
|
+
fs.mkdirSync(path.dirname(journalPath), { recursive: true, mode: 0o700 });
|
|
310
|
+
const temp = `${journalPath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
311
|
+
const fd = fs.openSync(temp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
|
|
312
|
+
try {
|
|
313
|
+
fs.writeFileSync(fd, JSON.stringify(value, null, 2) + "\n", "utf8");
|
|
314
|
+
fs.fsyncSync(fd);
|
|
315
|
+
} finally {
|
|
316
|
+
fs.closeSync(fd);
|
|
317
|
+
}
|
|
318
|
+
fs.renameSync(temp, journalPath);
|
|
319
|
+
cloudApplyPortableFileMode(journalPath, 0o600);
|
|
320
|
+
cloudFsyncDirectory(path.dirname(journalPath));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function cloudFsyncDirectory(directory) {
|
|
324
|
+
if (process.platform === "win32") return;
|
|
325
|
+
let fd;
|
|
326
|
+
try {
|
|
327
|
+
fd = fs.openSync(directory, fs.constants.O_RDONLY);
|
|
328
|
+
fs.fsyncSync(fd);
|
|
329
|
+
} catch { /* some filesystems do not support directory fsync */ }
|
|
330
|
+
finally { if (fd !== undefined) try { fs.closeSync(fd); } catch { /* best-effort */ } }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function rollbackCloudInstallSwap({ destination, staging, backup, movedExisting, installed }) {
|
|
334
|
+
if (installed && fs.existsSync(destination)) fs.rmSync(destination, { recursive: true, force: true });
|
|
335
|
+
if (movedExisting && fs.existsSync(backup) && !fs.existsSync(destination)) fs.renameSync(backup, destination);
|
|
336
|
+
if (fs.existsSync(staging)) fs.rmSync(staging, { recursive: true, force: true });
|
|
337
|
+
cloudFsyncDirectory(path.dirname(destination));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function recoverCloudInstallJournal(db, slug) {
|
|
341
|
+
const destination = path.join(userDataDir(), "cloud-agent-installs", slug);
|
|
342
|
+
const parent = path.dirname(destination);
|
|
343
|
+
const journalPath = path.join(parent, `.${path.basename(destination)}.install-journal.json`);
|
|
344
|
+
if (!fs.existsSync(journalPath)) return;
|
|
345
|
+
let journal;
|
|
346
|
+
try { journal = JSON.parse(fs.readFileSync(journalPath, "utf8")); } catch { throw new Error(`cloud install recovery journal is unreadable for ${slug}`); }
|
|
347
|
+
const safeSibling = (candidate, prefix) =>
|
|
348
|
+
typeof candidate === "string" && path.dirname(candidate) === parent && path.basename(candidate).startsWith(prefix);
|
|
349
|
+
if (
|
|
350
|
+
journal.schemaVersion !== 1 || journal.slug !== slug || journal.destination !== destination ||
|
|
351
|
+
!["prepared", "disk-swapped-db-pending", "db-committed"].includes(journal.phase) ||
|
|
352
|
+
typeof journal.hadExisting !== "boolean" ||
|
|
353
|
+
!safeSibling(journal.staging, `.${path.basename(destination)}.installing-`) ||
|
|
354
|
+
!safeSibling(journal.backup, `.${path.basename(destination)}.backup-`)
|
|
355
|
+
) {
|
|
356
|
+
throw new Error(`cloud install recovery journal is invalid for ${slug}`);
|
|
357
|
+
}
|
|
358
|
+
const row = db.prepare("SELECT * FROM installed_agents WHERE slug=?").get(slug);
|
|
359
|
+
const expected = journal.dbExpected && typeof journal.dbExpected === "object" ? journal.dbExpected : {};
|
|
360
|
+
const expectedEntries = Object.entries(expected);
|
|
361
|
+
const dbMatches = Boolean(row) && expectedEntries.length > 0 && expectedEntries.every(
|
|
362
|
+
([key, value]) => String(row[key] ?? "") === String(value ?? ""),
|
|
363
|
+
);
|
|
364
|
+
if (journal.phase === "prepared") {
|
|
365
|
+
// The DB mutation starts only after materializeCloudListing returns, so a
|
|
366
|
+
// prepared journal always represents the pre-DB state. Cover both rename
|
|
367
|
+
// crash windows: old→backup and staging→destination.
|
|
368
|
+
if (journal.hadExisting) {
|
|
369
|
+
if (fs.existsSync(journal.backup)) {
|
|
370
|
+
if (fs.existsSync(destination)) fs.rmSync(destination, { recursive: true, force: true });
|
|
371
|
+
fs.renameSync(journal.backup, destination);
|
|
372
|
+
} else if (!fs.existsSync(destination)) {
|
|
373
|
+
throw new Error(`prepared cloud install lost both destination and backup for ${slug}`);
|
|
374
|
+
}
|
|
375
|
+
} else {
|
|
376
|
+
if (fs.existsSync(journal.backup)) {
|
|
377
|
+
throw new Error(`prepared first cloud install has an unexpected backup for ${slug}`);
|
|
378
|
+
}
|
|
379
|
+
if (fs.existsSync(destination)) fs.rmSync(destination, { recursive: true, force: true });
|
|
380
|
+
}
|
|
381
|
+
if (fs.existsSync(journal.staging)) fs.rmSync(journal.staging, { recursive: true, force: true });
|
|
382
|
+
} else if (journal.phase === "db-committed" || dbMatches) {
|
|
383
|
+
if (!fs.existsSync(destination) && fs.existsSync(journal.staging)) fs.renameSync(journal.staging, destination);
|
|
384
|
+
if (!fs.existsSync(destination)) throw new Error(`committed cloud install is missing for ${slug}`);
|
|
385
|
+
if (fs.existsSync(journal.backup)) fs.rmSync(journal.backup, { recursive: true, force: true });
|
|
386
|
+
if (fs.existsSync(journal.staging)) fs.rmSync(journal.staging, { recursive: true, force: true });
|
|
387
|
+
} else if (journal.phase === "disk-swapped-db-pending") {
|
|
388
|
+
if (!fs.existsSync(destination)) throw new Error(`pending cloud install destination is missing for ${slug}`);
|
|
389
|
+
if (journal.hadExisting !== fs.existsSync(journal.backup)) {
|
|
390
|
+
throw new Error(`pending cloud install backup state is invalid for ${slug}`);
|
|
391
|
+
}
|
|
392
|
+
rollbackCloudInstallSwap({
|
|
393
|
+
destination,
|
|
394
|
+
staging: journal.staging,
|
|
395
|
+
backup: journal.backup,
|
|
396
|
+
movedExisting: Boolean(journal.hadExisting),
|
|
397
|
+
installed: true,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
fs.unlinkSync(journalPath);
|
|
401
|
+
cloudFsyncDirectory(parent);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function recoverCloudInstallJournals(db) {
|
|
405
|
+
const parent = path.join(userDataDir(), "cloud-agent-installs");
|
|
406
|
+
if (!fs.existsSync(parent)) return 0;
|
|
407
|
+
let recovered = 0;
|
|
408
|
+
for (const entry of fs.readdirSync(parent, { withFileTypes: true })) {
|
|
409
|
+
if (!entry.name.endsWith(".install-journal.json")) continue;
|
|
410
|
+
const match = entry.name.match(/^\.([a-z0-9][a-z0-9-]{0,63})\.install-journal\.json$/);
|
|
411
|
+
if (!match || cloudSlug(match[1]) !== match[1] || !entry.isFile() || entry.isSymbolicLink()) {
|
|
412
|
+
throw new Error(`invalid cloud install recovery journal entry: ${entry.name}`);
|
|
413
|
+
}
|
|
414
|
+
recoverCloudInstallJournal(db, match[1]);
|
|
415
|
+
recovered += 1;
|
|
416
|
+
}
|
|
417
|
+
return recovered;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ── 시스템 프롬프트 합성 (패키지 엔트리 문서 → 불변 루트 헤더 포함) ──
|
|
421
|
+
function cloudSystemPromptFromPackage(listing, slug) {
|
|
422
|
+
const pkg = listing && listing.cloudPackage;
|
|
423
|
+
if (!pkg || !Array.isArray(pkg.files) || !pkg.files.length) return "";
|
|
424
|
+
const byPath = new Map();
|
|
425
|
+
for (const file of pkg.files) {
|
|
426
|
+
if (!file || typeof file.path !== "string" || typeof file.contentBase64 !== "string") continue;
|
|
427
|
+
byPath.set(cloudPortablePathKey(file.path), file);
|
|
428
|
+
}
|
|
429
|
+
const readText = (candidate) => {
|
|
430
|
+
const safe = cloudPortableRelativePath(candidate);
|
|
431
|
+
if (!safe) return "";
|
|
432
|
+
const file = byPath.get(cloudPortablePathKey(safe));
|
|
433
|
+
if (!file) return "";
|
|
434
|
+
let bytes;
|
|
435
|
+
try { bytes = Buffer.from(file.contentBase64, "base64"); } catch { return ""; }
|
|
436
|
+
if (!bytes.length || bytes.includes(0)) return "";
|
|
437
|
+
const text = bytes.toString("utf8");
|
|
438
|
+
if (!text.trim() || text.includes("�")) return "";
|
|
439
|
+
return text.slice(0, 64 * 1024);
|
|
440
|
+
};
|
|
441
|
+
let manifest = null;
|
|
442
|
+
const manifestFile = byPath.get(cloudPortablePathKey("agentlas.json"));
|
|
443
|
+
if (manifestFile) {
|
|
444
|
+
try { manifest = JSON.parse(Buffer.from(manifestFile.contentBase64, "base64").toString("utf8")); }
|
|
445
|
+
catch { manifest = null; }
|
|
446
|
+
}
|
|
447
|
+
const declaredEntry = manifest && typeof manifest === "object" && typeof manifest.entry === "string"
|
|
448
|
+
? cloudPortableRelativePath(manifest.entry)
|
|
449
|
+
: null;
|
|
450
|
+
const candidates = [
|
|
451
|
+
declaredEntry,
|
|
452
|
+
"AGENTS.md",
|
|
453
|
+
"CLAUDE.md",
|
|
454
|
+
"GEMINI.md",
|
|
455
|
+
"AGENT.md",
|
|
456
|
+
"agent.md",
|
|
457
|
+
"system-prompt.md",
|
|
458
|
+
"README.md",
|
|
459
|
+
].filter(Boolean);
|
|
460
|
+
let entryPath = "";
|
|
461
|
+
let entryText = "";
|
|
462
|
+
for (const candidate of candidates) {
|
|
463
|
+
const text = readText(candidate);
|
|
464
|
+
if (!text) continue;
|
|
465
|
+
entryPath = candidate;
|
|
466
|
+
entryText = text;
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
if (!entryText) return "";
|
|
470
|
+
const installRoot = path.join(userDataDir(), "cloud-agent-installs", slug);
|
|
471
|
+
return [
|
|
472
|
+
`You are the Agentlas Cloud agent "${listing.name || slug}".`,
|
|
473
|
+
`IMMUTABLE CLOUD AGENT ROOT: ${installRoot}`,
|
|
474
|
+
`CANONICAL ENTRY: ${entryPath}`,
|
|
475
|
+
`PACKAGE HASH: ${String(pkg.packageHash || "").replace(/^sha256:/i, "")}`,
|
|
476
|
+
"Resolve package-relative references under IMMUTABLE CLOUD AGENT ROOT. Treat that root as read-only and do work in the user's active project.",
|
|
477
|
+
"",
|
|
478
|
+
"--- CLOUD AGENT ENTRY ---",
|
|
479
|
+
entryText,
|
|
480
|
+
].join("\n");
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ── materialize (스테이징 → 전수검증 → 원자 스왑; deferCommit이면 저널 + commit/rollback 핸들) ──
|
|
484
|
+
function materializeCloudListing(agentId, slug, listing, options = {}) {
|
|
485
|
+
const pkg = listing.cloudPackage;
|
|
486
|
+
if (!pkg || !Array.isArray(pkg.files) || pkg.files.length === 0) return null;
|
|
487
|
+
if (pkg.files.length > CLOUD_MAX_FILES) throw new Error(`cloud package exceeds ${CLOUD_MAX_FILES} files`);
|
|
488
|
+
if (!Number.isSafeInteger(pkg.fileCount) || pkg.fileCount !== pkg.files.length) {
|
|
489
|
+
throw new Error("cloud package file count does not match its manifest");
|
|
490
|
+
}
|
|
491
|
+
if (!Number.isSafeInteger(pkg.totalBytes) || pkg.totalBytes < 0 || pkg.totalBytes > CLOUD_MAX_TOTAL_BYTES) {
|
|
492
|
+
throw new Error("cloud package total byte count is invalid");
|
|
493
|
+
}
|
|
494
|
+
const packageHashVersion = cloudPackageHashVersion(pkg.packageHashVersion);
|
|
495
|
+
if (!packageHashVersion) throw new Error(`unsupported cloud package hash version: ${pkg.packageHashVersion}`);
|
|
496
|
+
const assetDescriptor = listing.assetDescriptor
|
|
497
|
+
? normalizeCloudAssetDescriptor(listing.assetDescriptor, "restore asset descriptor")
|
|
498
|
+
: null;
|
|
499
|
+
if (assetDescriptor && assetDescriptor.slug !== slug) throw new Error("restore asset descriptor slug mismatch");
|
|
500
|
+
const pathConflict = cloudPortablePathConflict(pkg.files.map((file) => file && file.path));
|
|
501
|
+
if (pathConflict) throw new Error(pathConflict.message);
|
|
502
|
+
const dir = path.join(userDataDir(), "cloud-agent-installs", slug);
|
|
503
|
+
const parent = path.dirname(dir);
|
|
504
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
505
|
+
const nonce = `${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
|
|
506
|
+
const staging = path.join(parent, `.${path.basename(dir)}.installing-${nonce}`);
|
|
507
|
+
const backup = path.join(parent, `.${path.basename(dir)}.backup-${nonce}`);
|
|
508
|
+
const journal = path.join(parent, `.${path.basename(dir)}.install-journal.json`);
|
|
509
|
+
const seen = new Set();
|
|
510
|
+
const verifiedFiles = [];
|
|
511
|
+
let verifiedTotalBytes = 0;
|
|
512
|
+
let movedExisting = false;
|
|
513
|
+
let installed = false;
|
|
514
|
+
try {
|
|
515
|
+
fs.mkdirSync(staging, { recursive: false, mode: 0o700 });
|
|
516
|
+
cloudApplyPrivateDirectoryMode(staging);
|
|
517
|
+
for (const file of pkg.files) {
|
|
518
|
+
const target = resolveCloudInstallPath(staging, file.path);
|
|
519
|
+
const normalizedPath = path.relative(staging, target).split(path.sep).join("/");
|
|
520
|
+
if (seen.has(normalizedPath)) throw new Error(`duplicate cloud package path: ${file.path}`);
|
|
521
|
+
seen.add(normalizedPath);
|
|
522
|
+
if (packageHashVersion === CLOUD_PACKAGE_HASH_V2 && typeof file.executable !== "boolean") {
|
|
523
|
+
throw new Error(`cloud package hash v2 requires executable boolean: ${file.path}`);
|
|
524
|
+
}
|
|
525
|
+
if (packageHashVersion === CLOUD_PACKAGE_HASH_V1 && file.executable !== undefined) {
|
|
526
|
+
throw new Error(`legacy cloud package hash v1 cannot authenticate executable flag: ${file.path}`);
|
|
527
|
+
}
|
|
528
|
+
if (!cloudCanonicalBase64(file.contentBase64)) {
|
|
529
|
+
throw new Error(`cloud package file base64 is not canonical: ${file.path}`);
|
|
530
|
+
}
|
|
531
|
+
const bytes = Buffer.from(String(file.contentBase64 || ""), "base64");
|
|
532
|
+
if (!Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > CLOUD_MAX_FILE_BYTES) {
|
|
533
|
+
throw new Error(`cloud package file byte count is invalid: ${file.path}`);
|
|
534
|
+
}
|
|
535
|
+
if (bytes.length !== Number(file.bytes) || sha(bytes) !== String(file.sha256 || "").toLowerCase()) {
|
|
536
|
+
throw new Error(`cloud package file integrity failed: ${file.path}`);
|
|
537
|
+
}
|
|
538
|
+
verifiedFiles.push({
|
|
539
|
+
path: normalizedPath,
|
|
540
|
+
bytes: bytes.length,
|
|
541
|
+
sha256: String(file.sha256 || "").toLowerCase(),
|
|
542
|
+
...(packageHashVersion === CLOUD_PACKAGE_HASH_V2 ? { executable: file.executable } : {}),
|
|
543
|
+
});
|
|
544
|
+
verifiedTotalBytes += bytes.length;
|
|
545
|
+
if (verifiedTotalBytes > CLOUD_MAX_TOTAL_BYTES) throw new Error("cloud package exceeds total byte limit");
|
|
546
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
547
|
+
cloudApplyPrivateDirectoryMode(path.dirname(target));
|
|
548
|
+
const mode = packageHashVersion === CLOUD_PACKAGE_HASH_V2 && file.executable ? 0o700 : 0o600;
|
|
549
|
+
fs.writeFileSync(target, bytes, { mode });
|
|
550
|
+
cloudApplyPortableFileMode(target, mode);
|
|
551
|
+
}
|
|
552
|
+
const expectedPackageHash = String(pkg.packageHash || "").toLowerCase().replace(/^sha256:/, "");
|
|
553
|
+
if (!/^[a-f0-9]{64}$/.test(expectedPackageHash)) {
|
|
554
|
+
throw new Error("cloud package aggregate hash is missing or invalid");
|
|
555
|
+
}
|
|
556
|
+
const actualPackageHash = cloudHashPackage(verifiedFiles, packageHashVersion);
|
|
557
|
+
if (actualPackageHash !== expectedPackageHash) {
|
|
558
|
+
throw new Error("cloud package aggregate integrity failed");
|
|
559
|
+
}
|
|
560
|
+
if (assetDescriptor && (
|
|
561
|
+
assetDescriptor.packageHash !== expectedPackageHash ||
|
|
562
|
+
assetDescriptor.packageHashVersion !== packageHashVersion
|
|
563
|
+
)) {
|
|
564
|
+
throw new Error("restore asset descriptor package identity mismatch");
|
|
565
|
+
}
|
|
566
|
+
if (verifiedTotalBytes !== pkg.totalBytes) throw new Error("cloud package total byte count does not match its files");
|
|
567
|
+
const restoredAt = new Date().toISOString();
|
|
568
|
+
fs.writeFileSync(
|
|
569
|
+
path.join(staging, ".agentlas-cloud-package.json"),
|
|
570
|
+
JSON.stringify({
|
|
571
|
+
schemaVersion: 1,
|
|
572
|
+
source: "agentlas-cloud",
|
|
573
|
+
slug,
|
|
574
|
+
packageHash: expectedPackageHash,
|
|
575
|
+
packageHashVersion,
|
|
576
|
+
fileCount: verifiedFiles.length,
|
|
577
|
+
totalBytes: verifiedTotalBytes,
|
|
578
|
+
executablePaths: packageHashVersion === CLOUD_PACKAGE_HASH_V2
|
|
579
|
+
? verifiedFiles.filter((file) => file.executable).map((file) => file.path).sort()
|
|
580
|
+
: undefined,
|
|
581
|
+
...(assetDescriptor ? {
|
|
582
|
+
cloudId: assetDescriptor.cloudId,
|
|
583
|
+
scope: assetDescriptor.scope,
|
|
584
|
+
revision: assetDescriptor.revision,
|
|
585
|
+
etag: assetDescriptor.etag,
|
|
586
|
+
updatedAt: assetDescriptor.updatedAt,
|
|
587
|
+
cloudAssets: { [assetDescriptor.scope]: assetDescriptor },
|
|
588
|
+
} : {}),
|
|
589
|
+
restoredAt,
|
|
590
|
+
}, null, 2) + "\n",
|
|
591
|
+
{ encoding: "utf8", mode: 0o600 },
|
|
592
|
+
);
|
|
593
|
+
cloudApplyPortableFileMode(path.join(staging, CLOUD_RESTORE_MARKER_PATH), 0o600);
|
|
594
|
+
cloudVerifyRestoredSnapshot(staging, verifiedFiles, {
|
|
595
|
+
slug,
|
|
596
|
+
packageHash: expectedPackageHash,
|
|
597
|
+
packageHashVersion,
|
|
598
|
+
totalBytes: verifiedTotalBytes,
|
|
599
|
+
assetDescriptor,
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
if (options.deferCommit) {
|
|
603
|
+
writeCloudInstallJournal(journal, {
|
|
604
|
+
schemaVersion: 1,
|
|
605
|
+
slug,
|
|
606
|
+
phase: "prepared",
|
|
607
|
+
destination: dir,
|
|
608
|
+
staging,
|
|
609
|
+
backup,
|
|
610
|
+
hadExisting: fs.existsSync(dir),
|
|
611
|
+
dbExpected: options.dbExpected || {},
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// A Cloud agent is an immutable asset snapshot. Replace the managed install
|
|
616
|
+
// as a whole so removed files and local mutations cannot leak across versions.
|
|
617
|
+
if (fs.existsSync(dir)) {
|
|
618
|
+
fs.renameSync(dir, backup);
|
|
619
|
+
movedExisting = true;
|
|
620
|
+
}
|
|
621
|
+
fs.renameSync(staging, dir);
|
|
622
|
+
cloudFsyncDirectory(parent);
|
|
623
|
+
installed = true;
|
|
624
|
+
if (options.deferCommit) {
|
|
625
|
+
writeCloudInstallJournal(journal, {
|
|
626
|
+
schemaVersion: 1,
|
|
627
|
+
slug,
|
|
628
|
+
phase: "disk-swapped-db-pending",
|
|
629
|
+
destination: dir,
|
|
630
|
+
staging,
|
|
631
|
+
backup,
|
|
632
|
+
hadExisting: movedExisting,
|
|
633
|
+
dbExpected: options.dbExpected || {},
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
} catch (error) {
|
|
637
|
+
rollbackCloudInstallSwap({ destination: dir, staging, backup, movedExisting, installed });
|
|
638
|
+
try { if (fs.existsSync(journal)) fs.unlinkSync(journal); } catch { /* best-effort */ }
|
|
639
|
+
throw error;
|
|
640
|
+
} finally {
|
|
641
|
+
try { if (fs.existsSync(staging)) fs.rmSync(staging, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
642
|
+
try { if (!options.deferCommit && installed && fs.existsSync(backup)) fs.rmSync(backup, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
643
|
+
}
|
|
644
|
+
if (!options.deferCommit) return dir;
|
|
645
|
+
let settled = false;
|
|
646
|
+
return {
|
|
647
|
+
path: dir,
|
|
648
|
+
commit() {
|
|
649
|
+
if (settled) return;
|
|
650
|
+
writeCloudInstallJournal(journal, {
|
|
651
|
+
schemaVersion: 1,
|
|
652
|
+
slug,
|
|
653
|
+
phase: "db-committed",
|
|
654
|
+
destination: dir,
|
|
655
|
+
staging,
|
|
656
|
+
backup,
|
|
657
|
+
hadExisting: movedExisting,
|
|
658
|
+
dbExpected: options.dbExpected || {},
|
|
659
|
+
});
|
|
660
|
+
if (fs.existsSync(backup)) fs.rmSync(backup, { recursive: true, force: true });
|
|
661
|
+
if (fs.existsSync(journal)) fs.unlinkSync(journal);
|
|
662
|
+
cloudFsyncDirectory(parent);
|
|
663
|
+
settled = true;
|
|
664
|
+
},
|
|
665
|
+
rollback() {
|
|
666
|
+
if (settled) return;
|
|
667
|
+
rollbackCloudInstallSwap({ destination: dir, staging, backup, movedExisting, installed });
|
|
668
|
+
if (fs.existsSync(journal)) fs.unlinkSync(journal);
|
|
669
|
+
cloudFsyncDirectory(parent);
|
|
670
|
+
settled = true;
|
|
671
|
+
},
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ── persist (DB upsert + materialize를 하나의 commit/rollback 단위로) ──
|
|
676
|
+
function persistCloudListing(db, listing) {
|
|
677
|
+
if (listing?.delivery?.mode === "call_only") {
|
|
678
|
+
throw new Error(`call-only Hub asset cannot be source-installed; invoke it with agentlas call ${listing.slug || "<slug>"}`);
|
|
679
|
+
}
|
|
680
|
+
const slug = cloudSlug(listing.slug || listing.name || "cloud-agent");
|
|
681
|
+
recoverCloudInstallJournal(db, slug);
|
|
682
|
+
const existing = db.prepare("SELECT * FROM installed_agents WHERE slug=?").get(slug);
|
|
683
|
+
const now = new Date().toISOString();
|
|
684
|
+
const envReqs = JSON.stringify(listing.envRequirements || []);
|
|
685
|
+
const mcpServers = JSON.stringify(listing.mcpServers || []);
|
|
686
|
+
const id = existing?.id || crypto.randomUUID();
|
|
687
|
+
const hasVisibility = columnExists(db, "installed_agents", "visibility");
|
|
688
|
+
let installedAt = now;
|
|
689
|
+
// 재설치 판별용 저널 대조: installed_at까지 기대값에 들어가므로 같은 ms 재설치도 구분한다.
|
|
690
|
+
if (existing && String(existing.installed_at || "") === installedAt) {
|
|
691
|
+
installedAt = new Date(Date.now() + 1).toISOString();
|
|
692
|
+
}
|
|
693
|
+
const tone = listing.tone || "blue";
|
|
694
|
+
const packageSystemPrompt = cloudSystemPromptFromPackage(listing, slug);
|
|
695
|
+
const dbExpected = {
|
|
696
|
+
id,
|
|
697
|
+
slug,
|
|
698
|
+
name: listing.name || slug,
|
|
699
|
+
name_en: listing.nameEn || listing.name || slug,
|
|
700
|
+
tagline: listing.tagline || "",
|
|
701
|
+
tagline_en: listing.taglineEn || listing.tagline || "",
|
|
702
|
+
system_prompt: packageSystemPrompt || listing.systemPrompt || "",
|
|
703
|
+
mcp_servers_json: mcpServers,
|
|
704
|
+
env_requirements_json: envReqs,
|
|
705
|
+
trust_grade: listing.trustGrade || "unknown",
|
|
706
|
+
installed_at: installedAt,
|
|
707
|
+
tone,
|
|
708
|
+
...(!existing ? { preferred_backend: null } : {}),
|
|
709
|
+
...(hasVisibility ? { visibility: listing.visibility || "visible" } : {}),
|
|
710
|
+
};
|
|
711
|
+
const restore = materializeCloudListing(id, slug, listing, { deferCommit: true, dbExpected });
|
|
712
|
+
const mutate = () => {
|
|
713
|
+
if (existing) {
|
|
714
|
+
if (hasVisibility) {
|
|
715
|
+
db.prepare("UPDATE installed_agents SET name=?, name_en=?, tagline=?, tagline_en=?, system_prompt=?, mcp_servers_json=?, env_requirements_json=?, trust_grade=?, installed_at=?, tone=?, visibility=? WHERE slug=?")
|
|
716
|
+
.run(dbExpected.name, dbExpected.name_en, dbExpected.tagline, dbExpected.tagline_en, dbExpected.system_prompt, mcpServers, envReqs, dbExpected.trust_grade, installedAt, tone, dbExpected.visibility, slug);
|
|
717
|
+
} else {
|
|
718
|
+
db.prepare("UPDATE installed_agents SET name=?, name_en=?, tagline=?, tagline_en=?, system_prompt=?, mcp_servers_json=?, env_requirements_json=?, trust_grade=?, installed_at=?, tone=? WHERE slug=?")
|
|
719
|
+
.run(dbExpected.name, dbExpected.name_en, dbExpected.tagline, dbExpected.tagline_en, dbExpected.system_prompt, mcpServers, envReqs, dbExpected.trust_grade, installedAt, tone, slug);
|
|
720
|
+
}
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (hasVisibility) {
|
|
724
|
+
db.prepare("INSERT INTO installed_agents (id, slug, name, name_en, tagline, tagline_en, system_prompt, mcp_servers_json, env_requirements_json, preferred_backend, trust_grade, installed_at, tone, visibility) VALUES (?,?,?,?,?,?,?,?,?,NULL,?,?,?,?)")
|
|
725
|
+
.run(id, slug, dbExpected.name, dbExpected.name_en, dbExpected.tagline, dbExpected.tagline_en, dbExpected.system_prompt, mcpServers, envReqs, dbExpected.trust_grade, installedAt, tone, dbExpected.visibility);
|
|
726
|
+
} else {
|
|
727
|
+
db.prepare("INSERT INTO installed_agents (id, slug, name, name_en, tagline, tagline_en, system_prompt, mcp_servers_json, env_requirements_json, preferred_backend, trust_grade, installed_at, tone) VALUES (?,?,?,?,?,?,?,?,?,NULL,?,?,?)")
|
|
728
|
+
.run(id, slug, dbExpected.name, dbExpected.name_en, dbExpected.tagline, dbExpected.tagline_en, dbExpected.system_prompt, mcpServers, envReqs, dbExpected.trust_grade, installedAt, tone);
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
let dbCommitted = false;
|
|
732
|
+
try {
|
|
733
|
+
runWriteTransaction(db, mutate);
|
|
734
|
+
dbCommitted = true;
|
|
735
|
+
restore?.commit();
|
|
736
|
+
} catch (error) {
|
|
737
|
+
if (!dbCommitted) restore?.rollback();
|
|
738
|
+
throw error;
|
|
739
|
+
}
|
|
740
|
+
const localPath = restore?.path || null;
|
|
741
|
+
// entity_kind 기록 — needsImage의 팀 body-veto가 로컬 폴더 임포트(detectKind)뿐 아니라
|
|
742
|
+
// 클라우드/Hub 소스 설치 팀에도 걸리게 한다. 안 하면 팀 CEO 두뇌의 부서 키워드
|
|
743
|
+
// ("Design HQ" 등)로 needsImage가 참이 되어 세션 런타임이 통째로 gemini로 하이재킹된다.
|
|
744
|
+
// Hub가 준 entityKind를 우선하고, 없으면 materialize된 팩 폴더 구조로 판정한다.
|
|
745
|
+
if (columnExists(db, "installed_agents", "entity_kind")) {
|
|
746
|
+
let kind = String(listing.entityKind || "").toLowerCase();
|
|
747
|
+
if (kind !== "team" && kind !== "agent") {
|
|
748
|
+
kind = localPath && fs.existsSync(localPath) ? detectKind(localPath) : "agent";
|
|
749
|
+
}
|
|
750
|
+
db.prepare("UPDATE installed_agents SET entity_kind=? WHERE id=?").run(kind, id);
|
|
751
|
+
}
|
|
752
|
+
return existing
|
|
753
|
+
? { ...existing, slug, name: dbExpected.name, ...(localPath ? { localPath } : {}) }
|
|
754
|
+
: { id, slug, name: dbExpected.name, ...(localPath ? { localPath } : {}) };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** Hub 에이전트 매니페스트 조회 (marketplace.get_manifest, kind 고정 "agent"). */
|
|
758
|
+
async function fetchHubAgentManifest(slug, { callTool } = {}) {
|
|
759
|
+
const call = callTool || callHubTool;
|
|
760
|
+
return call("marketplace.get_manifest", { kind: "agent", slug });
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Hub 에이전트 설치 진입점.
|
|
765
|
+
* options.callTool — 테스트/오프라인 주입 지점 (hub-client options.fetch와 같은 설계).
|
|
766
|
+
* 반환: persistCloudListing 결과({ id?, slug, name, localPath? }).
|
|
767
|
+
*/
|
|
768
|
+
async function installHubAgent(db, slug, options = {}) {
|
|
769
|
+
if (!slug) throw new Error("usage: agentlas install <slug>");
|
|
770
|
+
const listing = await fetchHubAgentManifest(slug, options);
|
|
771
|
+
if (!listing) throw new Error(`Hub agent not found: ${slug}`);
|
|
772
|
+
assertHubInstallAllowed(listing, slug);
|
|
773
|
+
return persistCloudListing(db, listing);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/*
|
|
777
|
+
* 데스크탑 registry.installAgent와 동일한 설치 게이트 (electron/mcp/registry.ts:178).
|
|
778
|
+
* 제품 모델: Hub는 기본이 "빌림"(call-only → 북마크 또는 agentlas call)이고,
|
|
779
|
+
* 로컬 설치는 소스가 공개된 install-only 패키지 중 신뢰등급 A/B만 허용된다.
|
|
780
|
+
* v1 터미널은 delivery.mode 검사 하나뿐이라 이 게이트들이 전부 빠져 있었다
|
|
781
|
+
* (실사용 테스트에서 실증된 모델 드리프트 — 데스크탑과 토씨까지 맞춘다).
|
|
782
|
+
*/
|
|
783
|
+
function assertHubInstallAllowed(listing, slug) {
|
|
784
|
+
const { isPrivateWebOnlyAgentRow, publicAgentVisibilityRow } = require("../agents/registry.cjs");
|
|
785
|
+
// 회수된 마켓 시드는 데스크탑 marketplace가 리스팅 자체를 숨긴다
|
|
786
|
+
// (electron/marketplace/index.ts:180 isPublicDesktopAgent → null = not found).
|
|
787
|
+
// 터미널도 같은 관측 결과를 낸다 — 설치 불가, 존재하지 않는 상품으로 취급.
|
|
788
|
+
const rowLike = {
|
|
789
|
+
slug: listing.slug || slug,
|
|
790
|
+
name: listing.name,
|
|
791
|
+
name_en: listing.nameEn,
|
|
792
|
+
tagline: listing.tagline,
|
|
793
|
+
tagline_en: listing.taglineEn,
|
|
794
|
+
visibility: listing.visibility,
|
|
795
|
+
role: listing.role,
|
|
796
|
+
};
|
|
797
|
+
if (publicAgentVisibilityRow(rowLike) === "private" && !isPrivateWebOnlyAgentRow(rowLike)) {
|
|
798
|
+
throw new Error(`Hub agent not found: ${slug}`);
|
|
799
|
+
}
|
|
800
|
+
if (isPrivateWebOnlyAgentRow({
|
|
801
|
+
slug: listing.slug || slug,
|
|
802
|
+
name: listing.name,
|
|
803
|
+
name_en: listing.nameEn,
|
|
804
|
+
tagline: listing.tagline,
|
|
805
|
+
tagline_en: listing.taglineEn,
|
|
806
|
+
visibility: listing.visibility,
|
|
807
|
+
role: listing.role,
|
|
808
|
+
})) {
|
|
809
|
+
throw new Error("This web-only agent is not available in the Agentlas terminal.");
|
|
810
|
+
}
|
|
811
|
+
const callOnly = (listing.delivery && listing.delivery.mode === "call_only")
|
|
812
|
+
|| listing.callable === true
|
|
813
|
+
|| listing.kind === "cloud-callable"
|
|
814
|
+
|| listing.entityKind === "cloud-callable";
|
|
815
|
+
const packagePrompt = cloudSystemPromptFromPackage(listing, listing.slug || slug);
|
|
816
|
+
const hasPrompt = (typeof listing.systemPrompt === "string" && listing.systemPrompt.trim())
|
|
817
|
+
|| (typeof packagePrompt === "string" && packagePrompt.trim());
|
|
818
|
+
if (!hasPrompt) {
|
|
819
|
+
if (callOnly) {
|
|
820
|
+
throw new Error(
|
|
821
|
+
"This Hub agent is call-only and cannot be installed locally. Bookmark it or run `agentlas call " + slug + "`; owners can restore their Agent Cloud package with `agentlas cloud restore`.",
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
throw new Error("This Hub package is missing the instructions required for a safe local install.");
|
|
825
|
+
}
|
|
826
|
+
if (callOnly) {
|
|
827
|
+
throw new Error(
|
|
828
|
+
"This Hub agent is call-only and cannot be installed locally. Bookmark it or run `agentlas call " + slug + "`.",
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
const trust = String(listing.trustGrade || "").toUpperCase();
|
|
832
|
+
if (trust !== "A" && trust !== "B") {
|
|
833
|
+
throw new Error(`Trust grade ${listing.trustGrade || "unknown"} blocked. Sideloading requires explicit approval (V1+).`);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
module.exports = {
|
|
838
|
+
CLOUD_MAX_TOTAL_BYTES,
|
|
839
|
+
CLOUD_MAX_FILE_BYTES,
|
|
840
|
+
CLOUD_MAX_FILES,
|
|
841
|
+
CLOUD_PACKAGE_HASH_V1,
|
|
842
|
+
CLOUD_PACKAGE_HASH_V2,
|
|
843
|
+
CLOUD_RESTORE_MARKER_PATH,
|
|
844
|
+
// cloud-assets(패키징/CAS/상태 저널)가 같은 원시 규칙을 공유한다 — 복제 금지 계약.
|
|
845
|
+
// 특히 cloudIsLocalExperienceLineagePath는 cloudHashPackage의 제외 규칙과
|
|
846
|
+
// 바이트 단위로 같아야 한다(복제본이 드리프트하면 해시 계약이 갈라진다).
|
|
847
|
+
CLOUD_ASSET_SCOPES,
|
|
848
|
+
cloudRevisionEtag,
|
|
849
|
+
cloudCodePointPathOrder,
|
|
850
|
+
cloudIsLocalExperienceLineagePath,
|
|
851
|
+
cloudApplyPortableFileMode,
|
|
852
|
+
cloudFsyncDirectory,
|
|
853
|
+
cloudSlug,
|
|
854
|
+
detectKind,
|
|
855
|
+
sha,
|
|
856
|
+
cloudPortablePathKey,
|
|
857
|
+
cloudPortableRelativePath,
|
|
858
|
+
cloudPortablePathConflict,
|
|
859
|
+
cloudCanonicalBase64,
|
|
860
|
+
cloudPackageHashVersion,
|
|
861
|
+
cloudHashPackage,
|
|
862
|
+
normalizeCloudAssetDescriptor,
|
|
863
|
+
cloudSystemPromptFromPackage,
|
|
864
|
+
materializeCloudListing,
|
|
865
|
+
writeCloudInstallJournal,
|
|
866
|
+
recoverCloudInstallJournal,
|
|
867
|
+
recoverCloudInstallJournals,
|
|
868
|
+
rollbackCloudInstallSwap,
|
|
869
|
+
persistCloudListing,
|
|
870
|
+
fetchHubAgentManifest,
|
|
871
|
+
installHubAgent,
|
|
872
|
+
};
|