agentlas 1.0.65 → 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 +94 -0
- package/README.md +12 -4
- package/bin/agentlas.cjs +354 -16
- package/engine/acp/server.cjs +34 -3
- package/engine/agentlas-api-agent.cjs +151 -120
- package/engine/agentlas-banner.cjs +6 -2
- package/engine/agentlas-capabilities.cjs +17 -10
- package/engine/agentlas-cloud-runtime.cjs +4 -5
- package/engine/agentlas-config.cjs +19 -54
- package/engine/agentlas-core-harness.cjs +59 -5
- package/engine/agentlas-desktop-loadout.cjs +17 -3
- package/engine/agentlas-evolution.cjs +75 -18
- package/engine/agentlas-experience-exchange.cjs +145 -14
- package/engine/agentlas-experience-intake.cjs +2 -2
- package/engine/agentlas-i18n.cjs +0 -2
- package/engine/agentlas-input.cjs +273 -20
- package/engine/agentlas-judgment.cjs +0 -0
- package/engine/agentlas-mcp-env.cjs +84 -23
- package/engine/agentlas-mcp-wrapper.cjs +36 -4
- package/engine/agentlas-memory-governance.cjs +2 -1
- package/engine/agentlas-memory-import.cjs +49 -12
- package/engine/agentlas-native-host.cjs +279 -44
- package/engine/agentlas-onboard.cjs +23 -1
- package/engine/agentlas-permissions.cjs +5 -1
- package/engine/agentlas-sqlite-policy.cjs +40 -2
- package/engine/agentlas-tasks.cjs +7 -0
- package/engine/agentlas-tools.cjs +37 -10
- package/engine/agentlas-ui.cjs +26 -4
- package/engine/agentlas-workforce.cjs +30 -5
- package/engine/agentlas-workload-routing.cjs +83 -10
- package/engine/agentlas.cjs +21 -4
- package/engine/agents/builder.cjs +35 -10
- package/engine/agents/files.cjs +81 -10
- package/engine/agents/import-local.cjs +10 -2
- package/engine/agents/registry.cjs +10 -3
- package/engine/agents/router.cjs +7 -7
- package/engine/agents/routes.cjs +31 -9
- package/engine/automation/daemon.cjs +154 -40
- package/engine/automation/launchd.cjs +395 -16
- package/engine/automation/schedule.cjs +90 -3
- package/engine/automation/store.cjs +163 -59
- package/engine/browser/cdp.cjs +190 -17
- package/engine/browser/vault.cjs +1 -0
- package/engine/cli-output.cjs +96 -30
- package/engine/cloud/auth.cjs +131 -13
- package/engine/cloud/hub-client.cjs +41 -16
- package/engine/cloud-assets/cas.cjs +18 -9
- package/engine/cloud-assets/commands.cjs +108 -30
- package/engine/cloud-assets/package.cjs +66 -3
- package/engine/cloud-assets/restore.cjs +12 -9
- package/engine/cloud-assets/state.cjs +609 -116
- package/engine/commands/acp.cjs +13 -2
- package/engine/commands/automation.cjs +75 -24
- package/engine/commands/billing.cjs +6 -1
- package/engine/commands/browser.cjs +61 -29
- package/engine/commands/build.cjs +72 -14
- package/engine/commands/call.cjs +1 -1
- package/engine/commands/cd.cjs +1 -1
- package/engine/commands/cloud.cjs +2 -1
- package/engine/commands/connect.cjs +38 -13
- package/engine/commands/creds.cjs +74 -29
- package/engine/commands/doctor.cjs +17 -1
- package/engine/commands/document.cjs +38 -10
- package/engine/commands/env.cjs +6 -1
- package/engine/commands/firm.cjs +42 -12
- package/engine/commands/graph.cjs +36 -32
- package/engine/commands/help.cjs +11 -1
- package/engine/commands/hep.cjs +1 -1
- package/engine/commands/import.cjs +5 -4
- package/engine/commands/index.cjs +1 -1
- package/engine/commands/install.cjs +6 -4
- package/engine/commands/list.cjs +56 -16
- package/engine/commands/login.cjs +5 -0
- package/engine/commands/logout.cjs +6 -1
- package/engine/commands/mcp.cjs +150 -37
- package/engine/commands/memory.cjs +3 -2
- package/engine/commands/multimodal.cjs +12 -0
- package/engine/commands/native.cjs +5 -1
- package/engine/commands/one.cjs +78 -33
- package/engine/commands/plugin.cjs +13 -2
- package/engine/commands/project.cjs +15 -12
- package/engine/commands/research.cjs +18 -7
- package/engine/commands/roles.cjs +104 -28
- package/engine/commands/route.cjs +2 -2
- package/engine/commands/run.cjs +42 -9
- package/engine/commands/search.cjs +23 -2
- package/engine/commands/setup.cjs +6 -1
- package/engine/commands/storm.cjs +44 -6
- package/engine/commands/swarm.cjs +47 -9
- package/engine/commands/telegram.cjs +6 -1
- package/engine/commands/uninstall.cjs +12 -6
- package/engine/commands/update.cjs +5 -0
- package/engine/commands/upload.cjs +2 -1
- package/engine/commands/usage.cjs +6 -1
- package/engine/commands/version.cjs +6 -1
- package/engine/commands/whoami.cjs +6 -1
- package/engine/commands/workforce.cjs +60 -16
- package/engine/core/capability-grants.cjs +7 -2
- package/engine/core/daemon-client.cjs +36 -7
- package/engine/core/db.cjs +10 -2
- package/engine/core/desktop-core-fetch.cjs +477 -77
- package/engine/core/schema-ensure.cjs +23 -11
- package/engine/experience/build.cjs +5 -1
- package/engine/experience/intents.cjs +35 -82
- package/engine/experience/runtime.cjs +7 -1
- package/engine/experience/variant.cjs +58 -4
- package/engine/firms/orchestrate.cjs +9 -6
- package/engine/graph/ask-model.cjs +22 -11
- package/engine/graph/interview.cjs +74 -23
- package/engine/graph/package.cjs +63 -8
- package/engine/hephaestus/local-core.cjs +18 -5
- package/engine/hephaestus/runtime.cjs +48 -43
- package/engine/hub/install.cjs +484 -63
- package/engine/hub/plugins.cjs +391 -29
- package/engine/mcp/consent.cjs +25 -17
- package/engine/mcp/contract.cjs +229 -24
- package/engine/mcp/inventory.cjs +46 -10
- package/engine/mcp/plan.cjs +10 -1
- package/engine/mcp/probe.cjs +24 -9
- package/engine/memory-cli/curate.cjs +74 -16
- package/engine/oberon/common.cjs +37 -12
- package/engine/oberon/manifest.cjs +25 -2
- package/engine/oberon/outputs.cjs +49 -19
- package/engine/oberon/render.cjs +665 -40
- package/engine/project/career-graph.cjs +15 -2
- package/engine/project/controller.cjs +133 -15
- package/engine/project/credentials.cjs +382 -34
- package/engine/project/env-file.cjs +93 -8
- package/engine/project/memory-context.cjs +11 -57
- package/engine/project/ontology.cjs +66 -25
- package/engine/project/paths.cjs +42 -8
- package/engine/project/seed.cjs +88 -51
- package/engine/project/state.cjs +41 -16
- package/engine/project/team.cjs +26 -1
- package/engine/runtime-refusal.cjs +4 -1
- package/engine/runtimes/acp-driver.cjs +56 -9
- package/engine/runtimes/auth-evidence.cjs +21 -2
- package/engine/runtimes/detect.cjs +28 -8
- package/engine/runtimes/kinds.cjs +12 -4
- package/engine/runtimes/overrides.cjs +4 -3
- package/engine/runtimes/resolve.cjs +8 -7
- package/engine/runtimes/roles.cjs +7 -5
- package/engine/sessions/apply-fences.cjs +396 -56
- package/engine/sessions/fences.cjs +111 -27
- package/engine/sessions/memory-turn.cjs +62 -3
- package/engine/sessions/orchestrator.cjs +11 -1
- package/engine/sessions/prompt.cjs +66 -9
- package/engine/sessions/session.cjs +70 -10
- package/engine/sessions/sink.cjs +78 -12
- package/engine/sessions/store.cjs +29 -7
- package/engine/storm/storm.cjs +26 -3
- package/engine/storm/swarm.cjs +31 -3
- package/engine/telegram/connect.cjs +185 -22
- package/engine/ui/commands-catalog.cjs +1 -1
- package/engine/ui/palette.cjs +12 -2
- package/engine/ui/repl.cjs +57 -9
- package/engine/ui/screens.cjs +2 -4
- package/engine/vendor/desktop-core.manifest.json +5 -5
- package/engine/workforce/capture.cjs +9 -27
- package/engine/workforce/deps.cjs +18 -6
- package/engine/workforce/local-core-transport.cjs +68 -5
- package/package.json +1 -1
- package/engine/commands/career-graph.cjs +0 -51
|
@@ -24,12 +24,430 @@ const {
|
|
|
24
24
|
CLOUD_PACKAGE_HASH_V1,
|
|
25
25
|
CLOUD_RESTORE_MARKER_PATH,
|
|
26
26
|
cloudSlug,
|
|
27
|
-
cloudApplyPortableFileMode,
|
|
28
27
|
cloudFsyncDirectory,
|
|
29
28
|
normalizeCloudAssetDescriptor,
|
|
30
29
|
} = require("../hub/install.cjs");
|
|
31
30
|
|
|
32
31
|
const CLOUD_ASSET_STATE_FILE = "cloud-asset-state.v1.json";
|
|
32
|
+
const CLOUD_ASSET_STATE_MAX_BYTES = 1024 * 1024;
|
|
33
|
+
const CLOUD_ASSET_LOCK_STALE_MS = 30_000;
|
|
34
|
+
const CLOUD_ASSET_LOCK_WAIT_MS = 10_000;
|
|
35
|
+
|
|
36
|
+
function waitSync(milliseconds) {
|
|
37
|
+
const signal = new Int32Array(new SharedArrayBuffer(4));
|
|
38
|
+
Atomics.wait(signal, 0, 0, milliseconds);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const STATE_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
|
|
42
|
+
|
|
43
|
+
function stateSameDirectoryIdentity(left, right) {
|
|
44
|
+
return Boolean(
|
|
45
|
+
left && right && left.isDirectory() && right.isDirectory() &&
|
|
46
|
+
!left.isSymbolicLink() && !right.isSymbolicLink() &&
|
|
47
|
+
left.dev === right.dev && left.ino === right.ino,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function stateDirectoryAnchor(target, label, { allowMissing = false, containedBy = null } = {}) {
|
|
52
|
+
let stat;
|
|
53
|
+
try { stat = fs.lstatSync(target); }
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (allowMissing && error && error.code === "ENOENT") return null;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
59
|
+
throw new Error(`${label} is not a safe managed directory`);
|
|
60
|
+
}
|
|
61
|
+
let realpath;
|
|
62
|
+
try { realpath = fs.realpathSync.native(target); }
|
|
63
|
+
catch (error) { throw new Error(`${label} could not be canonicalized: ${error.message || error}`); }
|
|
64
|
+
if (containedBy && !(
|
|
65
|
+
realpath === containedBy.realpath || realpath.startsWith(`${containedBy.realpath}${path.sep}`)
|
|
66
|
+
)) {
|
|
67
|
+
throw new Error(`${label} escapes its managed root`);
|
|
68
|
+
}
|
|
69
|
+
return { path: target, realpath, dev: stat.dev, ino: stat.ino, stat };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function stateAssertDirectoryAnchor(anchor, label, containedBy = null) {
|
|
73
|
+
const current = stateDirectoryAnchor(anchor.path, label, { allowMissing: false, containedBy });
|
|
74
|
+
if (
|
|
75
|
+
!stateSameDirectoryIdentity(anchor.stat || anchor, current.stat || current) ||
|
|
76
|
+
current.realpath !== anchor.realpath
|
|
77
|
+
) {
|
|
78
|
+
throw new Error(`${label} changed while it was being used`);
|
|
79
|
+
}
|
|
80
|
+
return current;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function stateEnsureDirectory(target, label) {
|
|
84
|
+
fs.mkdirSync(target, { recursive: true, mode: 0o700 });
|
|
85
|
+
const anchor = stateDirectoryAnchor(target, label);
|
|
86
|
+
try { fs.chmodSync(target, 0o700); } catch { /* Windows/best-effort */ }
|
|
87
|
+
stateAssertDirectoryAnchor(anchor, label);
|
|
88
|
+
return anchor;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function stateSameFileIdentity(left, right) {
|
|
92
|
+
return Boolean(
|
|
93
|
+
left && right && left.isFile() && right.isFile() &&
|
|
94
|
+
!left.isSymbolicLink() && !right.isSymbolicLink() &&
|
|
95
|
+
left.dev === right.dev && left.ino === right.ino,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function stateSameFileSnapshot(left, right) {
|
|
100
|
+
return stateSameFileIdentity(left, right) && left.nlink === right.nlink &&
|
|
101
|
+
left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function stateFileSnapshot(file, label, { allowMissing = false, maxBytes = CLOUD_ASSET_STATE_MAX_BYTES, allowHardLinks = false } = {}) {
|
|
105
|
+
let stat;
|
|
106
|
+
try { stat = fs.lstatSync(file); }
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (allowMissing && error && error.code === "ENOENT") return null;
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (!allowHardLinks && stat.nlink !== 1) || stat.size > maxBytes) {
|
|
112
|
+
throw new Error(`${label} is not a bounded private file`);
|
|
113
|
+
}
|
|
114
|
+
return stat;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function stateRemoveOwnedFile(file, expected, { allowLinked = false } = {}) {
|
|
118
|
+
try {
|
|
119
|
+
const current = fs.lstatSync(file);
|
|
120
|
+
if (stateSameFileIdentity(current, expected) && (current.nlink === 1 || (allowLinked && current.nlink >= 2))) {
|
|
121
|
+
fs.unlinkSync(file);
|
|
122
|
+
}
|
|
123
|
+
} catch { /* leave unknown successors and recovery artifacts untouched */ }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function stateWriteTemp(directory, name, payload, label) {
|
|
127
|
+
stateAssertDirectoryAnchor(directory, `${label} directory`);
|
|
128
|
+
const file = path.join(directory.realpath, name);
|
|
129
|
+
let fd;
|
|
130
|
+
let created;
|
|
131
|
+
try {
|
|
132
|
+
fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | STATE_NOFOLLOW, 0o600);
|
|
133
|
+
created = fs.fstatSync(fd);
|
|
134
|
+
if (!created.isFile() || created.isSymbolicLink() || created.nlink !== 1) {
|
|
135
|
+
throw new Error(`${label} temporary file is unsafe`);
|
|
136
|
+
}
|
|
137
|
+
const bytes = Buffer.from(payload, "utf8");
|
|
138
|
+
let offset = 0;
|
|
139
|
+
while (offset < bytes.length) {
|
|
140
|
+
const written = fs.writeSync(fd, bytes, offset, bytes.length - offset, null);
|
|
141
|
+
if (!Number.isInteger(written) || written <= 0) throw new Error(`${label} temporary file write stalled`);
|
|
142
|
+
offset += written;
|
|
143
|
+
}
|
|
144
|
+
try { fs.fchmodSync(fd, 0o600); } catch { /* Windows/best-effort */ }
|
|
145
|
+
fs.fsyncSync(fd);
|
|
146
|
+
const written = fs.fstatSync(fd);
|
|
147
|
+
if (!stateSameFileIdentity(created, written) || written.nlink !== 1 || written.size !== bytes.length) {
|
|
148
|
+
throw new Error(`${label} temporary file changed while writing`);
|
|
149
|
+
}
|
|
150
|
+
return { path: file, stat: written };
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (created) stateRemoveOwnedFile(file, created);
|
|
153
|
+
throw error;
|
|
154
|
+
} finally {
|
|
155
|
+
if (fd !== undefined) try { fs.closeSync(fd); } catch { /* preserve original failure */ }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function stateRestoreBackup(backup, target, expected, label) {
|
|
160
|
+
try {
|
|
161
|
+
if (stateFileSnapshot(target, `${label} successor`, { allowMissing: true })) return false;
|
|
162
|
+
const current = stateFileSnapshot(backup, `${label} backup`);
|
|
163
|
+
if (!current || !stateSameFileIdentity(current, expected) || current.nlink !== 1) return false;
|
|
164
|
+
fs.linkSync(backup, target);
|
|
165
|
+
const restored = stateFileSnapshot(target, `${label} restored`);
|
|
166
|
+
if (!restored || !stateSameFileIdentity(restored, expected) || restored.nlink < 2) return false;
|
|
167
|
+
fs.unlinkSync(backup);
|
|
168
|
+
return true;
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Publish a state/marker file only after the managed directory and the
|
|
176
|
+
* previously observed target have remained the same. Existing targets are
|
|
177
|
+
* quarantined first, then the new file is linked with no-replace semantics;
|
|
178
|
+
* an unexpected successor is never overwritten.
|
|
179
|
+
*/
|
|
180
|
+
function statePublishFile(directory, targetName, temporary, expected, label) {
|
|
181
|
+
stateAssertDirectoryAnchor(directory, `${label} directory`);
|
|
182
|
+
const target = path.join(directory.realpath, targetName);
|
|
183
|
+
const current = stateFileSnapshot(target, `${label} target`, { allowMissing: true });
|
|
184
|
+
if ((expected && (!current || !stateSameFileSnapshot(current, expected))) || (!expected && current)) {
|
|
185
|
+
throw new Error(`${label} changed before publication`);
|
|
186
|
+
}
|
|
187
|
+
let backup = null;
|
|
188
|
+
let linked = false;
|
|
189
|
+
try {
|
|
190
|
+
if (expected) {
|
|
191
|
+
backup = `${target}.previous-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
192
|
+
fs.renameSync(target, backup);
|
|
193
|
+
const moved = stateFileSnapshot(backup, `${label} backup`);
|
|
194
|
+
if (!moved || !stateSameFileIdentity(moved, expected) || moved.nlink !== 1) {
|
|
195
|
+
stateRestoreBackup(backup, target, expected, label);
|
|
196
|
+
throw new Error(`${label} target changed before publication`);
|
|
197
|
+
}
|
|
198
|
+
stateAssertDirectoryAnchor(directory, `${label} directory`);
|
|
199
|
+
if (stateFileSnapshot(target, `${label} successor`, { allowMissing: true })) {
|
|
200
|
+
throw new Error(`${label} successor appeared during publication`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
stateAssertDirectoryAnchor(directory, `${label} directory`);
|
|
204
|
+
if (stateFileSnapshot(target, `${label} successor`, { allowMissing: true })) {
|
|
205
|
+
throw new Error(`${label} successor appeared during publication`);
|
|
206
|
+
}
|
|
207
|
+
fs.linkSync(temporary.path, target);
|
|
208
|
+
linked = true;
|
|
209
|
+
const linkedTarget = stateFileSnapshot(target, `${label} target`, { allowHardLinks: true });
|
|
210
|
+
if (!linkedTarget || !stateSameFileIdentity(linkedTarget, temporary.stat) || linkedTarget.nlink < 2) {
|
|
211
|
+
throw new Error(`${label} publication produced an unsafe target`);
|
|
212
|
+
}
|
|
213
|
+
stateAssertDirectoryAnchor(directory, `${label} directory`);
|
|
214
|
+
stateRemoveOwnedFile(temporary.path, temporary.stat, { allowLinked: true });
|
|
215
|
+
const installed = stateFileSnapshot(target, `${label} target`);
|
|
216
|
+
if (!installed || !stateSameFileIdentity(installed, temporary.stat) || installed.nlink !== 1) {
|
|
217
|
+
throw new Error(`${label} identity changed after publication`);
|
|
218
|
+
}
|
|
219
|
+
try { fs.chmodSync(target, 0o600); } catch { /* Windows/best-effort */ }
|
|
220
|
+
const final = stateFileSnapshot(target, `${label} target`);
|
|
221
|
+
if (!final || !stateSameFileIdentity(final, temporary.stat) || final.nlink !== 1 ||
|
|
222
|
+
(process.platform !== "win32" && (final.mode & 0o777) !== 0o600)) {
|
|
223
|
+
throw new Error(`${label} mode or identity changed after publication`);
|
|
224
|
+
}
|
|
225
|
+
stateAssertDirectoryAnchor(directory, `${label} directory`);
|
|
226
|
+
if (backup) {
|
|
227
|
+
const backupStat = stateFileSnapshot(backup, `${label} backup`, { allowMissing: true });
|
|
228
|
+
if (backupStat && stateSameFileIdentity(backupStat, expected) && backupStat.nlink === 1) {
|
|
229
|
+
fs.unlinkSync(backup);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return target;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (linked) stateRemoveOwnedFile(target, temporary.stat);
|
|
235
|
+
stateRemoveOwnedFile(temporary.path, temporary.stat);
|
|
236
|
+
if (backup) stateRestoreBackup(backup, target, expected, label);
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function stateWriteLockOwner(lockParent, lock, owner) {
|
|
242
|
+
stateAssertDirectoryAnchor(lockParent, "Cloud asset lock parent");
|
|
243
|
+
stateAssertDirectoryAnchor(lock, "Cloud asset lock", lockParent);
|
|
244
|
+
const ownerPath = path.join(lock.realpath, "owner.json");
|
|
245
|
+
let fd;
|
|
246
|
+
let created;
|
|
247
|
+
try {
|
|
248
|
+
fd = fs.openSync(ownerPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | STATE_NOFOLLOW, 0o600);
|
|
249
|
+
created = fs.fstatSync(fd);
|
|
250
|
+
if (!created.isFile() || created.isSymbolicLink() || created.nlink !== 1) {
|
|
251
|
+
throw new Error("lock owner is not a bounded private file");
|
|
252
|
+
}
|
|
253
|
+
const payload = Buffer.from(JSON.stringify(owner) + "\n", "utf8");
|
|
254
|
+
let offset = 0;
|
|
255
|
+
while (offset < payload.length) {
|
|
256
|
+
const written = fs.writeSync(fd, payload, offset, payload.length - offset, null);
|
|
257
|
+
if (!Number.isInteger(written) || written <= 0) throw new Error("lock owner write stalled");
|
|
258
|
+
offset += written;
|
|
259
|
+
}
|
|
260
|
+
try { fs.fchmodSync(fd, 0o600); } catch { /* Windows/best-effort */ }
|
|
261
|
+
fs.fsyncSync(fd);
|
|
262
|
+
const written = fs.fstatSync(fd);
|
|
263
|
+
if (!stateSameFileIdentity(created, written) || written.nlink !== 1 || written.size !== payload.length) {
|
|
264
|
+
throw new Error("lock owner changed while it was written");
|
|
265
|
+
}
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (created) stateRemoveOwnedFile(ownerPath, created);
|
|
268
|
+
throw error;
|
|
269
|
+
} finally {
|
|
270
|
+
if (fd !== undefined) try { fs.closeSync(fd); } catch { /* preserve original failure */ }
|
|
271
|
+
}
|
|
272
|
+
stateAssertDirectoryAnchor(lockParent, "Cloud asset lock parent");
|
|
273
|
+
stateAssertDirectoryAnchor(lock, "Cloud asset lock", lockParent);
|
|
274
|
+
const written = stateFileSnapshot(ownerPath, "Cloud asset lock owner", { maxBytes: 512 });
|
|
275
|
+
if (!written || !stateSameFileIdentity(written, created) || written.nlink !== 1 || (written.mode & 0o777) !== 0o600) {
|
|
276
|
+
throw new Error("lock owner changed while it was written");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function stateRemoveOwnedLockDirectory(directoryPath, expected) {
|
|
281
|
+
let current;
|
|
282
|
+
try { current = stateDirectoryAnchor(directoryPath, "Cloud asset lock cleanup"); }
|
|
283
|
+
catch { return false; }
|
|
284
|
+
if (!stateSameDirectoryIdentity(current.stat, expected.stat || expected)) return false;
|
|
285
|
+
const ownerPath = path.join(current.realpath, "owner.json");
|
|
286
|
+
const owner = stateFileSnapshot(ownerPath, "Cloud asset lock owner", { allowMissing: true, maxBytes: 512 });
|
|
287
|
+
if (owner) {
|
|
288
|
+
try { fs.unlinkSync(ownerPath); } catch { return false; }
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
fs.rmdirSync(current.realpath);
|
|
292
|
+
return true;
|
|
293
|
+
} catch {
|
|
294
|
+
// Never recursively delete a lock directory after its identity is no
|
|
295
|
+
// longer provable; leave the artifact for bounded stale-lock recovery.
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function processIsAlive(pid) {
|
|
301
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
302
|
+
try {
|
|
303
|
+
process.kill(pid, 0);
|
|
304
|
+
return true;
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (error && error.code === "ESRCH") return false;
|
|
307
|
+
// EPERM means the process exists but belongs to another user. Unknown
|
|
308
|
+
// failures also stay fail-safe: never steal a lock from a possibly live owner.
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function readLockOwner(lockPath, lockAnchor = null) {
|
|
314
|
+
if (lockAnchor) stateAssertDirectoryAnchor(lockAnchor, "Cloud asset lock");
|
|
315
|
+
const ownerPath = path.join(lockAnchor ? lockAnchor.realpath : lockPath, "owner.json");
|
|
316
|
+
let fd;
|
|
317
|
+
try {
|
|
318
|
+
const listed = stateFileSnapshot(ownerPath, "Cloud asset lock owner", { allowMissing: true, maxBytes: 512 });
|
|
319
|
+
if (!listed) return null;
|
|
320
|
+
fd = fs.openSync(ownerPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
321
|
+
const before = fs.fstatSync(fd);
|
|
322
|
+
if (!before.isFile() || before.nlink !== 1 || before.size <= 0 || before.size > 512 || !stateSameFileIdentity(before, listed)) {
|
|
323
|
+
throw new Error("lock owner is not a bounded private file");
|
|
324
|
+
}
|
|
325
|
+
const raw = fs.readFileSync(fd, "utf8");
|
|
326
|
+
const after = fs.fstatSync(fd);
|
|
327
|
+
if (
|
|
328
|
+
Buffer.byteLength(raw, "utf8") !== before.size || after.dev !== before.dev || after.ino !== before.ino ||
|
|
329
|
+
after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs
|
|
330
|
+
) {
|
|
331
|
+
throw new Error("lock owner changed while it was read");
|
|
332
|
+
}
|
|
333
|
+
const parsed = JSON.parse(raw);
|
|
334
|
+
if (!Number.isSafeInteger(parsed.pid) || parsed.pid <= 0 || typeof parsed.nonce !== "string" || !/^[a-f0-9]{32}$/.test(parsed.nonce)) {
|
|
335
|
+
throw new Error("lock owner identity is invalid");
|
|
336
|
+
}
|
|
337
|
+
return parsed;
|
|
338
|
+
} catch (error) {
|
|
339
|
+
if (error && error.code === "ENOENT") return null;
|
|
340
|
+
throw error;
|
|
341
|
+
} finally {
|
|
342
|
+
if (fd !== undefined) try { fs.closeSync(fd); } catch { /* best effort */ }
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Directory rename is the lock hand-off boundary. A stale lock is moved to a
|
|
348
|
+
* unique quarantine path before cleanup, so a new owner's lock can never be
|
|
349
|
+
* removed by the old cleanup path.
|
|
350
|
+
*/
|
|
351
|
+
function withCloudAssetLock(targetPath, label, action) {
|
|
352
|
+
const lockPath = `${targetPath}.lock`;
|
|
353
|
+
const lockParent = path.dirname(lockPath);
|
|
354
|
+
const lockParentAnchor = stateEnsureDirectory(lockParent, `${label} lock parent`);
|
|
355
|
+
const lockName = path.basename(lockPath);
|
|
356
|
+
const lockActualPath = path.join(lockParentAnchor.realpath, lockName);
|
|
357
|
+
const deadline = Date.now() + CLOUD_ASSET_LOCK_WAIT_MS;
|
|
358
|
+
let acquired = false;
|
|
359
|
+
let lockAnchor = null;
|
|
360
|
+
while (!acquired) {
|
|
361
|
+
let createdLock = false;
|
|
362
|
+
try {
|
|
363
|
+
stateAssertDirectoryAnchor(lockParentAnchor, `${label} lock parent`);
|
|
364
|
+
fs.mkdirSync(lockActualPath, { mode: 0o700 });
|
|
365
|
+
createdLock = true;
|
|
366
|
+
lockAnchor = stateDirectoryAnchor(lockActualPath, `${label} lock`, { containedBy: lockParentAnchor });
|
|
367
|
+
try { fs.chmodSync(lockActualPath, 0o700); } catch { /* Windows/best-effort */ }
|
|
368
|
+
stateAssertDirectoryAnchor(lockParentAnchor, `${label} lock parent`);
|
|
369
|
+
stateAssertDirectoryAnchor(lockAnchor, `${label} lock`, lockParentAnchor);
|
|
370
|
+
const owner = {
|
|
371
|
+
pid: process.pid,
|
|
372
|
+
nonce: crypto.randomBytes(16).toString("hex"),
|
|
373
|
+
createdAt: new Date().toISOString(),
|
|
374
|
+
};
|
|
375
|
+
stateWriteLockOwner(lockParentAnchor, lockAnchor, owner);
|
|
376
|
+
acquired = true;
|
|
377
|
+
} catch (error) {
|
|
378
|
+
if (!createdLock && error && (error.code === "EEXIST" || error.code === "ENOENT")) {
|
|
379
|
+
if (error.code === "ENOENT") continue;
|
|
380
|
+
let lock = null;
|
|
381
|
+
try {
|
|
382
|
+
lock = stateDirectoryAnchor(lockActualPath, `${label} lock`, {
|
|
383
|
+
allowMissing: true,
|
|
384
|
+
containedBy: lockParentAnchor,
|
|
385
|
+
});
|
|
386
|
+
} catch (statError) {
|
|
387
|
+
if (statError && statError.code === "ENOENT") continue;
|
|
388
|
+
throw statError;
|
|
389
|
+
}
|
|
390
|
+
if (!lock) continue;
|
|
391
|
+
let owner = null;
|
|
392
|
+
try { owner = readLockOwner(lockActualPath, lock); } catch { owner = null; }
|
|
393
|
+
if (Date.now() - lock.stat.mtimeMs > CLOUD_ASSET_LOCK_STALE_MS && (!owner || !processIsAlive(owner.pid))) {
|
|
394
|
+
stateAssertDirectoryAnchor(lockParentAnchor, `${label} lock parent`);
|
|
395
|
+
stateAssertDirectoryAnchor(lock, `${label} lock`, lockParentAnchor);
|
|
396
|
+
const quarantine = path.join(
|
|
397
|
+
lockParentAnchor.realpath,
|
|
398
|
+
`${lockName}.stale-${process.pid}-${crypto.randomBytes(6).toString("hex")}`,
|
|
399
|
+
);
|
|
400
|
+
try {
|
|
401
|
+
fs.renameSync(lockActualPath, quarantine);
|
|
402
|
+
const moved = stateDirectoryAnchor(quarantine, `${label} stale lock`, { containedBy: lockParentAnchor });
|
|
403
|
+
if (stateSameDirectoryIdentity(moved.stat, lock.stat)) stateRemoveOwnedLockDirectory(quarantine, moved);
|
|
404
|
+
} catch (reclaimError) {
|
|
405
|
+
if (reclaimError && reclaimError.code === "ENOENT") continue;
|
|
406
|
+
throw reclaimError;
|
|
407
|
+
}
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (Date.now() >= deadline) throw new Error(`${label} is busy; retry after the active operation finishes`);
|
|
411
|
+
waitSync(25);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (lockAnchor && !acquired) {
|
|
415
|
+
const abandoned = path.join(
|
|
416
|
+
lockParentAnchor.realpath,
|
|
417
|
+
`${lockName}.abandoned-${process.pid}-${crypto.randomBytes(6).toString("hex")}`,
|
|
418
|
+
);
|
|
419
|
+
try {
|
|
420
|
+
stateAssertDirectoryAnchor(lockParentAnchor, `${label} lock parent`);
|
|
421
|
+
stateAssertDirectoryAnchor(lockAnchor, `${label} lock`, lockParentAnchor);
|
|
422
|
+
fs.renameSync(lockActualPath, abandoned);
|
|
423
|
+
const moved = stateDirectoryAnchor(abandoned, `${label} abandoned lock`, { containedBy: lockParentAnchor });
|
|
424
|
+
if (stateSameDirectoryIdentity(moved.stat, lockAnchor.stat)) stateRemoveOwnedLockDirectory(abandoned, moved);
|
|
425
|
+
} catch { /* original owner-write error remains authoritative */ }
|
|
426
|
+
}
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
return action();
|
|
432
|
+
} finally {
|
|
433
|
+
const cleanup = path.join(
|
|
434
|
+
lockParentAnchor.realpath,
|
|
435
|
+
`${lockName}.done-${process.pid}-${crypto.randomBytes(6).toString("hex")}`,
|
|
436
|
+
);
|
|
437
|
+
try {
|
|
438
|
+
stateAssertDirectoryAnchor(lockParentAnchor, `${label} lock parent`);
|
|
439
|
+
stateAssertDirectoryAnchor(lockAnchor, `${label} lock`, lockParentAnchor);
|
|
440
|
+
fs.renameSync(lockActualPath, cleanup);
|
|
441
|
+
const moved = stateDirectoryAnchor(cleanup, `${label} completed lock`, { containedBy: lockParentAnchor });
|
|
442
|
+
if (!stateSameDirectoryIdentity(moved.stat, lockAnchor.stat)) {
|
|
443
|
+
throw new Error(`${label} lock changed while it was being released`);
|
|
444
|
+
}
|
|
445
|
+
stateRemoveOwnedLockDirectory(cleanup, moved);
|
|
446
|
+
} catch (error) {
|
|
447
|
+
if (!error || error.code !== "ENOENT") throw error;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
33
451
|
|
|
34
452
|
function normalizeCloudScopeFlag(value) {
|
|
35
453
|
if (value === "owner-private" || value === "private" || value === "private-link") return "owner-private";
|
|
@@ -45,45 +463,68 @@ function cloudAssetStatePath() {
|
|
|
45
463
|
return path.join(userDataDir(), CLOUD_ASSET_STATE_FILE);
|
|
46
464
|
}
|
|
47
465
|
|
|
466
|
+
function normalizeCloudAssetState(parsed) {
|
|
467
|
+
if (!parsed || parsed.schemaVersion !== 1 || !parsed.assets || typeof parsed.assets !== "object" || Array.isArray(parsed.assets)) {
|
|
468
|
+
throw new Error("state schema is invalid");
|
|
469
|
+
}
|
|
470
|
+
const assets = {};
|
|
471
|
+
for (const [key, raw] of Object.entries(parsed.assets)) {
|
|
472
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`state entry ${key} is invalid`);
|
|
473
|
+
const descriptor = normalizeCloudAssetDescriptor(raw.descriptor, `state entry ${key}`);
|
|
474
|
+
if (key !== cloudDescriptorKey(descriptor)) throw new Error(`state entry ${key} key is invalid`);
|
|
475
|
+
if (!Array.isArray(raw.sourceRoots)) throw new Error(`state entry ${key} sourceRoots is invalid`);
|
|
476
|
+
for (const item of raw.sourceRoots) {
|
|
477
|
+
if (typeof item !== "string" || !path.isAbsolute(item)) throw new Error(`state entry ${key} sourceRoots is invalid`);
|
|
478
|
+
}
|
|
479
|
+
const sourceRoots = [...new Set(raw.sourceRoots.map((item) => path.resolve(item)))].slice(0, 32);
|
|
480
|
+
assets[key] = { descriptor, sourceRoots };
|
|
481
|
+
}
|
|
482
|
+
if (!Array.isArray(parsed.deletedBases)) throw new Error("state deletedBases is invalid");
|
|
483
|
+
const deletedBases = parsed.deletedBases.map((item, index) => {
|
|
484
|
+
if (
|
|
485
|
+
!item || typeof item !== "object" || Array.isArray(item) ||
|
|
486
|
+
typeof item.rootPath !== "string" || !path.isAbsolute(item.rootPath) ||
|
|
487
|
+
typeof item.slug !== "string" || cloudSlug(item.slug) !== item.slug ||
|
|
488
|
+
!CLOUD_ASSET_SCOPES.has(item.scope) || !/^[A-Za-z0-9_-]{8,128}$/.test(String(item.cloudId || "")) ||
|
|
489
|
+
typeof item.revision !== "string" || !item.revision || item.revision.length > 512 || /["\\\u0000-\u001f\u007f]/.test(item.revision)
|
|
490
|
+
) {
|
|
491
|
+
throw new Error(`state deletedBases[${index}] is invalid`);
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
rootPath: path.resolve(item.rootPath),
|
|
495
|
+
slug: item.slug,
|
|
496
|
+
scope: item.scope,
|
|
497
|
+
cloudId: item.cloudId,
|
|
498
|
+
revision: item.revision,
|
|
499
|
+
};
|
|
500
|
+
}).slice(-256);
|
|
501
|
+
return { schemaVersion: 1, assets, deletedBases };
|
|
502
|
+
}
|
|
503
|
+
|
|
48
504
|
function readCloudAssetState() {
|
|
49
505
|
const statePath = cloudAssetStatePath();
|
|
50
|
-
if (!fs.existsSync(statePath)) return { schemaVersion: 1, assets: {}, deletedBases: [] };
|
|
51
506
|
let fd;
|
|
52
507
|
try {
|
|
53
508
|
// O_NOFOLLOW: 상태 파일이 심링크로 바꿔치기되면 읽지 않는다 (로컬 상태 위조 방어).
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
throw new Error("state schema is invalid");
|
|
509
|
+
try {
|
|
510
|
+
fd = fs.openSync(statePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
511
|
+
} catch (error) {
|
|
512
|
+
if (error && error.code === "ENOENT") return { schemaVersion: 1, assets: {}, deletedBases: [] };
|
|
513
|
+
throw error;
|
|
60
514
|
}
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const descriptor = normalizeCloudAssetDescriptor(raw.descriptor, `state entry ${key}`);
|
|
65
|
-
if (key !== cloudDescriptorKey(descriptor)) throw new Error(`state entry ${key} key is invalid`);
|
|
66
|
-
const sourceRoots = Array.isArray(raw.sourceRoots)
|
|
67
|
-
? [...new Set(raw.sourceRoots.filter((item) => typeof item === "string" && path.isAbsolute(item)).map((item) => path.resolve(item)))].slice(0, 32)
|
|
68
|
-
: [];
|
|
69
|
-
assets[key] = { descriptor, sourceRoots };
|
|
515
|
+
const before = fs.fstatSync(fd);
|
|
516
|
+
if (!before.isFile() || before.nlink !== 1 || before.size <= 0 || before.size > CLOUD_ASSET_STATE_MAX_BYTES) {
|
|
517
|
+
throw new Error("state file is not a bounded private file");
|
|
70
518
|
}
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
slug: item.slug,
|
|
81
|
-
scope: item.scope,
|
|
82
|
-
cloudId: item.cloudId,
|
|
83
|
-
revision: item.revision,
|
|
84
|
-
})).slice(-256)
|
|
85
|
-
: [];
|
|
86
|
-
return { schemaVersion: 1, assets, deletedBases };
|
|
519
|
+
const raw = fs.readFileSync(fd, "utf8");
|
|
520
|
+
const after = fs.fstatSync(fd);
|
|
521
|
+
if (
|
|
522
|
+
Buffer.byteLength(raw, "utf8") !== before.size || after.dev !== before.dev || after.ino !== before.ino ||
|
|
523
|
+
after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs
|
|
524
|
+
) {
|
|
525
|
+
throw new Error("state file changed while it was read");
|
|
526
|
+
}
|
|
527
|
+
return normalizeCloudAssetState(JSON.parse(raw));
|
|
87
528
|
} catch (error) {
|
|
88
529
|
// 깨진 상태 파일을 빈 상태로 위장하면 stale 베이스로 원격 리비전을 덮어쓸 수 있다.
|
|
89
530
|
throw new Error(`Agent Cloud local revision state is unreadable: ${error.message || error}`);
|
|
@@ -92,40 +533,64 @@ function readCloudAssetState() {
|
|
|
92
533
|
}
|
|
93
534
|
}
|
|
94
535
|
|
|
95
|
-
function
|
|
536
|
+
function writeCloudAssetStateUnlocked(state) {
|
|
96
537
|
const statePath = cloudAssetStatePath();
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
const
|
|
538
|
+
const normalized = normalizeCloudAssetState(state);
|
|
539
|
+
const directory = stateEnsureDirectory(path.dirname(statePath), "Cloud asset state directory");
|
|
540
|
+
const targetName = path.basename(statePath);
|
|
541
|
+
const target = path.join(directory.realpath, targetName);
|
|
542
|
+
const expected = stateFileSnapshot(target, "Cloud asset state", { allowMissing: true });
|
|
543
|
+
const payload = JSON.stringify(normalized, null, 2) + "\n";
|
|
544
|
+
if (Buffer.byteLength(payload, "utf8") > CLOUD_ASSET_STATE_MAX_BYTES) {
|
|
545
|
+
throw new Error("Cloud asset state exceeds its safety limit");
|
|
546
|
+
}
|
|
547
|
+
const temporary = stateWriteTemp(
|
|
548
|
+
directory,
|
|
549
|
+
`.${targetName}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`,
|
|
550
|
+
payload,
|
|
551
|
+
"Cloud asset state",
|
|
552
|
+
);
|
|
100
553
|
try {
|
|
101
|
-
|
|
102
|
-
|
|
554
|
+
statePublishFile(directory, targetName, temporary, expected, "Cloud asset state");
|
|
555
|
+
cloudFsyncDirectory(directory.realpath);
|
|
103
556
|
} finally {
|
|
104
|
-
|
|
557
|
+
stateRemoveOwnedFile(temporary.path, temporary.stat);
|
|
105
558
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
559
|
+
return normalized;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function writeCloudAssetState(state) {
|
|
563
|
+
return withCloudAssetLock(cloudAssetStatePath(), "Agent Cloud local revision state", () => writeCloudAssetStateUnlocked(state));
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function updateCloudAssetState(mutator) {
|
|
567
|
+
if (typeof mutator !== "function") throw new TypeError("Cloud asset state mutator must be a function");
|
|
568
|
+
return withCloudAssetLock(cloudAssetStatePath(), "Agent Cloud local revision state", () => {
|
|
569
|
+
const state = readCloudAssetState();
|
|
570
|
+
const result = mutator(state);
|
|
571
|
+
writeCloudAssetStateUnlocked(state);
|
|
572
|
+
return result;
|
|
573
|
+
});
|
|
109
574
|
}
|
|
110
575
|
|
|
111
576
|
/** 서버가 준 리비전 영수증을 관측 상태로 승격. sourceRoot가 있으면 톰스톤도 해제한다. */
|
|
112
577
|
function rememberCloudAssetDescriptor(value, options = {}) {
|
|
113
578
|
const descriptor = normalizeCloudAssetDescriptor(value);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
579
|
+
return updateCloudAssetState((state) => {
|
|
580
|
+
const key = cloudDescriptorKey(descriptor);
|
|
581
|
+
const previous = state.assets[key];
|
|
582
|
+
const sameRevision = previous && previous.descriptor.cloudId === descriptor.cloudId && previous.descriptor.revision === descriptor.revision;
|
|
583
|
+
const roots = sameRevision ? [...previous.sourceRoots] : [];
|
|
584
|
+
if (options.sourceRoot) {
|
|
585
|
+
const sourceRoot = path.resolve(options.sourceRoot);
|
|
586
|
+
roots.push(sourceRoot);
|
|
587
|
+
state.deletedBases = state.deletedBases.filter(
|
|
588
|
+
(item) => !(item.rootPath === sourceRoot && item.slug === descriptor.slug && item.scope === descriptor.scope),
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
state.assets[key] = { descriptor, sourceRoots: [...new Set(roots)].slice(0, 32) };
|
|
592
|
+
return descriptor;
|
|
593
|
+
});
|
|
129
594
|
}
|
|
130
595
|
|
|
131
596
|
function findCloudAssetDescriptor(slug, scope) {
|
|
@@ -193,18 +658,18 @@ function cloudUnboundDescriptorForSource(rootPath, slug, scope) {
|
|
|
193
658
|
|
|
194
659
|
/** 이 소스 루트를 이미 관측된 클라우드 자산에 명시적으로 연결한다. */
|
|
195
660
|
function bindCloudAssetSourceRoot(rootPath, slug, scope) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
661
|
+
return updateCloudAssetState((state) => {
|
|
662
|
+
const key = `${scope}:${slug}`;
|
|
663
|
+
const entry = state.assets[key];
|
|
664
|
+
if (!entry) throw new Error(`No observed Cloud revision for ${key} to bind.`);
|
|
665
|
+
const normalizedRoot = path.resolve(rootPath);
|
|
666
|
+
const roots = [...new Set([...entry.sourceRoots, normalizedRoot])].slice(0, 32);
|
|
667
|
+
state.assets[key] = { descriptor: entry.descriptor, sourceRoots: roots };
|
|
668
|
+
state.deletedBases = state.deletedBases.filter(
|
|
669
|
+
(item) => !(item.rootPath === normalizedRoot && item.slug === slug && item.scope === scope),
|
|
670
|
+
);
|
|
671
|
+
return entry.descriptor;
|
|
672
|
+
});
|
|
208
673
|
}
|
|
209
674
|
|
|
210
675
|
function cloudBaseDescriptorForSource(marker, rootPath, slug, scope) {
|
|
@@ -221,64 +686,91 @@ function cloudBaseDescriptorForSource(marker, rootPath, slug, scope) {
|
|
|
221
686
|
const stateDescriptor = entry && entry.sourceRoots.includes(normalizedRoot) ? entry.descriptor : null;
|
|
222
687
|
if (!markerDescriptor) return stateDescriptor;
|
|
223
688
|
if (!stateDescriptor) return markerDescriptor;
|
|
224
|
-
return stateDescriptor.cloudId === markerDescriptor.cloudId && stateDescriptor.updatedAt >= markerDescriptor.updatedAt
|
|
689
|
+
return stateDescriptor.cloudId === markerDescriptor.cloudId && Date.parse(stateDescriptor.updatedAt) >= Date.parse(markerDescriptor.updatedAt)
|
|
225
690
|
? stateDescriptor
|
|
226
691
|
: markerDescriptor;
|
|
227
692
|
}
|
|
228
693
|
|
|
229
694
|
function writeCloudSourceMarker(rootPath, scan, descriptor, options = {}) {
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
695
|
+
const lockTarget = path.join(
|
|
696
|
+
userDataDir(),
|
|
697
|
+
"cloud-source-marker-locks",
|
|
698
|
+
crypto.createHash("sha256").update(path.resolve(rootPath)).digest("hex"),
|
|
699
|
+
);
|
|
700
|
+
return withCloudAssetLock(lockTarget, "Agent Cloud source marker", () => {
|
|
701
|
+
const rootAnchor = stateDirectoryAnchor(rootPath, "Cloud source marker root");
|
|
702
|
+
const markerPath = path.join(rootAnchor.realpath, CLOUD_RESTORE_MARKER_PATH);
|
|
703
|
+
const expectedMarker = stateFileSnapshot(markerPath, "Cloud source marker", { allowMissing: true });
|
|
704
|
+
const previousMarker = readCloudSourceMarker(rootAnchor.realpath);
|
|
705
|
+
stateAssertDirectoryAnchor(rootAnchor, "Cloud source marker root");
|
|
706
|
+
const currentMarker = stateFileSnapshot(markerPath, "Cloud source marker", { allowMissing: true });
|
|
707
|
+
if ((expectedMarker && (!currentMarker || !stateSameFileSnapshot(currentMarker, expectedMarker))) ||
|
|
708
|
+
(!expectedMarker && currentMarker)) {
|
|
709
|
+
throw new Error("Cloud source marker changed while it was read");
|
|
241
710
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
711
|
+
const descriptors = cloudMarkerDescriptors(previousMarker);
|
|
712
|
+
if (descriptor) descriptors[descriptor.scope] = descriptor;
|
|
713
|
+
if (options.removeDescriptor) {
|
|
714
|
+
const current = descriptors[options.removeDescriptor.scope];
|
|
715
|
+
if (current && current.cloudId === options.removeDescriptor.cloudId && current.revision === options.removeDescriptor.revision) {
|
|
716
|
+
delete descriptors[options.removeDescriptor.scope];
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
const latest = descriptor || Object.values(descriptors)[0] || null;
|
|
720
|
+
const marker = {
|
|
721
|
+
schemaVersion: 1,
|
|
722
|
+
source: "agentlas-cloud",
|
|
723
|
+
slug: latest?.slug || options.removeDescriptor?.slug || cloudSlug(path.basename(rootPath)),
|
|
724
|
+
packageHash: descriptor?.packageHash || options.packageHash || previousMarker?.packageHash || "",
|
|
725
|
+
packageHashVersion: descriptor?.packageHashVersion || options.packageHashVersion || previousMarker?.packageHashVersion || CLOUD_PACKAGE_HASH_V1,
|
|
726
|
+
fileCount: Number.isSafeInteger(options.fileCount) ? options.fileCount : (previousMarker?.fileCount || 0),
|
|
727
|
+
totalBytes: Number.isSafeInteger(options.totalBytes) ? options.totalBytes : (previousMarker?.totalBytes || 0),
|
|
728
|
+
executablePaths: Array.isArray(options.executablePaths) ? options.executablePaths : previousMarker?.executablePaths,
|
|
729
|
+
cloudAssets: descriptors,
|
|
730
|
+
...(latest ? latest : {}),
|
|
731
|
+
restoredAt: previousMarker?.restoredAt,
|
|
732
|
+
savedAt: new Date().toISOString(),
|
|
733
|
+
};
|
|
734
|
+
for (const key of Object.keys(marker)) if (marker[key] === undefined) delete marker[key];
|
|
735
|
+
const temporary = stateWriteTemp(
|
|
736
|
+
rootAnchor,
|
|
737
|
+
`.${CLOUD_RESTORE_MARKER_PATH}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`,
|
|
738
|
+
JSON.stringify(marker, null, 2) + "\n",
|
|
739
|
+
"Cloud source marker",
|
|
740
|
+
);
|
|
741
|
+
try {
|
|
742
|
+
statePublishFile(rootAnchor, CLOUD_RESTORE_MARKER_PATH, temporary, expectedMarker, "Cloud source marker");
|
|
743
|
+
cloudFsyncDirectory(rootAnchor.realpath);
|
|
744
|
+
} finally {
|
|
745
|
+
stateRemoveOwnedFile(temporary.path, temporary.stat);
|
|
746
|
+
}
|
|
747
|
+
return marker;
|
|
748
|
+
});
|
|
271
749
|
}
|
|
272
750
|
|
|
273
751
|
function readCloudSourceMarker(rootPath) {
|
|
274
752
|
const markerPath = path.join(rootPath, CLOUD_RESTORE_MARKER_PATH);
|
|
275
|
-
if (!fs.existsSync(markerPath)) return null;
|
|
276
753
|
let fd;
|
|
277
754
|
try {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
755
|
+
try {
|
|
756
|
+
fd = fs.openSync(markerPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
757
|
+
} catch (error) {
|
|
758
|
+
if (error && error.code === "ENOENT") return null;
|
|
759
|
+
throw error;
|
|
760
|
+
}
|
|
761
|
+
const before = fs.fstatSync(fd);
|
|
762
|
+
if (!before.isFile() || before.nlink !== 1 || before.size <= 0 || before.size > CLOUD_ASSET_STATE_MAX_BYTES) {
|
|
763
|
+
throw new Error("marker is not a bounded private file");
|
|
764
|
+
}
|
|
765
|
+
const raw = fs.readFileSync(fd, "utf8");
|
|
766
|
+
const after = fs.fstatSync(fd);
|
|
767
|
+
if (
|
|
768
|
+
Buffer.byteLength(raw, "utf8") !== before.size || after.dev !== before.dev || after.ino !== before.ino ||
|
|
769
|
+
after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs
|
|
770
|
+
) {
|
|
771
|
+
throw new Error("marker changed while it was read");
|
|
772
|
+
}
|
|
773
|
+
return JSON.parse(raw);
|
|
282
774
|
} finally {
|
|
283
775
|
if (fd !== undefined) fs.closeSync(fd);
|
|
284
776
|
}
|
|
@@ -291,6 +783,7 @@ module.exports = {
|
|
|
291
783
|
cloudAssetStatePath,
|
|
292
784
|
readCloudAssetState,
|
|
293
785
|
writeCloudAssetState,
|
|
786
|
+
updateCloudAssetState,
|
|
294
787
|
rememberCloudAssetDescriptor,
|
|
295
788
|
findCloudAssetDescriptor,
|
|
296
789
|
cloudMarkerDescriptors,
|