codex-orchestrator 2.0.5 → 2.0.6
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 +27 -0
- package/README.md +1 -0
- package/dist/src/v2/acceptance-proof.d.ts +7 -0
- package/dist/src/v2/acceptance-proof.d.ts.map +1 -1
- package/dist/src/v2/acceptance-proof.js +111 -5
- package/dist/src/v2/acceptance-proof.js.map +1 -1
- package/dist/src/v2/adapters/command.d.ts +2 -0
- package/dist/src/v2/adapters/command.d.ts.map +1 -1
- package/dist/src/v2/adapters/command.js +67 -8
- package/dist/src/v2/adapters/command.js.map +1 -1
- package/dist/src/v2/adapters/gh-issue-adapter.js +16 -5
- package/dist/src/v2/adapters/gh-issue-adapter.js.map +1 -1
- package/dist/src/v2/android-proof-runner.d.ts +93 -0
- package/dist/src/v2/android-proof-runner.d.ts.map +1 -0
- package/dist/src/v2/android-proof-runner.js +921 -0
- package/dist/src/v2/android-proof-runner.js.map +1 -0
- package/dist/src/v2/config.d.ts +13 -0
- package/dist/src/v2/config.d.ts.map +1 -1
- package/dist/src/v2/config.js +57 -1
- package/dist/src/v2/config.js.map +1 -1
- package/dist/src/v2/immutable-workflow-publisher.js +6 -1
- package/dist/src/v2/immutable-workflow-publisher.js.map +1 -1
- package/dist/src/v2/mobile-lease.d.ts +9 -0
- package/dist/src/v2/mobile-lease.d.ts.map +1 -1
- package/dist/src/v2/mobile-lease.js +27 -3
- package/dist/src/v2/mobile-lease.js.map +1 -1
- package/dist/src/v2/run-issue.d.ts +4 -0
- package/dist/src/v2/run-issue.d.ts.map +1 -1
- package/dist/src/v2/run-issue.js +156 -44
- package/dist/src/v2/run-issue.js.map +1 -1
- package/dist/src/v2/run-store.d.ts +3 -0
- package/dist/src/v2/run-store.d.ts.map +1 -1
- package/dist/src/v2/run-store.js +12 -1
- package/dist/src/v2/run-store.js.map +1 -1
- package/dist/src/v2/runtime.d.ts.map +1 -1
- package/dist/src/v2/runtime.js +63 -10
- package/dist/src/v2/runtime.js.map +1 -1
- package/docs/deep-dive.md +10 -2
- package/internal-workflow/manifest.json +1 -1
- package/internal-workflow/skills/acceptance-proof/SKILL.md +1 -1
- package/internal-workflow/skills/acceptance-proof/references/android.md +6 -6
- package/internal-workflow/skills/implementation-spec-maker/SKILL.md +10 -8
- package/internal-workflow/skills/implementation-spec-review/SKILL.md +17 -3
- package/internal-workflow/skills/implementation-spec-review/evals/evals.json +30 -0
- package/internal-workflow/skills/implementation-spec-review/references/review-loop.md +30 -6
- package/package.json +1 -1
- package/internal-workflow/skills/acceptance-proof/tools/android-lease.mjs +0 -280
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { access, lstat, mkdir, mkdtemp, open, readFile, realpath, rm } from 'node:fs/promises';
|
|
5
|
+
import { platform, tmpdir } from 'node:os';
|
|
6
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { defaultProcessExecutor } from './adapters/command.js';
|
|
8
|
+
import { writeDurableAtomicFile } from './adapters/durable-atomic-file.js';
|
|
9
|
+
import { canonicalJson, sha256 } from './containment.js';
|
|
10
|
+
import { parseAndroidLease } from './mobile-lease.js';
|
|
11
|
+
const COMMAND_TIMEOUT_MS = 2 * 60 * 1000;
|
|
12
|
+
const BUILD_TIMEOUT_MS = 30 * 60 * 1000;
|
|
13
|
+
const CAPTURE_TIMEOUT_MS = 2 * 60 * 1000;
|
|
14
|
+
const EMULATOR_STOP_ATTEMPTS = 50;
|
|
15
|
+
const SCREENSHOT_MAX_BYTES = 5 * 1024 * 1024;
|
|
16
|
+
const TEXT_CAPTURE_MAX_BYTES = 1024 * 1024;
|
|
17
|
+
const CAPTURE_STDERR_MAX_BYTES = 64 * 1024;
|
|
18
|
+
const processPreparationTokens = new Set();
|
|
19
|
+
export class RunnerAndroidProofController {
|
|
20
|
+
adbPath;
|
|
21
|
+
emulatorPath;
|
|
22
|
+
execute;
|
|
23
|
+
executeBinary;
|
|
24
|
+
startEmulator;
|
|
25
|
+
createDataDir;
|
|
26
|
+
removeDataDir;
|
|
27
|
+
wait;
|
|
28
|
+
now;
|
|
29
|
+
createToken;
|
|
30
|
+
ownerPid;
|
|
31
|
+
readProcessIdentity;
|
|
32
|
+
inspectProcessIdentity;
|
|
33
|
+
signalProcess;
|
|
34
|
+
activePreparationTokens = processPreparationTokens;
|
|
35
|
+
constructor(input) {
|
|
36
|
+
this.adbPath = resolve(input.adbPath);
|
|
37
|
+
this.emulatorPath = resolve(input.emulatorPath);
|
|
38
|
+
this.execute = input.execute ?? defaultProcessExecutor;
|
|
39
|
+
this.executeBinary = input.executeBinary ?? executeBinary;
|
|
40
|
+
this.startEmulator = input.startEmulator ?? startEmulator;
|
|
41
|
+
this.createDataDir = input.createDataDir ?? createEphemeralDataDir;
|
|
42
|
+
this.removeDataDir = input.removeDataDir ?? removeEphemeralDataDir;
|
|
43
|
+
this.wait = input.wait ?? wait;
|
|
44
|
+
this.now = input.now ?? (() => new Date());
|
|
45
|
+
this.createToken = input.createToken ?? randomUUID;
|
|
46
|
+
this.ownerPid = input.ownerPid ?? process.pid;
|
|
47
|
+
this.readProcessIdentity = input.readProcessIdentity ?? readProcessStartIdentity;
|
|
48
|
+
this.inspectProcessIdentity = input.inspectProcessIdentity
|
|
49
|
+
?? (input.readProcessIdentity
|
|
50
|
+
? async (pid) => {
|
|
51
|
+
const identity = await input.readProcessIdentity(pid);
|
|
52
|
+
return identity ? { status: 'present', identity } : { status: 'absent' };
|
|
53
|
+
}
|
|
54
|
+
: inspectProcessStartIdentity);
|
|
55
|
+
this.signalProcess = input.signalProcess ?? ((pid, signal) => process.kill(pid, signal));
|
|
56
|
+
}
|
|
57
|
+
async prepare(input) {
|
|
58
|
+
if (!/^[0-9a-f]{64}$/u.test(input.checkedChangeSha256))
|
|
59
|
+
throw new Error('Checked change digest is invalid.');
|
|
60
|
+
const worktreePath = resolve(input.worktreePath);
|
|
61
|
+
const proofRelativeRoot = `${input.artifactDir}/${input.proofId}`;
|
|
62
|
+
const proofRoot = resolve(worktreePath, proofRelativeRoot);
|
|
63
|
+
if (!proofRoot.startsWith(`${worktreePath}/`))
|
|
64
|
+
throw new Error('Android proof artifact root escapes worktree');
|
|
65
|
+
try {
|
|
66
|
+
await ensureManagedProofRoot(worktreePath, proofRoot);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
return { status: 'blocked', summary: boundedError(error), runnerPreparedArtifactPaths: [], runnerPreparedArtifactSha256: {} };
|
|
70
|
+
}
|
|
71
|
+
const receiptRelativePath = `${proofRelativeRoot}/android-runner-receipt.json`;
|
|
72
|
+
const receiptPath = join(worktreePath, receiptRelativePath);
|
|
73
|
+
const leaseRelativePath = `${proofRelativeRoot}/android-lease.json`;
|
|
74
|
+
const leaseArtifactPath = join(worktreePath, leaseRelativePath);
|
|
75
|
+
const screenshotRelativePath = `${proofRelativeRoot}/android-final.png`;
|
|
76
|
+
const hierarchyRelativePath = `${proofRelativeRoot}/android-ui.xml`;
|
|
77
|
+
const logRelativePath = `${proofRelativeRoot}/android-device-log.txt`;
|
|
78
|
+
const externalLeasePath = join(resolve(input.leaseRoot), 'android.json');
|
|
79
|
+
const preparationPath = join(resolve(input.leaseRoot), 'android.preparation.json');
|
|
80
|
+
const preparedStatePath = join(resolve(input.leaseRoot), `android.prepared.${sha256(input.proofId)}.json`);
|
|
81
|
+
const existingLease = await inspectExistingLease(externalLeasePath);
|
|
82
|
+
if (existingLease !== 'absent') {
|
|
83
|
+
if (existingLease !== 'invalid' && existingLease.proofId === input.proofId && existingLease.appPid) {
|
|
84
|
+
const runnerPreparedArtifactPaths = [
|
|
85
|
+
screenshotRelativePath, hierarchyRelativePath, logRelativePath, leaseRelativePath, receiptRelativePath,
|
|
86
|
+
];
|
|
87
|
+
try {
|
|
88
|
+
const receipt = JSON.parse(await readFile(receiptPath, 'utf8'));
|
|
89
|
+
if (receipt.status !== 'prepared' || receipt.proofId !== input.proofId
|
|
90
|
+
|| receipt.checkedChangeSha256 !== input.checkedChangeSha256) {
|
|
91
|
+
throw new Error('Persisted Android proof receipt does not match the checked change.');
|
|
92
|
+
}
|
|
93
|
+
const runnerPreparedArtifactSha256 = Object.fromEntries(await Promise.all(runnerPreparedArtifactPaths.map(async (path) => [
|
|
94
|
+
path, sha256(await readFile(join(worktreePath, path))),
|
|
95
|
+
])));
|
|
96
|
+
const trusted = JSON.parse(await readFile(preparedStatePath, 'utf8'));
|
|
97
|
+
if (trusted.schema !== 'codex-orchestrator.android-prepared' || trusted.version !== 1
|
|
98
|
+
|| trusted.proofId !== input.proofId || trusted.leaseToken !== existingLease.token
|
|
99
|
+
|| trusted.checkedChangeSha256 !== input.checkedChangeSha256
|
|
100
|
+
|| canonicalJson(trusted.artifactSha256) !== canonicalJson(runnerPreparedArtifactSha256)) {
|
|
101
|
+
throw new Error('Persisted Android proof artifacts changed after capture.');
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
status: 'prepared', serial: existingLease.serial, appPid: existingLease.appPid,
|
|
105
|
+
runnerPreparedArtifactPaths, runnerPreparedArtifactSha256,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Fall through to a warning; incomplete persisted proof must not be claimed as successful.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const summary = existingLease === 'invalid'
|
|
113
|
+
? 'Existing durable Android lease is invalid and requires operator recovery.'
|
|
114
|
+
: existingLease.proofId === input.proofId
|
|
115
|
+
? 'Existing durable Android lease requires terminal recovery before preparation.'
|
|
116
|
+
: 'Existing durable Android lease belongs to another proof.';
|
|
117
|
+
await ensureManagedProofRoot(worktreePath, proofRoot);
|
|
118
|
+
await writeDurableAtomicFile(receiptPath, `${canonicalJson({
|
|
119
|
+
schema: 'codex-orchestrator.runner-android-proof', version: 1, status: 'blocked', proofId: input.proofId,
|
|
120
|
+
configuredCheckIds: input.checks.map((check) => check.id), summary, artifactRefs: [],
|
|
121
|
+
recordedAt: this.now().toISOString(),
|
|
122
|
+
})}\n`, 0o600);
|
|
123
|
+
return {
|
|
124
|
+
status: 'blocked', summary, runnerPreparedArtifactPaths: [receiptRelativePath],
|
|
125
|
+
runnerPreparedArtifactSha256: { [receiptRelativePath]: sha256(await readFile(receiptPath)) },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
let serial;
|
|
129
|
+
let dataDir;
|
|
130
|
+
let emulatorStarted = false;
|
|
131
|
+
let emulatorPid;
|
|
132
|
+
let emulatorProcessIdentity;
|
|
133
|
+
let appPid;
|
|
134
|
+
let activeLease;
|
|
135
|
+
let installSnapshotPath;
|
|
136
|
+
let preparation;
|
|
137
|
+
try {
|
|
138
|
+
await access(this.adbPath, constants.X_OK);
|
|
139
|
+
await access(this.emulatorPath, constants.X_OK);
|
|
140
|
+
const avds = lines(await this.requireSuccess(this.emulatorPath, ['-list-avds'], undefined, COMMAND_TIMEOUT_MS, input.signal));
|
|
141
|
+
if (!avds.includes(input.config.avdName))
|
|
142
|
+
throw new Error('Configured Android virtual device is unavailable.');
|
|
143
|
+
const observed = parseDevices((await this.requireSuccess(this.adbPath, ['devices', '-l'], undefined, COMMAND_TIMEOUT_MS, input.signal)).stdout);
|
|
144
|
+
serial = chooseSerial(observed.map((device) => device.serial));
|
|
145
|
+
const port = Number(serial.slice('emulator-'.length));
|
|
146
|
+
dataDir = resolve(await this.createDataDir());
|
|
147
|
+
const ownerProcessIdentity = await readProcessStartIdentity(this.ownerPid);
|
|
148
|
+
if (!ownerProcessIdentity)
|
|
149
|
+
throw new Error('Android proof Runner process identity is unavailable.');
|
|
150
|
+
preparation = {
|
|
151
|
+
schema: 'codex-orchestrator.android-preparation', version: 1, proofId: input.proofId,
|
|
152
|
+
token: this.createToken(), ownerPid: this.ownerPid, ownerProcessIdentity, serial, dataDir,
|
|
153
|
+
emulatorPid: null, emulatorProcessIdentity: null,
|
|
154
|
+
};
|
|
155
|
+
await this.acquirePreparation(preparationPath, preparation);
|
|
156
|
+
const emulator = await this.startEmulator(this.emulatorPath, [
|
|
157
|
+
'-avd', input.config.avdName, '-port', String(port), '-datadir', dataDir, '-wipe-data',
|
|
158
|
+
'-no-snapshot-load', '-no-snapshot-save',
|
|
159
|
+
'-no-boot-anim', '-no-audio', '-no-window',
|
|
160
|
+
], { cwd: worktreePath });
|
|
161
|
+
emulatorStarted = true;
|
|
162
|
+
emulatorPid = emulator.pid;
|
|
163
|
+
emulatorProcessIdentity = await this.readProcessIdentity(emulator.pid);
|
|
164
|
+
if (!emulatorProcessIdentity)
|
|
165
|
+
throw new Error('Runner-created Android emulator process identity is unavailable.');
|
|
166
|
+
preparation = { ...preparation, emulatorPid: emulator.pid, emulatorProcessIdentity };
|
|
167
|
+
await replaceOwnedPreparation(preparationPath, preparation);
|
|
168
|
+
await this.waitForBoot(serial, emulator.pid, emulatorProcessIdentity, input.config.bootTimeoutMs, input.signal);
|
|
169
|
+
const acquiredAt = this.now();
|
|
170
|
+
activeLease = {
|
|
171
|
+
schema: 'codex-orchestrator.android-lease', version: 1, status: 'active', proofId: input.proofId,
|
|
172
|
+
token: preparation.token, serial, appId: input.config.applicationId, ownerPid: this.ownerPid, appPid: null,
|
|
173
|
+
runnerCreated: true, emulatorPid: emulator.pid, emulatorProcessIdentity, dataDir,
|
|
174
|
+
acquiredAt: acquiredAt.toISOString(), expiresAt: new Date(acquiredAt.getTime() + proofLeaseDurationMs(input.config, input.proofAgentBudgetMs)).toISOString(),
|
|
175
|
+
updatedAt: acquiredAt.toISOString(),
|
|
176
|
+
};
|
|
177
|
+
await ensureManagedProofRoot(worktreePath, proofRoot);
|
|
178
|
+
await writeDurableAtomicFile(leaseArtifactPath, `${canonicalJson(activeLease)}\n`, 0o600);
|
|
179
|
+
await mkdir(resolve(input.leaseRoot), { recursive: true, mode: 0o700 });
|
|
180
|
+
await createExclusiveLease(externalLeasePath, activeLease);
|
|
181
|
+
await removeOwnedPreparation(preparationPath, preparation.token);
|
|
182
|
+
this.activePreparationTokens.delete(preparation.token);
|
|
183
|
+
preparation = undefined;
|
|
184
|
+
const apkPath = resolve(worktreePath, input.config.apkPath);
|
|
185
|
+
if (!apkPath.startsWith(`${worktreePath}/`))
|
|
186
|
+
throw new Error('Configured Android APK escapes worktree.');
|
|
187
|
+
await removeExistingApkSafely(worktreePath, apkPath);
|
|
188
|
+
const build = await this.requireSuccess(input.config.flutterCommand, input.config.buildArgs, worktreePath, BUILD_TIMEOUT_MS, input.signal, true);
|
|
189
|
+
await assertNoSymlinkAncestors(worktreePath, dirname(apkPath));
|
|
190
|
+
installSnapshotPath = join(resolve(input.leaseRoot), `android-install-${activeLease.token}.apk`);
|
|
191
|
+
const apkSha256 = await snapshotFreshRegularFile(apkPath, installSnapshotPath);
|
|
192
|
+
await this.assertOwnedEmulator(emulator.pid, emulatorProcessIdentity);
|
|
193
|
+
await this.requireSuccess(this.adbPath, ['-s', serial, 'install', '-r', installSnapshotPath], undefined, COMMAND_TIMEOUT_MS, input.signal);
|
|
194
|
+
await rm(installSnapshotPath, { force: true });
|
|
195
|
+
installSnapshotPath = undefined;
|
|
196
|
+
await this.assertOwnedEmulator(emulator.pid, emulatorProcessIdentity);
|
|
197
|
+
if (input.config.launchUri) {
|
|
198
|
+
await this.requireSuccess(this.adbPath, [
|
|
199
|
+
'-s', serial, 'shell', 'am', 'start', '-W', '-a', 'android.intent.action.VIEW',
|
|
200
|
+
'-d', input.config.launchUri, '-p', input.config.applicationId,
|
|
201
|
+
], undefined, COMMAND_TIMEOUT_MS, input.signal);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
await this.requireSuccess(this.adbPath, [
|
|
205
|
+
'-s', serial, 'shell', 'monkey', '-p', input.config.applicationId,
|
|
206
|
+
'-c', 'android.intent.category.LAUNCHER', '1',
|
|
207
|
+
], undefined, COMMAND_TIMEOUT_MS, input.signal);
|
|
208
|
+
}
|
|
209
|
+
await this.wait(input.config.settleMs, input.signal);
|
|
210
|
+
appPid = await this.resolveAppPid(serial, input.config.applicationId, input.signal);
|
|
211
|
+
activeLease = { ...activeLease, appPid, updatedAt: this.now().toISOString() };
|
|
212
|
+
await ensureManagedProofRoot(worktreePath, proofRoot);
|
|
213
|
+
await writeDurableAtomicFile(leaseArtifactPath, `${canonicalJson(activeLease)}\n`, 0o600);
|
|
214
|
+
await replaceOwnedLease(externalLeasePath, activeLease);
|
|
215
|
+
for (const text of input.config.tapText ?? []) {
|
|
216
|
+
await this.assertOwnedEmulator(emulator.pid, emulatorProcessIdentity);
|
|
217
|
+
const point = await this.waitForNavigationTarget({
|
|
218
|
+
serial, proofId: input.proofId, text, timeoutMs: input.config.navigationTimeoutMs, signal: input.signal,
|
|
219
|
+
});
|
|
220
|
+
await this.requireSuccess(this.adbPath, ['-s', serial, 'shell', 'input', 'tap', String(point.x), String(point.y)], undefined, COMMAND_TIMEOUT_MS, input.signal);
|
|
221
|
+
await this.wait(input.config.settleMs, input.signal);
|
|
222
|
+
if (await this.resolveAppPid(serial, input.config.applicationId, input.signal) !== appPid) {
|
|
223
|
+
throw new Error('Android proof application PID changed during navigation.');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
await this.assertOwnedEmulator(emulator.pid, emulatorProcessIdentity);
|
|
227
|
+
await this.captureEvidence({ serial, proofId: input.proofId, appPid, proofRoot, worktreePath, signal: input.signal });
|
|
228
|
+
const receipt = {
|
|
229
|
+
schema: 'codex-orchestrator.runner-android-proof', version: 1, status: 'prepared', proofId: input.proofId,
|
|
230
|
+
configuredCheckIds: input.checks.map((check) => check.id), buildOutputSha256: sha256(build.stdout + build.stderr),
|
|
231
|
+
checkedChangeSha256: input.checkedChangeSha256, apkSha256,
|
|
232
|
+
artifactRefs: [screenshotRelativePath, hierarchyRelativePath, logRelativePath, leaseRelativePath],
|
|
233
|
+
navigation: { launchUriConfigured: !!input.config.launchUri, tapText: input.config.tapText ?? [] },
|
|
234
|
+
capturedAt: this.now().toISOString(),
|
|
235
|
+
};
|
|
236
|
+
await ensureManagedProofRoot(worktreePath, proofRoot);
|
|
237
|
+
await writeDurableAtomicFile(receiptPath, `${canonicalJson(receipt)}\n`, 0o600);
|
|
238
|
+
validatePreparedReceipt(await readFile(receiptPath), receipt);
|
|
239
|
+
const runnerPreparedArtifactPaths = [
|
|
240
|
+
screenshotRelativePath, hierarchyRelativePath, logRelativePath, leaseRelativePath, receiptRelativePath,
|
|
241
|
+
];
|
|
242
|
+
const runnerPreparedArtifactSha256 = Object.fromEntries(await Promise.all(runnerPreparedArtifactPaths.map(async (path) => [
|
|
243
|
+
path, sha256(await readFile(join(worktreePath, path))),
|
|
244
|
+
])));
|
|
245
|
+
await writeDurableAtomicFile(preparedStatePath, `${canonicalJson({
|
|
246
|
+
schema: 'codex-orchestrator.android-prepared', version: 1, proofId: input.proofId,
|
|
247
|
+
leaseToken: activeLease.token, checkedChangeSha256: input.checkedChangeSha256,
|
|
248
|
+
artifactSha256: runnerPreparedArtifactSha256,
|
|
249
|
+
})}\n`, 0o600);
|
|
250
|
+
return {
|
|
251
|
+
status: 'prepared', serial, appPid,
|
|
252
|
+
runnerPreparedArtifactPaths,
|
|
253
|
+
runnerPreparedArtifactSha256,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
if (installSnapshotPath)
|
|
258
|
+
await rm(installSnapshotPath, { force: true }).catch(() => { });
|
|
259
|
+
const capturedArtifactRefs = [];
|
|
260
|
+
if (serial && emulatorStarted && appPid) {
|
|
261
|
+
try {
|
|
262
|
+
await this.captureEvidence({ serial, proofId: input.proofId, appPid, proofRoot, worktreePath, signal: input.signal });
|
|
263
|
+
capturedArtifactRefs.push(screenshotRelativePath, hierarchyRelativePath, logRelativePath);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// Preserve the original blocker when best-effort diagnostic capture is unavailable.
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
let released = false;
|
|
270
|
+
if (activeLease) {
|
|
271
|
+
try {
|
|
272
|
+
await this.release(activeLease);
|
|
273
|
+
await removeOwnedLease(externalLeasePath, activeLease.token);
|
|
274
|
+
released = true;
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
// Keep the durable active lease and data directory for safe replay recovery.
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
else if (serial && emulatorStarted && emulatorPid && emulatorProcessIdentity) {
|
|
281
|
+
try {
|
|
282
|
+
await this.stopOwnedEmulator(emulatorPid, emulatorProcessIdentity);
|
|
283
|
+
if (dataDir)
|
|
284
|
+
await this.removeDataDir(dataDir);
|
|
285
|
+
released = true;
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
// The process could not be proven stopped; retain its data directory.
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (activeLease && released) {
|
|
292
|
+
try {
|
|
293
|
+
await ensureManagedProofRoot(worktreePath, proofRoot);
|
|
294
|
+
await writeDurableAtomicFile(leaseArtifactPath, `${canonicalJson({
|
|
295
|
+
...activeLease, status: 'released', updatedAt: this.now().toISOString(),
|
|
296
|
+
})}\n`, 0o600);
|
|
297
|
+
capturedArtifactRefs.push(leaseRelativePath);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// Never write through an unsafe proof root during blocker handling.
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (preparation && released) {
|
|
304
|
+
await removeOwnedPreparation(preparationPath, preparation.token).catch(() => { });
|
|
305
|
+
this.activePreparationTokens.delete(preparation.token);
|
|
306
|
+
}
|
|
307
|
+
const summary = boundedError(error);
|
|
308
|
+
let receiptWritten = false;
|
|
309
|
+
try {
|
|
310
|
+
await ensureManagedProofRoot(worktreePath, proofRoot);
|
|
311
|
+
await writeDurableAtomicFile(receiptPath, `${canonicalJson({
|
|
312
|
+
schema: 'codex-orchestrator.runner-android-proof', version: 1, status: 'blocked', proofId: input.proofId,
|
|
313
|
+
configuredCheckIds: input.checks.map((check) => check.id), summary,
|
|
314
|
+
artifactRefs: capturedArtifactRefs,
|
|
315
|
+
recordedAt: this.now().toISOString(),
|
|
316
|
+
})}\n`, 0o600);
|
|
317
|
+
receiptWritten = true;
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
// An unsafe proof root is itself the blocker; do not mutate it.
|
|
321
|
+
}
|
|
322
|
+
const runnerPreparedArtifactPaths = [...new Set([...capturedArtifactRefs, ...(receiptWritten ? [receiptRelativePath] : [])])];
|
|
323
|
+
return {
|
|
324
|
+
status: 'blocked', summary,
|
|
325
|
+
runnerPreparedArtifactPaths,
|
|
326
|
+
runnerPreparedArtifactSha256: Object.fromEntries(await Promise.all(runnerPreparedArtifactPaths.map(async (path) => [
|
|
327
|
+
path, sha256(await readFile(join(worktreePath, path))),
|
|
328
|
+
]))),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async release(record) {
|
|
333
|
+
if (record.runnerCreated !== true || typeof record.emulatorPid !== 'number'
|
|
334
|
+
|| !Number.isSafeInteger(record.emulatorPid) || !record.emulatorProcessIdentity)
|
|
335
|
+
return;
|
|
336
|
+
await this.stopOwnedEmulator(record.emulatorPid, record.emulatorProcessIdentity);
|
|
337
|
+
if (record.dataDir)
|
|
338
|
+
await this.removeDataDir(record.dataDir);
|
|
339
|
+
}
|
|
340
|
+
async acquirePreparation(path, intent) {
|
|
341
|
+
const existing = await readPreparation(path);
|
|
342
|
+
if (existing) {
|
|
343
|
+
if (this.activePreparationTokens.has(existing.token))
|
|
344
|
+
throw new Error('Another Android proof preparation is active.');
|
|
345
|
+
const owner = await this.inspectProcessIdentity(existing.ownerPid);
|
|
346
|
+
if (owner.status === 'error')
|
|
347
|
+
throw new Error('Existing Android proof preparation owner cannot be inspected.');
|
|
348
|
+
if (owner.status === 'present' && owner.identity === existing.ownerProcessIdentity) {
|
|
349
|
+
throw new Error('Another Android proof preparation is active.');
|
|
350
|
+
}
|
|
351
|
+
if (existing.emulatorPid && existing.emulatorProcessIdentity) {
|
|
352
|
+
await this.stopOwnedEmulator(existing.emulatorPid, existing.emulatorProcessIdentity);
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
throw new Error('Existing Android proof preparation requires operator recovery before reuse.');
|
|
356
|
+
}
|
|
357
|
+
await this.removeDataDir(existing.dataDir);
|
|
358
|
+
await removeOwnedPreparation(path, existing.token);
|
|
359
|
+
}
|
|
360
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
361
|
+
const handle = await open(path, 'wx', 0o600);
|
|
362
|
+
try {
|
|
363
|
+
await handle.writeFile(`${canonicalJson(intent)}\n`);
|
|
364
|
+
await handle.sync();
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
await handle.close();
|
|
368
|
+
}
|
|
369
|
+
await syncDirectory(dirname(path));
|
|
370
|
+
this.activePreparationTokens.add(intent.token);
|
|
371
|
+
}
|
|
372
|
+
async stopOwnedEmulator(emulatorPid, expectedIdentity) {
|
|
373
|
+
const initial = await this.inspectProcessIdentity(emulatorPid);
|
|
374
|
+
if (initial.status === 'error')
|
|
375
|
+
throw new Error('Runner-created Android emulator process identity could not be inspected.');
|
|
376
|
+
if (initial.status === 'absent' || initial.identity !== expectedIdentity)
|
|
377
|
+
return;
|
|
378
|
+
this.signalProcess(emulatorPid, 'SIGTERM');
|
|
379
|
+
for (let attempt = 0; attempt < EMULATOR_STOP_ATTEMPTS / 2; attempt += 1) {
|
|
380
|
+
const inspection = await this.inspectProcessIdentity(emulatorPid);
|
|
381
|
+
if (inspection.status === 'error')
|
|
382
|
+
throw new Error('Runner-created Android emulator process identity could not be inspected.');
|
|
383
|
+
if (inspection.status === 'absent' || inspection.identity !== expectedIdentity)
|
|
384
|
+
return;
|
|
385
|
+
await this.wait(200);
|
|
386
|
+
}
|
|
387
|
+
const beforeKill = await this.inspectProcessIdentity(emulatorPid);
|
|
388
|
+
if (beforeKill.status === 'error')
|
|
389
|
+
throw new Error('Runner-created Android emulator process identity could not be inspected.');
|
|
390
|
+
if (beforeKill.status === 'absent' || beforeKill.identity !== expectedIdentity)
|
|
391
|
+
return;
|
|
392
|
+
this.signalProcess(emulatorPid, 'SIGKILL');
|
|
393
|
+
for (let attempt = 0; attempt < EMULATOR_STOP_ATTEMPTS / 2; attempt += 1) {
|
|
394
|
+
const inspection = await this.inspectProcessIdentity(emulatorPid);
|
|
395
|
+
if (inspection.status === 'error')
|
|
396
|
+
throw new Error('Runner-created Android emulator process identity could not be inspected.');
|
|
397
|
+
if (inspection.status === 'absent' || inspection.identity !== expectedIdentity)
|
|
398
|
+
return;
|
|
399
|
+
await this.wait(200);
|
|
400
|
+
}
|
|
401
|
+
throw new Error('Runner-created Android emulator process remained active after stop.');
|
|
402
|
+
}
|
|
403
|
+
async assertOwnedEmulator(emulatorPid, expectedIdentity) {
|
|
404
|
+
const inspection = await this.inspectProcessIdentity(emulatorPid);
|
|
405
|
+
if (inspection.status !== 'present' || inspection.identity !== expectedIdentity) {
|
|
406
|
+
throw new Error('Runner-created Android emulator process ownership was lost.');
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async waitForBoot(serial, emulatorPid, emulatorProcessIdentity, timeoutMs, signal) {
|
|
410
|
+
const deadline = Date.now() + timeoutMs;
|
|
411
|
+
while (Date.now() <= deadline) {
|
|
412
|
+
if (signal.aborted)
|
|
413
|
+
throw new Error('Android proof preparation was cancelled.');
|
|
414
|
+
await this.assertOwnedEmulator(emulatorPid, emulatorProcessIdentity);
|
|
415
|
+
const devices = await this.execute(this.adbPath, ['devices', '-l'], { timeoutMs: COMMAND_TIMEOUT_MS, signal });
|
|
416
|
+
const target = parseDevices(devices.stdout).find((device) => device.serial === serial && device.state === 'device');
|
|
417
|
+
if (target) {
|
|
418
|
+
const boot = await this.execute(this.adbPath, ['-s', serial, 'shell', 'getprop', 'sys.boot_completed'], { timeoutMs: COMMAND_TIMEOUT_MS, signal });
|
|
419
|
+
if (boot.exitCode === 0 && boot.stdout.trim() === '1')
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
await this.wait(1_000, signal);
|
|
423
|
+
}
|
|
424
|
+
throw new Error('Runner-created Android emulator did not finish booting.');
|
|
425
|
+
}
|
|
426
|
+
async readHierarchy(serial, proofId, signal) {
|
|
427
|
+
void proofId;
|
|
428
|
+
const output = await this.executeBinary(this.adbPath, ['-s', serial, 'exec-out', 'uiautomator', 'dump', '/dev/tty'], {
|
|
429
|
+
timeoutMs: CAPTURE_TIMEOUT_MS, signal,
|
|
430
|
+
maxStdoutBytes: TEXT_CAPTURE_MAX_BYTES, maxStderrBytes: CAPTURE_STDERR_MAX_BYTES,
|
|
431
|
+
});
|
|
432
|
+
const text = output.toString('utf8');
|
|
433
|
+
const start = text.indexOf('<?xml');
|
|
434
|
+
const fallbackStart = text.indexOf('<hierarchy');
|
|
435
|
+
const hierarchyStart = start >= 0 ? start : fallbackStart;
|
|
436
|
+
const end = text.lastIndexOf('</hierarchy>');
|
|
437
|
+
if (hierarchyStart < 0 || end < hierarchyStart)
|
|
438
|
+
throw new Error('Android UI hierarchy capture is incomplete.');
|
|
439
|
+
return Buffer.from(`${text.slice(hierarchyStart, end + '</hierarchy>'.length)}\n`);
|
|
440
|
+
}
|
|
441
|
+
async readHierarchyWithIncompleteRetry(serial, proofId, signal) {
|
|
442
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
443
|
+
try {
|
|
444
|
+
return await this.readHierarchy(serial, proofId, signal);
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
if (signal?.aborted || !(error instanceof Error)
|
|
448
|
+
|| error.message !== 'Android UI hierarchy capture is incomplete.' || attempt === 3)
|
|
449
|
+
throw error;
|
|
450
|
+
await this.wait(200, signal);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
throw new Error('Android UI hierarchy capture is incomplete.');
|
|
454
|
+
}
|
|
455
|
+
async resolveAppPid(serial, applicationId, signal) {
|
|
456
|
+
const text = (await this.requireSuccess(this.adbPath, ['-s', serial, 'shell', 'pidof', '-s', applicationId], undefined, COMMAND_TIMEOUT_MS, signal)).stdout.trim();
|
|
457
|
+
const appPid = Number(text);
|
|
458
|
+
if (!Number.isSafeInteger(appPid) || appPid < 1)
|
|
459
|
+
throw new Error('Android proof application PID is unavailable.');
|
|
460
|
+
return appPid;
|
|
461
|
+
}
|
|
462
|
+
async waitForNavigationTarget(input) {
|
|
463
|
+
const deadline = Date.now() + input.timeoutMs;
|
|
464
|
+
while (Date.now() <= deadline) {
|
|
465
|
+
if (input.signal.aborted)
|
|
466
|
+
throw new Error('Android proof preparation was cancelled.');
|
|
467
|
+
try {
|
|
468
|
+
const hierarchy = await this.readHierarchy(input.serial, input.proofId, input.signal);
|
|
469
|
+
const point = findNodeCenter(hierarchy.toString('utf8'), input.text);
|
|
470
|
+
if (point)
|
|
471
|
+
return point;
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
if (input.signal.aborted)
|
|
475
|
+
throw error;
|
|
476
|
+
if (!(error instanceof Error) || error.message !== 'Android UI hierarchy capture is incomplete.')
|
|
477
|
+
throw error;
|
|
478
|
+
// uiautomator can transiently emit a partial dump while the Flutter view changes; retry within the same bound.
|
|
479
|
+
}
|
|
480
|
+
await this.wait(Math.min(1_000, Math.max(1, deadline - Date.now())), input.signal);
|
|
481
|
+
}
|
|
482
|
+
throw new Error(`Android proof navigation target is unavailable: ${input.text}`);
|
|
483
|
+
}
|
|
484
|
+
async captureEvidence(input) {
|
|
485
|
+
const screenshot = await this.executeBinary(this.adbPath, ['-s', input.serial, 'exec-out', 'screencap', '-p'], {
|
|
486
|
+
timeoutMs: CAPTURE_TIMEOUT_MS, signal: input.signal,
|
|
487
|
+
maxStdoutBytes: SCREENSHOT_MAX_BYTES, maxStderrBytes: CAPTURE_STDERR_MAX_BYTES,
|
|
488
|
+
});
|
|
489
|
+
const hierarchy = await this.readHierarchyWithIncompleteRetry(input.serial, input.proofId, input.signal);
|
|
490
|
+
const deviceLog = await this.executeBinary(this.adbPath, [
|
|
491
|
+
'-s', input.serial, 'logcat', '--pid', String(input.appPid), '-d', '-v', 'threadtime',
|
|
492
|
+
], {
|
|
493
|
+
timeoutMs: CAPTURE_TIMEOUT_MS, signal: input.signal,
|
|
494
|
+
maxStdoutBytes: TEXT_CAPTURE_MAX_BYTES, maxStderrBytes: CAPTURE_STDERR_MAX_BYTES,
|
|
495
|
+
});
|
|
496
|
+
await ensureManagedProofRoot(input.worktreePath, input.proofRoot);
|
|
497
|
+
await writeDurableAtomicFile(join(input.proofRoot, 'android-final.png'), screenshot, 0o600);
|
|
498
|
+
await writeDurableAtomicFile(join(input.proofRoot, 'android-ui.xml'), hierarchy, 0o600);
|
|
499
|
+
await writeDurableAtomicFile(join(input.proofRoot, 'android-device-log.txt'), deviceLog, 0o600);
|
|
500
|
+
}
|
|
501
|
+
async requireSuccess(file, args, cwd, timeoutMs = COMMAND_TIMEOUT_MS, signal, processGroup = false) {
|
|
502
|
+
const result = await this.execute(file, args, { ...(cwd ? { cwd } : {}), timeoutMs, signal, processGroup });
|
|
503
|
+
if (result.exitCode !== 0) {
|
|
504
|
+
const diagnostic = safeCommandDiagnostic(`${result.stderr}\n${result.stdout}`);
|
|
505
|
+
throw new Error(`Android proof command ${basename(file)} failed with exit code ${result.exitCode}${diagnostic ? `: ${diagnostic}` : '.'}`);
|
|
506
|
+
}
|
|
507
|
+
return result;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
function parseDevices(output) {
|
|
511
|
+
return output.split(/\r?\n/u).slice(1).map((line) => line.trim()).filter(Boolean).map((line) => {
|
|
512
|
+
const [serial = '', state = ''] = line.split(/\s+/u);
|
|
513
|
+
return { serial, state };
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
function chooseSerial(observed) {
|
|
517
|
+
const used = new Set(observed.filter((serial) => /^emulator-[0-9]+$/u.test(serial)));
|
|
518
|
+
for (let port = 5554; port <= 5682; port += 2) {
|
|
519
|
+
const serial = `emulator-${port}`;
|
|
520
|
+
if (!used.has(serial))
|
|
521
|
+
return serial;
|
|
522
|
+
}
|
|
523
|
+
throw new Error('No isolated Android emulator port is available.');
|
|
524
|
+
}
|
|
525
|
+
function lines(result) {
|
|
526
|
+
if (result.exitCode !== 0)
|
|
527
|
+
throw new Error('Android virtual-device discovery failed.');
|
|
528
|
+
return result.stdout.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean);
|
|
529
|
+
}
|
|
530
|
+
async function createExclusiveLease(path, lease) {
|
|
531
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
532
|
+
const handle = await open(path, 'wx', 0o600);
|
|
533
|
+
try {
|
|
534
|
+
await handle.writeFile(`${canonicalJson(lease)}\n`);
|
|
535
|
+
await handle.sync();
|
|
536
|
+
}
|
|
537
|
+
finally {
|
|
538
|
+
await handle.close();
|
|
539
|
+
}
|
|
540
|
+
await syncDirectory(dirname(path));
|
|
541
|
+
}
|
|
542
|
+
async function replaceOwnedLease(path, lease) {
|
|
543
|
+
const current = JSON.parse(await readFile(path, 'utf8'));
|
|
544
|
+
if (current.token !== lease.token)
|
|
545
|
+
throw new Error('Android lease ownership changed before update.');
|
|
546
|
+
await writeDurableAtomicFile(path, `${canonicalJson(lease)}\n`, 0o600);
|
|
547
|
+
}
|
|
548
|
+
async function removeOwnedLease(path, token) {
|
|
549
|
+
let current;
|
|
550
|
+
try {
|
|
551
|
+
current = JSON.parse(await readFile(path, 'utf8'));
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
if (isMissing(error))
|
|
555
|
+
return;
|
|
556
|
+
throw error;
|
|
557
|
+
}
|
|
558
|
+
if (current.token !== token)
|
|
559
|
+
return;
|
|
560
|
+
await rm(path);
|
|
561
|
+
await syncDirectory(dirname(path));
|
|
562
|
+
}
|
|
563
|
+
async function readPreparation(path) {
|
|
564
|
+
let value;
|
|
565
|
+
try {
|
|
566
|
+
value = JSON.parse(await readFile(path, 'utf8'));
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
if (isMissing(error))
|
|
570
|
+
return undefined;
|
|
571
|
+
throw new Error('Existing Android proof preparation is invalid.');
|
|
572
|
+
}
|
|
573
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
574
|
+
throw new Error('Existing Android proof preparation is invalid.');
|
|
575
|
+
const record = value;
|
|
576
|
+
const expected = [
|
|
577
|
+
'schema', 'version', 'proofId', 'token', 'ownerPid', 'ownerProcessIdentity', 'serial', 'dataDir',
|
|
578
|
+
'emulatorPid', 'emulatorProcessIdentity',
|
|
579
|
+
].sort();
|
|
580
|
+
const actual = Object.keys(record).sort();
|
|
581
|
+
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])
|
|
582
|
+
|| record.schema !== 'codex-orchestrator.android-preparation' || record.version !== 1
|
|
583
|
+
|| typeof record.proofId !== 'string' || typeof record.token !== 'string'
|
|
584
|
+
|| !Number.isSafeInteger(record.ownerPid) || record.ownerPid < 1
|
|
585
|
+
|| typeof record.ownerProcessIdentity !== 'string' || typeof record.serial !== 'string'
|
|
586
|
+
|| typeof record.dataDir !== 'string'
|
|
587
|
+
|| (record.emulatorPid !== null && (!Number.isSafeInteger(record.emulatorPid) || record.emulatorPid < 1))
|
|
588
|
+
|| (record.emulatorProcessIdentity !== null && typeof record.emulatorProcessIdentity !== 'string')) {
|
|
589
|
+
throw new Error('Existing Android proof preparation is invalid.');
|
|
590
|
+
}
|
|
591
|
+
return record;
|
|
592
|
+
}
|
|
593
|
+
async function replaceOwnedPreparation(path, intent) {
|
|
594
|
+
const current = await readPreparation(path);
|
|
595
|
+
if (!current || current.token !== intent.token)
|
|
596
|
+
throw new Error('Android proof preparation ownership changed.');
|
|
597
|
+
await writeDurableAtomicFile(path, `${canonicalJson(intent)}\n`, 0o600);
|
|
598
|
+
}
|
|
599
|
+
async function removeOwnedPreparation(path, token) {
|
|
600
|
+
const current = await readPreparation(path);
|
|
601
|
+
if (!current || current.token !== token)
|
|
602
|
+
return;
|
|
603
|
+
await rm(path);
|
|
604
|
+
await syncDirectory(dirname(path));
|
|
605
|
+
}
|
|
606
|
+
async function inspectExistingLease(path) {
|
|
607
|
+
let bytes;
|
|
608
|
+
try {
|
|
609
|
+
bytes = await readFile(path);
|
|
610
|
+
}
|
|
611
|
+
catch (error) {
|
|
612
|
+
if (isMissing(error))
|
|
613
|
+
return 'absent';
|
|
614
|
+
throw error;
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
return parseAndroidLease(bytes);
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
return 'invalid';
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
function findNodeCenter(xml, expectedText) {
|
|
624
|
+
for (const match of xml.matchAll(/<node\b[^>]*>/gu)) {
|
|
625
|
+
const node = match[0];
|
|
626
|
+
const text = /\btext="([^"]*)"/u.exec(node)?.[1];
|
|
627
|
+
const description = /\bcontent-desc="([^"]*)"/u.exec(node)?.[1];
|
|
628
|
+
if (![text, description].some((value) => value !== undefined && decodeXmlAttribute(value) === expectedText))
|
|
629
|
+
continue;
|
|
630
|
+
const bounds = /\bbounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/u.exec(node);
|
|
631
|
+
if (!bounds)
|
|
632
|
+
continue;
|
|
633
|
+
const coordinates = bounds.slice(1).map(Number);
|
|
634
|
+
if (coordinates.some((value) => !Number.isSafeInteger(value) || value < 0))
|
|
635
|
+
continue;
|
|
636
|
+
const [left, top, right, bottom] = coordinates;
|
|
637
|
+
if (right <= left || bottom <= top)
|
|
638
|
+
continue;
|
|
639
|
+
return { x: Math.floor((left + right) / 2), y: Math.floor((top + bottom) / 2) };
|
|
640
|
+
}
|
|
641
|
+
return undefined;
|
|
642
|
+
}
|
|
643
|
+
function decodeXmlAttribute(value) {
|
|
644
|
+
return value
|
|
645
|
+
.replace(/&#(\d+);/gu, (_match, decimal) => String.fromCodePoint(Number(decimal)))
|
|
646
|
+
.replace(/&#x([0-9A-F]+);/giu, (_match, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
|
|
647
|
+
.replace(/"/gu, '"')
|
|
648
|
+
.replace(/'/gu, "'")
|
|
649
|
+
.replace(/</gu, '<')
|
|
650
|
+
.replace(/>/gu, '>')
|
|
651
|
+
.replace(/&/gu, '&');
|
|
652
|
+
}
|
|
653
|
+
function isMissing(error) {
|
|
654
|
+
return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT';
|
|
655
|
+
}
|
|
656
|
+
function safeCommandDiagnostic(value) {
|
|
657
|
+
return value
|
|
658
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/gu, '')
|
|
659
|
+
.replace(/(["']?(?:api[_-]?key|access[_-]?token|refresh[_-]?token|password|secret|token)["']?\s*[:=]\s*)[^\s,;]+/giu, '$1[REDACTED]')
|
|
660
|
+
.replace(/\/(?:Users|home)\/[^\s:]+/gu, '[local-path]')
|
|
661
|
+
.trim()
|
|
662
|
+
.slice(0, 2_048);
|
|
663
|
+
}
|
|
664
|
+
function executeBinary(file, args, options) {
|
|
665
|
+
return new Promise((resolveRun, rejectRun) => {
|
|
666
|
+
const child = spawn(file, args, { cwd: options?.cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
667
|
+
const stdout = [];
|
|
668
|
+
const stderr = [];
|
|
669
|
+
let settled = false;
|
|
670
|
+
let stdoutBytes = 0;
|
|
671
|
+
let stderrBytes = 0;
|
|
672
|
+
let overflow;
|
|
673
|
+
const terminate = () => {
|
|
674
|
+
child.kill('SIGTERM');
|
|
675
|
+
setTimeout(() => { if (!settled)
|
|
676
|
+
child.kill('SIGKILL'); }, 2_000).unref();
|
|
677
|
+
};
|
|
678
|
+
const timeout = options?.timeoutMs ? setTimeout(terminate, options.timeoutMs) : undefined;
|
|
679
|
+
timeout?.unref();
|
|
680
|
+
options?.signal?.addEventListener('abort', terminate, { once: true });
|
|
681
|
+
child.stdout.on('data', (chunk) => {
|
|
682
|
+
stdoutBytes += chunk.length;
|
|
683
|
+
if (options?.maxStdoutBytes && stdoutBytes > options.maxStdoutBytes) {
|
|
684
|
+
overflow = 'stdout';
|
|
685
|
+
terminate();
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
stdout.push(chunk);
|
|
689
|
+
});
|
|
690
|
+
child.stderr.on('data', (chunk) => {
|
|
691
|
+
stderrBytes += chunk.length;
|
|
692
|
+
if (options?.maxStderrBytes && stderrBytes > options.maxStderrBytes) {
|
|
693
|
+
overflow = 'stderr';
|
|
694
|
+
terminate();
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
stderr.push(chunk);
|
|
698
|
+
});
|
|
699
|
+
child.once('error', (error) => { settled = true; if (timeout)
|
|
700
|
+
clearTimeout(timeout); rejectRun(error); });
|
|
701
|
+
child.once('close', (code) => {
|
|
702
|
+
settled = true;
|
|
703
|
+
if (timeout)
|
|
704
|
+
clearTimeout(timeout);
|
|
705
|
+
if (overflow)
|
|
706
|
+
rejectRun(new Error(`Android binary command ${overflow} exceeded its byte limit.`));
|
|
707
|
+
else if (code === 0)
|
|
708
|
+
resolveRun(Buffer.concat(stdout));
|
|
709
|
+
else
|
|
710
|
+
rejectRun(new Error(`Android binary command failed: ${safeCommandDiagnostic(Buffer.concat(stderr).toString('utf8'))}`));
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
function startEmulator(file, args, options) {
|
|
715
|
+
return new Promise((resolveRun, rejectRun) => {
|
|
716
|
+
const child = spawn(file, args, { cwd: options?.cwd, env: process.env, detached: false, stdio: 'ignore' });
|
|
717
|
+
child.once('error', rejectRun);
|
|
718
|
+
child.once('spawn', () => child.pid ? resolveRun({ pid: child.pid }) : rejectRun(new Error('Android emulator PID is unavailable.')));
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
function wait(milliseconds, signal) {
|
|
722
|
+
return new Promise((resolveWait, rejectWait) => {
|
|
723
|
+
if (signal?.aborted)
|
|
724
|
+
return rejectWait(new Error('Android proof preparation was cancelled.'));
|
|
725
|
+
const timeout = setTimeout(resolveWait, milliseconds);
|
|
726
|
+
signal?.addEventListener('abort', () => { clearTimeout(timeout); rejectWait(new Error('Android proof preparation was cancelled.')); }, { once: true });
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
function createEphemeralDataDir() {
|
|
730
|
+
return mkdtemp(join(tmpdir(), 'codex-orchestrator-android-'));
|
|
731
|
+
}
|
|
732
|
+
async function removeEphemeralDataDir(path) {
|
|
733
|
+
const canonical = resolve(path);
|
|
734
|
+
if (dirname(canonical) !== resolve(tmpdir()) || !basename(canonical).startsWith('codex-orchestrator-android-')) {
|
|
735
|
+
throw new Error('Android proof data directory is outside the Runner-owned temporary root.');
|
|
736
|
+
}
|
|
737
|
+
let metadata;
|
|
738
|
+
try {
|
|
739
|
+
metadata = await lstat(canonical);
|
|
740
|
+
}
|
|
741
|
+
catch (error) {
|
|
742
|
+
if (isMissing(error))
|
|
743
|
+
return;
|
|
744
|
+
throw error;
|
|
745
|
+
}
|
|
746
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink())
|
|
747
|
+
throw new Error('Android proof data directory is unsafe.');
|
|
748
|
+
await rm(canonical, { recursive: true });
|
|
749
|
+
}
|
|
750
|
+
function boundedError(error) {
|
|
751
|
+
return safeCommandDiagnostic(error instanceof Error ? error.message : 'Android proof preparation failed.')
|
|
752
|
+
.replace(/[\r\n\0]+/gu, ' ')
|
|
753
|
+
.slice(0, 4 * 1024);
|
|
754
|
+
}
|
|
755
|
+
async function readProcessStartIdentity(pid) {
|
|
756
|
+
const result = await inspectProcessStartIdentity(pid);
|
|
757
|
+
return result.status === 'present' ? result.identity : undefined;
|
|
758
|
+
}
|
|
759
|
+
async function inspectProcessStartIdentity(pid) {
|
|
760
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
761
|
+
return { status: 'error' };
|
|
762
|
+
if (platform() === 'linux') {
|
|
763
|
+
try {
|
|
764
|
+
const text = await readFile(`/proc/${pid}/stat`, 'utf8');
|
|
765
|
+
const close = text.lastIndexOf(') ');
|
|
766
|
+
const fields = close < 0 ? [] : text.slice(close + 2).trim().split(/\s+/u);
|
|
767
|
+
return fields[19] ? { status: 'present', identity: `linux:${fields[19]}` } : { status: 'error' };
|
|
768
|
+
}
|
|
769
|
+
catch (error) {
|
|
770
|
+
return isMissing(error) ? { status: 'absent' } : { status: 'error' };
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return await new Promise((resolveIdentity) => {
|
|
774
|
+
execFile('/bin/ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8' }, (error, stdout) => {
|
|
775
|
+
if (error) {
|
|
776
|
+
const code = error.code;
|
|
777
|
+
resolveIdentity(code === 1 || code === '1'
|
|
778
|
+
? { status: 'absent' }
|
|
779
|
+
: { status: 'error' });
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
const value = stdout.trim().replace(/\s+/gu, ' ');
|
|
783
|
+
resolveIdentity(value ? { status: 'present', identity: `${platform()}:${value}` } : { status: 'error' });
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
async function snapshotFreshRegularFile(path, snapshotPath) {
|
|
788
|
+
let handle;
|
|
789
|
+
try {
|
|
790
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
791
|
+
}
|
|
792
|
+
catch (error) {
|
|
793
|
+
if (isMissing(error))
|
|
794
|
+
throw new Error('Configured Android APK was not produced.');
|
|
795
|
+
throw error;
|
|
796
|
+
}
|
|
797
|
+
try {
|
|
798
|
+
const metadata = await handle.stat();
|
|
799
|
+
if (!metadata.isFile() || metadata.size < 1)
|
|
800
|
+
throw new Error('Configured Android APK was not produced as a regular file.');
|
|
801
|
+
await mkdir(dirname(snapshotPath), { recursive: true, mode: 0o700 });
|
|
802
|
+
const snapshot = await open(snapshotPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o400);
|
|
803
|
+
const digest = createHash('sha256');
|
|
804
|
+
const chunk = Buffer.allocUnsafe(1024 * 1024);
|
|
805
|
+
let position = 0;
|
|
806
|
+
try {
|
|
807
|
+
while (position < metadata.size) {
|
|
808
|
+
const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, metadata.size - position), position);
|
|
809
|
+
if (bytesRead === 0)
|
|
810
|
+
throw new Error('Configured Android APK changed while snapshotting.');
|
|
811
|
+
const bytes = chunk.subarray(0, bytesRead);
|
|
812
|
+
digest.update(bytes);
|
|
813
|
+
await snapshot.write(bytes);
|
|
814
|
+
position += bytesRead;
|
|
815
|
+
}
|
|
816
|
+
await snapshot.sync();
|
|
817
|
+
}
|
|
818
|
+
finally {
|
|
819
|
+
await snapshot.close();
|
|
820
|
+
}
|
|
821
|
+
const after = await handle.stat();
|
|
822
|
+
if (after.size !== metadata.size || after.mtimeMs !== metadata.mtimeMs) {
|
|
823
|
+
await rm(snapshotPath, { force: true });
|
|
824
|
+
throw new Error('Configured Android APK changed while snapshotting.');
|
|
825
|
+
}
|
|
826
|
+
return digest.digest('hex');
|
|
827
|
+
}
|
|
828
|
+
catch (error) {
|
|
829
|
+
await rm(snapshotPath, { force: true }).catch(() => { });
|
|
830
|
+
throw error;
|
|
831
|
+
}
|
|
832
|
+
finally {
|
|
833
|
+
await handle?.close();
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
function proofLeaseDurationMs(config, proofAgentBudgetMs) {
|
|
837
|
+
if (!Number.isSafeInteger(proofAgentBudgetMs) || proofAgentBudgetMs < 1)
|
|
838
|
+
throw new Error('Android proof agent budget is invalid.');
|
|
839
|
+
const navigation = (config.tapText?.length ?? 0) * (config.navigationTimeoutMs + config.settleMs);
|
|
840
|
+
return BUILD_TIMEOUT_MS + config.bootTimeoutMs + navigation + proofAgentBudgetMs + 10 * 60 * 1000;
|
|
841
|
+
}
|
|
842
|
+
async function removeExistingApkSafely(worktreePath, apkPath) {
|
|
843
|
+
try {
|
|
844
|
+
await lstat(apkPath);
|
|
845
|
+
}
|
|
846
|
+
catch (error) {
|
|
847
|
+
if (isMissing(error))
|
|
848
|
+
return;
|
|
849
|
+
throw error;
|
|
850
|
+
}
|
|
851
|
+
await assertNoSymlinkAncestors(worktreePath, dirname(apkPath));
|
|
852
|
+
await rm(apkPath, { force: true });
|
|
853
|
+
}
|
|
854
|
+
async function assertNoSymlinkAncestors(worktreePath, parentPath) {
|
|
855
|
+
const relative = parentPath.slice(worktreePath.length + 1);
|
|
856
|
+
if (!relative || parentPath === worktreePath)
|
|
857
|
+
return;
|
|
858
|
+
let current = worktreePath;
|
|
859
|
+
for (const segment of relative.split('/')) {
|
|
860
|
+
current = join(current, segment);
|
|
861
|
+
let metadata;
|
|
862
|
+
try {
|
|
863
|
+
metadata = await lstat(current);
|
|
864
|
+
}
|
|
865
|
+
catch (error) {
|
|
866
|
+
if (isMissing(error))
|
|
867
|
+
return;
|
|
868
|
+
throw error;
|
|
869
|
+
}
|
|
870
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory())
|
|
871
|
+
throw new Error('Configured Android APK parent path is unsafe.');
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
async function ensureManagedProofRoot(worktreePath, proofRoot) {
|
|
875
|
+
if (proofRoot === worktreePath || !proofRoot.startsWith(`${worktreePath}/`)) {
|
|
876
|
+
throw new Error('Android proof artifact root escapes worktree.');
|
|
877
|
+
}
|
|
878
|
+
await assertNoSymlinkDirectoryPath(worktreePath, proofRoot);
|
|
879
|
+
await mkdir(proofRoot, { recursive: true, mode: 0o700 });
|
|
880
|
+
await assertNoSymlinkDirectoryPath(worktreePath, proofRoot);
|
|
881
|
+
const [realWorktree, realProofRoot] = await Promise.all([realpath(worktreePath), realpath(proofRoot)]);
|
|
882
|
+
if (realProofRoot === realWorktree || !realProofRoot.startsWith(`${realWorktree}/`)) {
|
|
883
|
+
throw new Error('Android proof artifact root resolves outside worktree.');
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
async function assertNoSymlinkDirectoryPath(worktreePath, targetPath) {
|
|
887
|
+
const relative = targetPath.slice(worktreePath.length + 1);
|
|
888
|
+
let current = worktreePath;
|
|
889
|
+
for (const segment of relative.split('/')) {
|
|
890
|
+
current = join(current, segment);
|
|
891
|
+
let metadata;
|
|
892
|
+
try {
|
|
893
|
+
metadata = await lstat(current);
|
|
894
|
+
}
|
|
895
|
+
catch (error) {
|
|
896
|
+
if (isMissing(error))
|
|
897
|
+
return;
|
|
898
|
+
throw error;
|
|
899
|
+
}
|
|
900
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
|
901
|
+
throw new Error('Android proof artifact directory is unsafe.');
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
async function syncDirectory(path) {
|
|
906
|
+
const handle = await open(path, 'r');
|
|
907
|
+
try {
|
|
908
|
+
await handle.sync();
|
|
909
|
+
}
|
|
910
|
+
finally {
|
|
911
|
+
await handle.close();
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
function validatePreparedReceipt(bytes, expected) {
|
|
915
|
+
if (bytes.length === 0 || bytes.length > 64 * 1024)
|
|
916
|
+
throw new Error('Android Runner receipt bytes are invalid.');
|
|
917
|
+
const parsed = JSON.parse(bytes.toString('utf8'));
|
|
918
|
+
if (canonicalJson(parsed) !== canonicalJson(expected))
|
|
919
|
+
throw new Error('Android Runner receipt validation failed.');
|
|
920
|
+
}
|
|
921
|
+
//# sourceMappingURL=android-proof-runner.js.map
|