@awak-app/simy-cli 0.5.7 → 0.5.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -1
- package/package.json +7 -3
- package/src/agent.js +71 -10
- package/src/auto-update.js +312 -35
- package/src/browser.js +24 -7
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/SKILL.md +188 -12
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/references/acceptance-evidence.md +130 -0
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/references/change-boundary-matrix.md +20 -1
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/references/cross-repository-control.md +76 -0
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/references/delivery-levels.md +76 -0
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/references/local-sqm-evidence.md +34 -0
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/references/state-and-receipts.md +15 -0
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/scripts/check-acceptance.mjs +191 -0
- package/src/console/app.js +21 -2
- package/src/console/commands.js +3 -0
- package/src/desktop-host.js +220 -0
- package/src/desktop-process-lock.js +231 -0
- package/src/desktop-status.js +263 -0
- package/src/direct-executor.js +6 -0
- package/src/index.js +38 -12
- package/src/install-update.js +494 -0
- package/src/sqm/agent-skill.js +61 -75
- package/src/sqm/command.js +3 -0
- package/src/sqm/local-evidence.js +28 -0
- package/src/sqm/workflow-onboarding.js +1 -1
- package/src/sqm/workflow-update.js +3 -2
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
lstat,
|
|
4
|
+
mkdir,
|
|
5
|
+
open,
|
|
6
|
+
readFile,
|
|
7
|
+
readdir,
|
|
8
|
+
readlink,
|
|
9
|
+
rename,
|
|
10
|
+
rm,
|
|
11
|
+
symlink,
|
|
12
|
+
} from "node:fs/promises";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
const APP_NAME = "SIMY CLI.app";
|
|
19
|
+
const MAX_ARCHIVE_ENTRIES = 100_000;
|
|
20
|
+
const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
|
|
21
|
+
const MAX_UNCOMPRESSED_BYTES = 2 * 1024 * 1024 * 1024;
|
|
22
|
+
const MH_MAGIC_64 = 0xfeedfacf;
|
|
23
|
+
const CPU_TYPE_ARM64 = 0x0100000c;
|
|
24
|
+
|
|
25
|
+
export async function installMacosInstallerRelease({
|
|
26
|
+
version,
|
|
27
|
+
release,
|
|
28
|
+
marker,
|
|
29
|
+
stateRoot,
|
|
30
|
+
fetchImpl = fetch,
|
|
31
|
+
execFileCommand = execFileAsync,
|
|
32
|
+
} = {}) {
|
|
33
|
+
if (!marker?.teamId) {
|
|
34
|
+
throw new Error("Automatic installer updates require a signed production installation.");
|
|
35
|
+
}
|
|
36
|
+
const updater = release?.artifact?.updater;
|
|
37
|
+
if (!updater) throw new Error("This release requires a manual installer download.");
|
|
38
|
+
|
|
39
|
+
const installerRoot = path.join(stateRoot, "installer");
|
|
40
|
+
const stagingRoot = path.join(installerRoot, "staging");
|
|
41
|
+
const releasesRoot = path.join(installerRoot, "releases");
|
|
42
|
+
const nonce = randomBytes(16).toString("hex");
|
|
43
|
+
const staging = path.join(stagingRoot, `${version}-${nonce}`);
|
|
44
|
+
const archivePath = path.join(staging, "update.zip");
|
|
45
|
+
const extractedRoot = path.join(staging, "extracted");
|
|
46
|
+
const candidateApp = path.join(extractedRoot, APP_NAME);
|
|
47
|
+
const releaseDirectory = path.join(releasesRoot, version);
|
|
48
|
+
const releaseApp = path.join(releaseDirectory, APP_NAME);
|
|
49
|
+
const currentLink = path.join(installerRoot, "current");
|
|
50
|
+
|
|
51
|
+
await mkdir(installerRoot, { recursive: true, mode: 0o700 });
|
|
52
|
+
await requirePlainDirectory(installerRoot);
|
|
53
|
+
await mkdir(stagingRoot, { recursive: true, mode: 0o700 });
|
|
54
|
+
await requirePlainDirectory(stagingRoot);
|
|
55
|
+
await mkdir(extractedRoot, { recursive: true, mode: 0o700 });
|
|
56
|
+
try {
|
|
57
|
+
await downloadVerifiedArchive(updater, archivePath, fetchImpl);
|
|
58
|
+
await inspectArchive(archivePath);
|
|
59
|
+
await execFileCommand("/usr/bin/tar", ["-xf", archivePath, "-C", extractedRoot, "--no-same-owner"], {
|
|
60
|
+
timeout: 5 * 60_000,
|
|
61
|
+
encoding: "utf8",
|
|
62
|
+
});
|
|
63
|
+
await rejectLinks(candidateApp);
|
|
64
|
+
await verifyCandidateApp(candidateApp, {
|
|
65
|
+
version,
|
|
66
|
+
marker,
|
|
67
|
+
execFileCommand,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await mkdir(releasesRoot, { recursive: true, mode: 0o700 });
|
|
71
|
+
await requirePlainDirectory(installerRoot);
|
|
72
|
+
await requirePlainDirectory(releasesRoot);
|
|
73
|
+
let releaseExists = false;
|
|
74
|
+
try {
|
|
75
|
+
const releaseDetails = await lstat(releaseDirectory);
|
|
76
|
+
if (!releaseDetails.isDirectory() || releaseDetails.isSymbolicLink()) {
|
|
77
|
+
throw new Error("Existing installer release path is not a plain directory.");
|
|
78
|
+
}
|
|
79
|
+
releaseExists = true;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error?.code !== "ENOENT") throw error;
|
|
82
|
+
}
|
|
83
|
+
if (releaseExists) {
|
|
84
|
+
await rejectLinks(releaseApp);
|
|
85
|
+
await verifyCandidateApp(releaseApp, { version, marker, execFileCommand });
|
|
86
|
+
await rm(staging, { recursive: true, force: true });
|
|
87
|
+
} else {
|
|
88
|
+
await rename(extractedRoot, releaseDirectory);
|
|
89
|
+
await rm(staging, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const previousTarget = await readCurrentTarget(currentLink, installerRoot);
|
|
93
|
+
await switchCurrentLink({ installerRoot, currentLink, target: releaseApp, nonce });
|
|
94
|
+
return {
|
|
95
|
+
entryPath: path.join(releaseApp, "Contents", "Resources", "cli", "src", "index.js"),
|
|
96
|
+
executablePath: path.join(releaseApp, "Contents", "Resources", "runtime", "bin", "node"),
|
|
97
|
+
appPath: releaseApp,
|
|
98
|
+
rollback: async () => {
|
|
99
|
+
if (previousTarget) {
|
|
100
|
+
await switchCurrentLink({ installerRoot, currentLink, target: previousTarget, nonce: `${nonce}-rollback` });
|
|
101
|
+
} else {
|
|
102
|
+
await rm(currentLink, { force: true });
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
} catch (error) {
|
|
107
|
+
await rm(staging, { recursive: true, force: true });
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function readCurrentInstallerVersion({ stateRoot } = {}) {
|
|
113
|
+
const packagePath = path.join(
|
|
114
|
+
stateRoot,
|
|
115
|
+
"installer",
|
|
116
|
+
"current",
|
|
117
|
+
"Contents",
|
|
118
|
+
"Resources",
|
|
119
|
+
"cli",
|
|
120
|
+
"package.json",
|
|
121
|
+
);
|
|
122
|
+
const payload = JSON.parse(await readFile(packagePath, "utf8"));
|
|
123
|
+
return String(payload.version || "").trim();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function downloadVerifiedArchive(updater, destination, fetchImpl = fetch) {
|
|
127
|
+
if (new URL(updater.url).protocol !== "https:") {
|
|
128
|
+
throw new Error("Installer update download must use HTTPS.");
|
|
129
|
+
}
|
|
130
|
+
if (updater.size > MAX_ARCHIVE_BYTES) {
|
|
131
|
+
throw new Error("Installer update archive exceeds the download safety limit.");
|
|
132
|
+
}
|
|
133
|
+
const response = await fetchImpl(updater.url, {
|
|
134
|
+
headers: { Accept: "application/zip, application/octet-stream" },
|
|
135
|
+
signal: AbortSignal.timeout(5 * 60_000),
|
|
136
|
+
});
|
|
137
|
+
if (!response.ok || !response.body) {
|
|
138
|
+
throw new Error(`Installer update download returned HTTP ${response.status}.`);
|
|
139
|
+
}
|
|
140
|
+
if (response.url && new URL(response.url).protocol !== "https:") {
|
|
141
|
+
throw new Error("Installer update download redirected to an insecure URL.");
|
|
142
|
+
}
|
|
143
|
+
const declaredLength = response.headers.get("content-length");
|
|
144
|
+
if (declaredLength && Number(declaredLength) !== updater.size) {
|
|
145
|
+
throw new Error("Installer update download size does not match the release manifest.");
|
|
146
|
+
}
|
|
147
|
+
const handle = await open(destination, "wx", 0o600);
|
|
148
|
+
const hash = createHash("sha256");
|
|
149
|
+
let size = 0;
|
|
150
|
+
try {
|
|
151
|
+
for await (const value of response.body) {
|
|
152
|
+
const chunk = Buffer.from(value);
|
|
153
|
+
size += chunk.length;
|
|
154
|
+
if (size > updater.size) throw new Error("Installer update download exceeds its declared size.");
|
|
155
|
+
hash.update(chunk);
|
|
156
|
+
await handle.write(chunk);
|
|
157
|
+
}
|
|
158
|
+
} finally {
|
|
159
|
+
await handle.close();
|
|
160
|
+
}
|
|
161
|
+
if (size !== updater.size || hash.digest("hex") !== updater.sha256) {
|
|
162
|
+
throw new Error("Installer update download failed size or SHA-256 verification.");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function inspectArchive(archivePath) {
|
|
167
|
+
const bytes = await readFile(archivePath);
|
|
168
|
+
const minimumEocd = Math.max(0, bytes.length - 65_557);
|
|
169
|
+
let eocd = -1;
|
|
170
|
+
for (let offset = bytes.length - 22; offset >= minimumEocd; offset -= 1) {
|
|
171
|
+
if (bytes.readUInt32LE(offset) === 0x06054b50) {
|
|
172
|
+
eocd = offset;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (eocd < 0 || eocd + 22 + bytes.readUInt16LE(eocd + 20) !== bytes.length) {
|
|
177
|
+
throw new Error("Installer update archive has an invalid central directory.");
|
|
178
|
+
}
|
|
179
|
+
const disk = bytes.readUInt16LE(eocd + 4);
|
|
180
|
+
const centralDisk = bytes.readUInt16LE(eocd + 6);
|
|
181
|
+
const diskEntries = bytes.readUInt16LE(eocd + 8);
|
|
182
|
+
const entryCount = bytes.readUInt16LE(eocd + 10);
|
|
183
|
+
const centralSize = bytes.readUInt32LE(eocd + 12);
|
|
184
|
+
const centralOffset = bytes.readUInt32LE(eocd + 16);
|
|
185
|
+
if (
|
|
186
|
+
disk !== 0 ||
|
|
187
|
+
centralDisk !== 0 ||
|
|
188
|
+
diskEntries !== entryCount ||
|
|
189
|
+
entryCount === 0 ||
|
|
190
|
+
entryCount > MAX_ARCHIVE_ENTRIES ||
|
|
191
|
+
centralSize === 0xffffffff ||
|
|
192
|
+
centralOffset === 0xffffffff ||
|
|
193
|
+
centralOffset + centralSize !== eocd
|
|
194
|
+
) {
|
|
195
|
+
throw new Error("Installer update archive uses an unsupported ZIP structure.");
|
|
196
|
+
}
|
|
197
|
+
const entries = [];
|
|
198
|
+
const localRanges = [];
|
|
199
|
+
let totalBytes = 0;
|
|
200
|
+
let offset = centralOffset;
|
|
201
|
+
for (let index = 0; index < entryCount; index += 1) {
|
|
202
|
+
if (offset + 46 > eocd || bytes.readUInt32LE(offset) !== 0x02014b50) {
|
|
203
|
+
throw new Error("Installer update archive has a malformed central-directory entry.");
|
|
204
|
+
}
|
|
205
|
+
const versionMadeBy = bytes.readUInt16LE(offset + 4);
|
|
206
|
+
const flags = bytes.readUInt16LE(offset + 8);
|
|
207
|
+
const method = bytes.readUInt16LE(offset + 10);
|
|
208
|
+
const crc32 = bytes.readUInt32LE(offset + 16);
|
|
209
|
+
const compressedSize = bytes.readUInt32LE(offset + 20);
|
|
210
|
+
const uncompressedSize = bytes.readUInt32LE(offset + 24);
|
|
211
|
+
const nameLength = bytes.readUInt16LE(offset + 28);
|
|
212
|
+
const extraLength = bytes.readUInt16LE(offset + 30);
|
|
213
|
+
const commentLength = bytes.readUInt16LE(offset + 32);
|
|
214
|
+
const externalAttributes = bytes.readUInt32LE(offset + 38);
|
|
215
|
+
const localOffset = bytes.readUInt32LE(offset + 42);
|
|
216
|
+
const end = offset + 46 + nameLength + extraLength + commentLength;
|
|
217
|
+
if (
|
|
218
|
+
end > eocd ||
|
|
219
|
+
compressedSize === 0xffffffff ||
|
|
220
|
+
uncompressedSize === 0xffffffff ||
|
|
221
|
+
localOffset === 0xffffffff ||
|
|
222
|
+
(flags & 0x1) !== 0 ||
|
|
223
|
+
localOffset + 30 > centralOffset ||
|
|
224
|
+
bytes.readUInt32LE(localOffset) !== 0x04034b50
|
|
225
|
+
) {
|
|
226
|
+
throw new Error("Installer update archive contains an unsupported entry.");
|
|
227
|
+
}
|
|
228
|
+
const nameBytes = bytes.subarray(offset + 46, offset + 46 + nameLength);
|
|
229
|
+
const centralExtra = bytes.subarray(
|
|
230
|
+
offset + 46 + nameLength,
|
|
231
|
+
offset + 46 + nameLength + extraLength,
|
|
232
|
+
);
|
|
233
|
+
rejectUnsafeExtraFields(centralExtra);
|
|
234
|
+
if ((flags & 0x800) === 0 && nameBytes.some((value) => value >= 0x80)) {
|
|
235
|
+
throw new Error("Installer update archive contains a non-UTF-8 path.");
|
|
236
|
+
}
|
|
237
|
+
let name;
|
|
238
|
+
try {
|
|
239
|
+
name = new TextDecoder("utf-8", { fatal: true }).decode(nameBytes);
|
|
240
|
+
} catch {
|
|
241
|
+
throw new Error("Installer update archive contains an invalid UTF-8 path.");
|
|
242
|
+
}
|
|
243
|
+
const localNameLength = bytes.readUInt16LE(localOffset + 26);
|
|
244
|
+
const localExtraLength = bytes.readUInt16LE(localOffset + 28);
|
|
245
|
+
const localFlags = bytes.readUInt16LE(localOffset + 6);
|
|
246
|
+
const localMethod = bytes.readUInt16LE(localOffset + 8);
|
|
247
|
+
const localName = bytes.subarray(localOffset + 30, localOffset + 30 + localNameLength);
|
|
248
|
+
const localExtra = bytes.subarray(
|
|
249
|
+
localOffset + 30 + localNameLength,
|
|
250
|
+
localOffset + 30 + localNameLength + localExtraLength,
|
|
251
|
+
);
|
|
252
|
+
rejectUnsafeExtraFields(localExtra);
|
|
253
|
+
const dataEnd = localOffset + 30 + localNameLength + localExtraLength + compressedSize;
|
|
254
|
+
if (
|
|
255
|
+
!nameBytes.equals(localName) ||
|
|
256
|
+
localFlags !== flags ||
|
|
257
|
+
localMethod !== method ||
|
|
258
|
+
dataEnd > centralOffset
|
|
259
|
+
) {
|
|
260
|
+
throw new Error("Installer update archive entry names do not match.");
|
|
261
|
+
}
|
|
262
|
+
let entryEnd = dataEnd;
|
|
263
|
+
if ((flags & 0x08) !== 0) {
|
|
264
|
+
const hasSignature = dataEnd + 4 <= centralOffset && bytes.readUInt32LE(dataEnd) === 0x08074b50;
|
|
265
|
+
const descriptor = dataEnd + (hasSignature ? 4 : 0);
|
|
266
|
+
if (
|
|
267
|
+
descriptor + 12 > centralOffset ||
|
|
268
|
+
bytes.readUInt32LE(descriptor) !== crc32 ||
|
|
269
|
+
bytes.readUInt32LE(descriptor + 4) !== compressedSize ||
|
|
270
|
+
bytes.readUInt32LE(descriptor + 8) !== uncompressedSize
|
|
271
|
+
) {
|
|
272
|
+
throw new Error("Installer update archive has an invalid data descriptor.");
|
|
273
|
+
}
|
|
274
|
+
entryEnd = descriptor + 12;
|
|
275
|
+
}
|
|
276
|
+
validateArchivePath(name);
|
|
277
|
+
const unixMode = (versionMadeBy >>> 8) === 3 ? externalAttributes >>> 16 : 0;
|
|
278
|
+
if ((unixMode & 0o170000) === 0o120000) {
|
|
279
|
+
throw new Error("Installer update archive contains a symbolic link.");
|
|
280
|
+
}
|
|
281
|
+
const fileType = unixMode & 0o170000;
|
|
282
|
+
if (fileType !== 0 && fileType !== 0o040000 && fileType !== 0o100000) {
|
|
283
|
+
throw new Error("Installer update archive contains an unsafe special file.");
|
|
284
|
+
}
|
|
285
|
+
if ((name.endsWith("/") && fileType === 0o100000) || (!name.endsWith("/") && fileType === 0o040000)) {
|
|
286
|
+
throw new Error("Installer update archive entry type does not match its path.");
|
|
287
|
+
}
|
|
288
|
+
totalBytes += uncompressedSize;
|
|
289
|
+
if (!Number.isSafeInteger(totalBytes) || totalBytes > MAX_UNCOMPRESSED_BYTES) {
|
|
290
|
+
throw new Error("Installer update archive expands beyond the safety limit.");
|
|
291
|
+
}
|
|
292
|
+
entries.push(name);
|
|
293
|
+
localRanges.push([localOffset, entryEnd]);
|
|
294
|
+
offset = end;
|
|
295
|
+
}
|
|
296
|
+
if (offset !== eocd) {
|
|
297
|
+
throw new Error("Installer update archive central-directory size does not match its entries.");
|
|
298
|
+
}
|
|
299
|
+
if (new Set(entries).size !== entries.length) {
|
|
300
|
+
throw new Error("Installer update archive contains duplicate paths.");
|
|
301
|
+
}
|
|
302
|
+
const normalizedEntries = entries.map((name) => name.normalize("NFD").toLowerCase());
|
|
303
|
+
if (new Set(normalizedEntries).size !== normalizedEntries.length) {
|
|
304
|
+
throw new Error("Installer update archive contains colliding paths.");
|
|
305
|
+
}
|
|
306
|
+
localRanges.sort((left, right) => left[0] - right[0]);
|
|
307
|
+
for (let index = 1; index < localRanges.length; index += 1) {
|
|
308
|
+
if (localRanges[index][0] < localRanges[index - 1][1]) {
|
|
309
|
+
throw new Error("Installer update archive contains overlapping entries.");
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return entries;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function rejectUnsafeExtraFields(bytes) {
|
|
316
|
+
let offset = 0;
|
|
317
|
+
while (offset < bytes.length) {
|
|
318
|
+
if (offset + 4 > bytes.length) {
|
|
319
|
+
throw new Error("Installer update archive contains a malformed ZIP extra field.");
|
|
320
|
+
}
|
|
321
|
+
const id = bytes.readUInt16LE(offset);
|
|
322
|
+
const size = bytes.readUInt16LE(offset + 2);
|
|
323
|
+
if (offset + 4 + size > bytes.length) {
|
|
324
|
+
throw new Error("Installer update archive contains a malformed ZIP extra field.");
|
|
325
|
+
}
|
|
326
|
+
if (id === 0x0001 || id === 0x7075) {
|
|
327
|
+
throw new Error("Installer update archive contains an unsafe path override.");
|
|
328
|
+
}
|
|
329
|
+
offset += 4 + size;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function validateArchivePath(name) {
|
|
334
|
+
if (
|
|
335
|
+
!name ||
|
|
336
|
+
name.includes("\\") ||
|
|
337
|
+
name.includes("\0") ||
|
|
338
|
+
/[\x00-\x1f\x7f]/.test(name) ||
|
|
339
|
+
path.posix.isAbsolute(name) ||
|
|
340
|
+
name.split("/").includes("..") ||
|
|
341
|
+
(name !== APP_NAME && !name.startsWith(`${APP_NAME}/`))
|
|
342
|
+
) {
|
|
343
|
+
throw new Error("Installer update archive contains an unsafe path.");
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export async function verifyCandidateApp(
|
|
348
|
+
appPath,
|
|
349
|
+
{ version, marker, execFileCommand = execFileAsync } = {},
|
|
350
|
+
) {
|
|
351
|
+
const resources = path.join(appPath, "Contents", "Resources");
|
|
352
|
+
const cliRoot = path.join(resources, "cli");
|
|
353
|
+
const runtimePath = path.join(resources, "runtime", "bin", "node");
|
|
354
|
+
const entryPath = path.join(cliRoot, "src", "index.js");
|
|
355
|
+
const appExecutable = path.join(appPath, "Contents", "MacOS", "simy");
|
|
356
|
+
for (const requiredPath of [runtimePath, entryPath, appExecutable]) await lstat(requiredPath);
|
|
357
|
+
|
|
358
|
+
const packageInfo = JSON.parse(await readFile(path.join(cliRoot, "package.json"), "utf8"));
|
|
359
|
+
const candidateMarker = JSON.parse(await readFile(path.join(cliRoot, "installation.json"), "utf8"));
|
|
360
|
+
if (packageInfo.version !== version || !sameMarker(candidateMarker, marker)) {
|
|
361
|
+
throw new Error("Installer update identity does not match the requested release.");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
await execFileCommand("/usr/bin/codesign", ["--verify", "--deep", "--strict", appPath], {
|
|
365
|
+
timeout: 60_000,
|
|
366
|
+
encoding: "utf8",
|
|
367
|
+
});
|
|
368
|
+
await execFileCommand("/usr/bin/codesign", ["--verify", "--strict", runtimePath], {
|
|
369
|
+
timeout: 60_000,
|
|
370
|
+
encoding: "utf8",
|
|
371
|
+
});
|
|
372
|
+
const { stderr = "", stdout = "" } = await execFileCommand(
|
|
373
|
+
"/usr/bin/codesign",
|
|
374
|
+
["-d", "--verbose=4", appPath],
|
|
375
|
+
{ timeout: 60_000, encoding: "utf8" },
|
|
376
|
+
);
|
|
377
|
+
const signingDetails = `${stdout}\n${stderr}`;
|
|
378
|
+
if (
|
|
379
|
+
!signingDetails.split("\n").includes(`Identifier=${marker.bundleId}`) ||
|
|
380
|
+
!signingDetails.split("\n").includes(`TeamIdentifier=${marker.teamId}`)
|
|
381
|
+
) {
|
|
382
|
+
throw new Error("Installer update signature identity does not match the installed SIMY release.");
|
|
383
|
+
}
|
|
384
|
+
const gatekeeper = await execFileCommand("/usr/sbin/spctl", [
|
|
385
|
+
"--assess",
|
|
386
|
+
"--type",
|
|
387
|
+
"execute",
|
|
388
|
+
"--verbose=4",
|
|
389
|
+
appPath,
|
|
390
|
+
], {
|
|
391
|
+
timeout: 60_000,
|
|
392
|
+
encoding: "utf8",
|
|
393
|
+
});
|
|
394
|
+
if (!/source=Notarized Developer ID/i.test(`${gatekeeper.stdout || ""}\n${gatekeeper.stderr || ""}`)) {
|
|
395
|
+
throw new Error("Installer update did not pass the notarized Developer ID policy.");
|
|
396
|
+
}
|
|
397
|
+
await verifyArm64MachO(runtimePath);
|
|
398
|
+
await verifyArm64MachO(appExecutable);
|
|
399
|
+
const { stdout: bundleVersion } = await execFileCommand(
|
|
400
|
+
"/usr/bin/plutil",
|
|
401
|
+
["-extract", "CFBundleShortVersionString", "raw", "-o", "-", path.join(appPath, "Contents", "Info.plist")],
|
|
402
|
+
{ timeout: 30_000, encoding: "utf8" },
|
|
403
|
+
);
|
|
404
|
+
if (String(bundleVersion || "").trim() !== version) {
|
|
405
|
+
throw new Error("Installer update app version does not match the requested release.");
|
|
406
|
+
}
|
|
407
|
+
return { version, runtimePath, entryPath };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export async function verifyArm64MachO(executablePath) {
|
|
411
|
+
const handle = await open(executablePath, "r");
|
|
412
|
+
const header = Buffer.alloc(8);
|
|
413
|
+
try {
|
|
414
|
+
const { bytesRead } = await handle.read(header, 0, header.length, 0);
|
|
415
|
+
if (bytesRead !== header.length) {
|
|
416
|
+
throw new Error("Installer update contains a truncated executable.");
|
|
417
|
+
}
|
|
418
|
+
} finally {
|
|
419
|
+
await handle.close();
|
|
420
|
+
}
|
|
421
|
+
if (
|
|
422
|
+
header.readUInt32LE(0) !== MH_MAGIC_64 ||
|
|
423
|
+
header.readUInt32LE(4) !== CPU_TYPE_ARM64
|
|
424
|
+
) {
|
|
425
|
+
throw new Error("Installer update contains a non-arm64 Mach-O executable.");
|
|
426
|
+
}
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function sameMarker(left, right) {
|
|
431
|
+
return Boolean(
|
|
432
|
+
left &&
|
|
433
|
+
right &&
|
|
434
|
+
left.schemaVersion === 1 &&
|
|
435
|
+
left.channel === "macos-installer" &&
|
|
436
|
+
left.platform === "darwin" &&
|
|
437
|
+
left.arch === "arm64" &&
|
|
438
|
+
left.teamId === right.teamId &&
|
|
439
|
+
left.bundleId === right.bundleId &&
|
|
440
|
+
Object.keys(left).sort().join(",") ===
|
|
441
|
+
"arch,bundleId,channel,platform,schemaVersion,teamId"
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function rejectLinks(root) {
|
|
446
|
+
const details = await lstat(root);
|
|
447
|
+
if (details.isSymbolicLink()) throw new Error("Installer update contains an unsafe symbolic link.");
|
|
448
|
+
if (details.isFile()) return;
|
|
449
|
+
if (!details.isDirectory()) throw new Error("Installer update contains an unsafe special file.");
|
|
450
|
+
for (const child of await readdir(root)) await rejectLinks(path.join(root, child));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function requirePlainDirectory(directory) {
|
|
454
|
+
const details = await lstat(directory);
|
|
455
|
+
if (!details.isDirectory() || details.isSymbolicLink()) {
|
|
456
|
+
throw new Error("Installer update storage is not a plain directory.");
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function readCurrentTarget(currentLink, installerRoot) {
|
|
461
|
+
try {
|
|
462
|
+
const details = await lstat(currentLink);
|
|
463
|
+
if (!details.isSymbolicLink()) {
|
|
464
|
+
throw new Error("The current installer release pointer is not a symbolic link.");
|
|
465
|
+
}
|
|
466
|
+
const target = await readlink(currentLink);
|
|
467
|
+
if (path.isAbsolute(target)) {
|
|
468
|
+
throw new Error("The current installer release pointer must be relative.");
|
|
469
|
+
}
|
|
470
|
+
const resolved = path.resolve(path.dirname(currentLink), target);
|
|
471
|
+
const relative = path.relative(installerRoot, resolved);
|
|
472
|
+
if (relative.startsWith("..") || !relative.startsWith(`releases${path.sep}`)) {
|
|
473
|
+
throw new Error("The current installer release pointer is outside the release directory.");
|
|
474
|
+
}
|
|
475
|
+
return resolved;
|
|
476
|
+
} catch (error) {
|
|
477
|
+
if (error?.code === "ENOENT") return null;
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function switchCurrentLink({ installerRoot, currentLink, target, nonce }) {
|
|
483
|
+
const temporaryLink = path.join(installerRoot, `.current-${nonce}`);
|
|
484
|
+
const relativeTarget = path.relative(installerRoot, target);
|
|
485
|
+
if (
|
|
486
|
+
path.isAbsolute(relativeTarget) ||
|
|
487
|
+
relativeTarget.startsWith("..") ||
|
|
488
|
+
!relativeTarget.startsWith(`releases${path.sep}`)
|
|
489
|
+
) {
|
|
490
|
+
throw new Error("Refusing to select an installer release outside the release directory.");
|
|
491
|
+
}
|
|
492
|
+
await symlink(relativeTarget, temporaryLink);
|
|
493
|
+
await rename(temporaryLink, currentLink);
|
|
494
|
+
}
|