@awak-app/simy-cli 0.5.8 → 0.5.11
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 +64 -0
- package/package.json +7 -3
- package/src/agent.js +70 -9
- 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 +227 -18
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/references/acceptance-evidence.md +131 -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 +80 -0
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/references/minimum-completion.md +137 -0
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/references/shared-system-specifications.md +138 -0
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/references/state-and-receipts.md +18 -0
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/scripts/check-acceptance.mjs +193 -0
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/scripts/check-minimum-completion.mjs +221 -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 +37 -11
- package/src/install-update.js +494 -0
- package/src/orchestrator/audit.js +17 -2
- package/src/orchestrator/completion-contract.js +1 -1
- package/src/orchestrator/evidence.js +9 -1
- package/src/orchestrator/independent-audit.js +13 -1
- package/src/orchestrator/instruction.js +13 -1
- package/src/orchestrator/loop.js +34 -4
- package/src/orchestrator/minimum-completion.js +108 -0
- package/src/orchestrator/result.js +7 -0
|
@@ -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
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { minimumCompletionCheck } from "./minimum-completion.js";
|
|
1
2
|
import { open, readdir, stat } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
|
|
@@ -131,7 +132,7 @@ export async function auditAttempt(charter, attempt, { previousAttempt = null, s
|
|
|
131
132
|
}
|
|
132
133
|
attempt.retry_problem_solving = retryProblemSolving;
|
|
133
134
|
|
|
134
|
-
for (const check of completionEvidenceChecks(charter, attempt)) {
|
|
135
|
+
for (const check of [...completionEvidenceChecks(charter, attempt), minimumCompletionCheck(charter, attempt)]) {
|
|
135
136
|
addCheck(
|
|
136
137
|
check.id,
|
|
137
138
|
check.label,
|
|
@@ -143,6 +144,13 @@ export async function auditAttempt(charter, attempt, { previousAttempt = null, s
|
|
|
143
144
|
);
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
if (attempt.independent_audit) {
|
|
148
|
+
addCheck("minimum_review_current", "Independent review covers the current evidence",
|
|
149
|
+
Boolean(attempt.independent_audit.minimum_matrix_sha256 &&
|
|
150
|
+
attempt.independent_audit.minimum_matrix_sha256 === attempt.observed_evidence?.minimum_completion?.matrix_sha256),
|
|
151
|
+
"Changed minimum evidence requires a fresh independent review.");
|
|
152
|
+
}
|
|
153
|
+
|
|
146
154
|
const evidenceRequired = charter.expected_evidence.includes("ui_evidence_path");
|
|
147
155
|
const evidenceArtifact = await inspectEvidence(charter, attempt.ui_evidence_path);
|
|
148
156
|
if (evidenceRequired || attempt.ui_evidence_path) {
|
|
@@ -305,7 +313,12 @@ export function evaluatePrReadiness(charter, attempt) {
|
|
|
305
313
|
const github = attempt.observed_evidence?.github || {};
|
|
306
314
|
const local = attempt.observed_evidence?.local || {};
|
|
307
315
|
const independentAudit = attempt.independent_audit;
|
|
316
|
+
const minimum = minimumCompletionCheck(charter, attempt);
|
|
308
317
|
const checks = [
|
|
318
|
+
readinessCheck(minimum.id, minimum.passed, minimum.detail),
|
|
319
|
+
readinessCheck("minimum_independent_binding",
|
|
320
|
+
Boolean(independentAudit?.minimum_matrix_sha256 && independentAudit.minimum_matrix_sha256 === attempt.observed_evidence?.minimum_completion?.matrix_sha256),
|
|
321
|
+
"Independent review must cover the current minimum evidence matrix."),
|
|
309
322
|
readinessCheck("implementation_gate", attempt.audit?.passed === true, "Implementation gate must pass."),
|
|
310
323
|
readinessCheck(
|
|
311
324
|
"independent_ai_audit",
|
|
@@ -372,7 +385,9 @@ export function evaluatePrReadiness(charter, attempt) {
|
|
|
372
385
|
state: ready ? "merge_ready" : "pr_ready_for_review",
|
|
373
386
|
summary: ready
|
|
374
387
|
? "PR has independently verified evidence, passing checks, and required approval."
|
|
375
|
-
:
|
|
388
|
+
: attempt.audit?.passed && minimum.passed
|
|
389
|
+
? "Implementation is verified, but the PR is not merge-ready."
|
|
390
|
+
: "Implementation verification is incomplete; resolve the pending evidence before promotion.",
|
|
376
391
|
checks,
|
|
377
392
|
pending_reasons: checks.filter((check) => !check.passed).map((check) => check.detail),
|
|
378
393
|
};
|
|
@@ -76,7 +76,7 @@ export function normalizeVisualReview(value) {
|
|
|
76
76
|
|
|
77
77
|
export function completionEvidenceChecks(charter, attempt) {
|
|
78
78
|
const contract = record(charter?.completion_contract);
|
|
79
|
-
if (contract.version !== COMPLETION_CONTRACT_VERSION) return [];
|
|
79
|
+
if (contract.version !== COMPLETION_CONTRACT_VERSION) return [check("completion_contract_version", "Supported completion contract required", false, "Missing or unsupported completion contract; re-charter and verify before promotion.")];
|
|
80
80
|
|
|
81
81
|
const uncertainty = normalizeUncertaintyVerification(attempt?.uncertainty_verification);
|
|
82
82
|
const rows = acceptanceCriterionRows(charter);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { collectMinimumCompletion } from "./minimum-completion.js";
|
|
1
2
|
import { execFile } from "node:child_process";
|
|
2
3
|
import { promisify } from "node:util";
|
|
3
4
|
|
|
@@ -25,9 +26,16 @@ export async function collectPrEvidence({
|
|
|
25
26
|
runCommand,
|
|
26
27
|
});
|
|
27
28
|
const github = await collectGitHubEvidence({ charter, attempt, repositoryPath, runCommand });
|
|
29
|
+
const minimumCompletion = await collectMinimumCompletion({ charter, attempt, repositoryPath, local });
|
|
30
|
+
const latest = await collectLocalGitEvidence({ baseBranch: charter.base_branch, repositoryPath, runCommand });
|
|
31
|
+
if (!latest.available || latest.head_sha !== local.head_sha || !latest.working_tree_clean) {
|
|
32
|
+
minimumCompletion.status = "evidence_incomplete";
|
|
33
|
+
minimumCompletion.errors = ["Checkout changed while collecting minimum evidence; verify again."];
|
|
34
|
+
}
|
|
28
35
|
return {
|
|
36
|
+
minimum_completion: minimumCompletion,
|
|
29
37
|
collected_at: new Date().toISOString(),
|
|
30
|
-
local,
|
|
38
|
+
local: latest,
|
|
31
39
|
github,
|
|
32
40
|
};
|
|
33
41
|
}
|
|
@@ -26,6 +26,7 @@ export function buildIndependentAuditInstruction(charter, attempt) {
|
|
|
26
26
|
"audit_target",
|
|
27
27
|
[
|
|
28
28
|
`Requirement: ${charter.requirement}`,
|
|
29
|
+
`Original request: ${charter.thread_state?.original_request || charter.requirement}`,
|
|
29
30
|
`Acceptance criteria:\n${bullets(charter.acceptance_criteria)}`,
|
|
30
31
|
`User-visible outcome: ${charter.completion_contract?.user_outcome || charter.requirement}`,
|
|
31
32
|
`Minimum scope:\n${bullets(charter.completion_contract?.minimum_scope || charter.acceptance_criteria)}`,
|
|
@@ -43,6 +44,8 @@ export function buildIndependentAuditInstruction(charter, attempt) {
|
|
|
43
44
|
`Reported tests:\n${bullets(attempt.tests_run || [])}`,
|
|
44
45
|
`Executor uncertainty verification: ${JSON.stringify(attempt.uncertainty_verification || {})}`,
|
|
45
46
|
`Executor criterion evidence: ${JSON.stringify(attempt.criterion_evidence || [])}`,
|
|
47
|
+
`Minimum evidence matrix: ${attempt.observed_evidence?.minimum_completion?.evidence_path || "missing"}`,
|
|
48
|
+
`Minimum evidence digest: ${attempt.observed_evidence?.minimum_completion?.matrix_sha256 || "missing"}`,
|
|
46
49
|
`Executor visual review: ${JSON.stringify(attempt.visual_review || {})}`,
|
|
47
50
|
`Observed local branch: ${localEvidence.branch_name || "unknown"}`,
|
|
48
51
|
`Observed local HEAD: ${localEvidence.head_sha || "unknown"}`,
|
|
@@ -59,6 +62,7 @@ export function buildIndependentAuditInstruction(charter, attempt) {
|
|
|
59
62
|
"Inspect the final diff and related code directly from the repository.",
|
|
60
63
|
"Look for unmet acceptance criteria, regressions, security issues, data and concurrency risks, missing tests, and unrelated changes.",
|
|
61
64
|
"Treat executor claims as untrusted until supported by repository evidence.",
|
|
65
|
+
"Compare the original request to the complete AC-N list and implementation mapping. Report forgotten features, dead controls, unsaved results and broken integration. Open the raw minimum evidence, verify its oracle and actual runtime observations; matching expected/actual JSON and hashes alone do not prove truth. Check reload/session return and persisted values/ownership when saving is required.",
|
|
62
66
|
"For every AC-N criterion, return a criterion_results entry only after directly checking its cited proof and observed outcome.",
|
|
63
67
|
charter.evidence_policy?.browser_required
|
|
64
68
|
? "Open and inspect the actual local screenshots or videos. Return visual_review.inspected=true only after checking their visible content against the acceptance criteria."
|
|
@@ -70,7 +74,8 @@ export function buildIndependentAuditInstruction(charter, attempt) {
|
|
|
70
74
|
"result_contract",
|
|
71
75
|
[
|
|
72
76
|
"Finish with exactly one line beginning SIMY_AUDIT_JSON: followed by one JSON object.",
|
|
73
|
-
"Required keys: passed, summary, findings, criterion_results, visual_review.",
|
|
77
|
+
"Required keys: passed, summary, findings, criterion_results, visual_review, coverage_review.",
|
|
78
|
+
"coverage_review must contain requirements_complete (boolean), unmapped_requirements (array), and observation explaining the comparison of the original request, AC-N list, implementation and raw user/system proof. Use false and list gaps when anything is missing or unverified.",
|
|
74
79
|
"Each finding candidate must contain passed, code, severity, target, explanation, and repairability.",
|
|
75
80
|
"criterion_results must contain one entry per AC-N with criterion_id, passed, verification_method, evidence_refs, and observed_result.",
|
|
76
81
|
"visual_review must contain inspected, artifacts, and acceptance_observations.",
|
|
@@ -195,12 +200,19 @@ export function buildIndependentAudit(execution, charter = null, attempt = null)
|
|
|
195
200
|
auto_fix_hint: null,
|
|
196
201
|
});
|
|
197
202
|
}
|
|
203
|
+
const coverage = result.coverage_review;
|
|
204
|
+
if (attempt && !(coverage?.requirements_complete === true &&
|
|
205
|
+
Array.isArray(coverage.unmapped_requirements) && coverage.unmapped_requirements.length === 0 && cleanString(coverage.observation))) {
|
|
206
|
+
findings.push({ passed: false, code: "MINIMUM_REQUIREMENT_COVERAGE_UNPROVEN", severity: "blocker",
|
|
207
|
+
target: "coverage_review", explanation: "Original request coverage and raw minimum evidence were not independently verified.", repairability: "auto" });
|
|
208
|
+
}
|
|
198
209
|
const requiresHuman = findings.some((item) => item.repairability === "manual");
|
|
199
210
|
return {
|
|
200
211
|
passed: result.passed === true && findings.length === 0,
|
|
201
212
|
summary: cleanString(result.summary) || "Independent audit completed.",
|
|
202
213
|
findings,
|
|
203
214
|
criterion_results: criterionResults,
|
|
215
|
+
coverage_review: coverage || null,
|
|
204
216
|
visual_review: visualReview,
|
|
205
217
|
requires_human: requiresHuman,
|
|
206
218
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { minimumCharter } from "../codex-plugins/simy/skills/agentic-delivery-loop/scripts/check-minimum-completion.mjs";
|
|
1
2
|
import { bullets, looksLikeUiTask, section } from "./shared.js";
|
|
2
3
|
import { problemSolvingResultContract } from "./problem-solving.js";
|
|
3
4
|
import { acceptanceCriterionRows } from "./completion-contract.js";
|
|
@@ -50,6 +51,17 @@ export function buildCodingInstruction(
|
|
|
50
51
|
),
|
|
51
52
|
),
|
|
52
53
|
),
|
|
54
|
+
section("minimum_completion_evidence", [
|
|
55
|
+
"All delivery levels require this floor. Before implementation reconcile every original requirement with the AC-N list; report omissions instead of silently dropping them.",
|
|
56
|
+
"Save a matrix.json under the local evidence root (ui_evidence_root or repository .artifacts). Return delivery_evidence_path in SIMY_RESULT_JSON. A passed flag or tests_run string is not proof.",
|
|
57
|
+
"Use check-minimum-completion.mjs from the installed Delivery Loop skill. Its reference minimum-completion.md defines the matrix and structured observation format.",
|
|
58
|
+
"For each AC-N include real implementation_refs and required user/system evidence. Exercise the actual operation and result, then inspect postcondition and integration independently. For saving, reload/return in a new session and verify stored values and ownership. Document-only work uses the real command/consumer and artifact integrity, not imaginary API/DB checks.",
|
|
59
|
+
"First evidence artifact per layer must be JSON: {criterion_id, context_sha256, checks}. User checks: operation (expected=true, actual=observed boolean) and result; system checks: postcondition and integration. Each check includes subject, expected and actual. Derive expected values from the original requirement, not from the candidate output. Include screenshots/logs as additional evidence.",
|
|
60
|
+
"Freeze and bind observations to the current committed revision and charter before verification. Do not rehash old observations to make changed code pass. Use pending/failed for unavailable or mismatching evidence.",
|
|
61
|
+
`Controller input JSON: ${JSON.stringify({ id: charter.id, requirement: charter.requirement, repository: charter.repository, acceptance_criteria: charter.acceptance_criteria, completion_contract: charter.completion_contract, thread_state: { original_request: charter.thread_state.original_request } })}`,
|
|
62
|
+
`Controller-owned acceptance specification: ${JSON.stringify(minimumCharter(charter))}`,
|
|
63
|
+
`Context: run_id=${charter.id}, stage=locally_verified, environment=local, fixture_id=${charter.id}; input_sha256 is contextDigest(spec.source); source_revisions maps ${charter.repository} to current full HEAD.`,
|
|
64
|
+
].join("\n")),
|
|
53
65
|
section("expected_tests", bullets(charter.expected_tests)),
|
|
54
66
|
section("must_not", bullets(charter.must_not)),
|
|
55
67
|
];
|
|
@@ -130,7 +142,7 @@ export function buildCodingInstruction(
|
|
|
130
142
|
"result_contract",
|
|
131
143
|
[
|
|
132
144
|
"Finish with exactly one line beginning SIMY_RESULT_JSON: followed by one JSON object.",
|
|
133
|
-
"Required keys: outcome_kind, summary, branch_name, commit_sha, commit_message_headline, pr_url, pr_number, pr_title, pr_base_branch, tests_run, tests_passed, ui_evidence_path, unrelated_changes_detected, secret_scan_passed, changed_files, residual_risks, problem_solving, uncertainty_verification, criterion_evidence, visual_review.",
|
|
145
|
+
"Required keys: outcome_kind, summary, branch_name, commit_sha, commit_message_headline, pr_url, pr_number, pr_title, pr_base_branch, tests_run, tests_passed, ui_evidence_path, unrelated_changes_detected, secret_scan_passed, changed_files, residual_risks, problem_solving, uncertainty_verification, criterion_evidence, visual_review, delivery_evidence_path.",
|
|
134
146
|
problemSolvingResultContract(),
|
|
135
147
|
"uncertainty_verification must contain top_uncertainty, first_verification, evidence_checked, observed_result, resolved, and fallback. Only set resolved=true when concrete observed evidence resolves it.",
|
|
136
148
|
"criterion_evidence must contain one entry for every AC-N with criterion_id, passed, verification_method, evidence_refs, and observed_result. Only set passed=true when the evidence directly proves that criterion.",
|