@walkhi/code-relax 0.1.0-beta.50 → 0.1.0-beta.59
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 +105 -105
- package/dist/bin/cleanup-accounts.mjs +63 -63
- package/dist/bin/codex-remote.mjs +403 -403
- package/dist/bin/publish-android-release.mjs +63 -63
- package/dist/bin/release-upload-server.mjs +190 -190
- package/dist/bin/set-relay-admin-password.mjs +11 -0
- package/dist/build-platform.json +1 -1
- package/dist/get-shared-service-url.ps1 +14 -14
- package/dist/shared/code-browser.cjs +159 -159
- package/dist/shared/file-changes.cjs +31 -31
- package/dist/shared/file-citations.cjs +117 -117
- package/dist/shared/file-mentions.cjs +92 -92
- package/dist/shared/message-delivery.cjs +115 -115
- package/dist/shared/p2p-file-channel.cjs +87 -87
- package/dist/shared/release-version.cjs +31 -0
- package/dist/shared/server-requests.cjs +311 -311
- package/dist/shared/timeline-sync.cjs +173 -173
- package/dist/skills/code-relax/SKILL.md +22 -22
- package/dist/src/bounded-process-log.mjs +63 -63
- package/dist/src/celsius-client.mjs +107 -107
- package/dist/src/dots-approval-timeline.mjs +99 -99
- package/dist/src/dots-avatar.mjs +42 -42
- package/dist/src/dots-chat-projection.mjs +50 -50
- package/dist/src/dots-chat-server.mjs +483 -483
- package/dist/src/dots-message-images.mjs +319 -319
- package/dist/src/dots-profile.mjs +77 -77
- package/dist/src/dots-room-activity.mjs +172 -172
- package/dist/src/dots-room-client.mjs +137 -135
- package/dist/src/dots-room-projection.mjs +94 -94
- package/dist/src/dots-schedules.mjs +66 -66
- package/dist/src/dots-summary-tasks.mjs +32 -32
- package/dist/src/dots-task-catalog.mjs +430 -430
- package/dist/src/file-transfer-range.mjs +22 -22
- package/dist/src/file-upload-session.mjs +146 -130
- package/dist/src/hosted-history-app-server.mjs +103 -103
- package/dist/src/hosted-history-cache.mjs +113 -113
- package/dist/src/lan-socket-server.mjs +338 -266
- package/dist/src/managed-app-server.mjs +373 -373
- package/dist/src/managed-endpoint.mjs +25 -25
- package/dist/src/message-upload-files.mjs +109 -109
- package/dist/src/onboarding.mjs +12 -12
- package/dist/src/platform/README.md +1 -1
- package/dist/src/platform/codex-protocol.mjs +46 -46
- package/dist/src/platform/desktop-project-store.mjs +219 -208
- package/dist/src/platform/linux/desktop-host.mjs +87 -87
- package/dist/src/platform/linux/process.mjs +53 -53
- package/dist/src/platform/linux/system.mjs +50 -50
- package/dist/src/platform/linux/user-environment.mjs +9 -9
- package/dist/src/platform/macos/codex-runtime.mjs +125 -125
- package/dist/src/platform/macos/desktop-host.mjs +86 -86
- package/dist/src/platform/macos/user-environment.mjs +9 -9
- package/dist/src/platform/native-package.mjs +58 -58
- package/dist/src/platform/windows/activate-desktop.ps1 +49 -49
- package/dist/src/platform/windows/codex-runtime.mjs +215 -215
- package/dist/src/platform/windows/credential-store.mjs +27 -27
- package/dist/src/platform/windows/credential-store.ps1 +49 -49
- package/dist/src/platform/windows/desktop-host.mjs +65 -65
- package/dist/src/platform/windows/desktop-info.ps1 +38 -38
- package/dist/src/platform/windows/desktop-project-registration.mjs +4 -4
- package/dist/src/platform/windows/desktop-shortcuts.ps1 +14 -14
- package/dist/src/platform/windows/dots-profile.mjs +49 -49
- package/dist/src/platform/windows/launch-worker.mjs +41 -41
- package/dist/src/platform/windows/project-locations.mjs +16 -16
- package/dist/src/platform/windows/service-host.mjs +35 -35
- package/dist/src/platform/windows/user-environment.mjs +52 -52
- package/dist/src/project-management.mjs +58 -58
- package/dist/src/runtime-discovery.mjs +47 -47
- package/dist/src/self-relay/account-cleanup.mjs +30 -30
- package/dist/src/self-relay/admin-auth.mjs +59 -0
- package/dist/src/self-relay/admin-login-page.mjs +7 -0
- package/dist/src/self-relay/admin-page.mjs +87 -80
- package/dist/src/self-relay/admin-server.mjs +49 -18
- package/dist/src/self-relay/admin-style.mjs +58 -14
- package/dist/src/self-relay/admin-workstations-page.mjs +95 -0
- package/dist/src/self-relay/admin-workstations.mjs +143 -0
- package/dist/src/self-relay/connection-reporting.mjs +38 -0
- package/dist/src/self-relay/connector.mjs +444 -439
- package/dist/src/self-relay/demo.mjs +31 -31
- package/dist/src/self-relay/diagnostics.mjs +54 -54
- package/dist/src/self-relay/email-accounts.mjs +183 -183
- package/dist/src/self-relay/file-wire.mjs +13 -13
- package/dist/src/self-relay/lifecycle.mjs +11 -0
- package/dist/src/self-relay/p2p-probe.mjs +168 -168
- package/dist/src/self-relay/phone-connections.mjs +55 -0
- package/dist/src/self-relay/privacy-page.mjs +26 -26
- package/dist/src/self-relay/public-releases.mjs +67 -67
- package/dist/src/self-relay/rtc-file-bridge.mjs +46 -41
- package/dist/src/self-relay/server.mjs +63 -3
- package/dist/src/self-relay/usage-meter.mjs +141 -141
- package/dist/src/server.mjs +23 -9
- package/dist/src/service-doctor.mjs +148 -148
- package/dist/src/service-install.mjs +97 -97
- package/dist/src/service-lifecycle.mjs +139 -139
- package/dist/src/service-update.mjs +6 -3
- package/dist/src/shared-app-server.mjs +683 -666
- package/dist/src/shared-catalog.mjs +132 -132
- package/dist/src/shared-thread-stream.mjs +165 -165
- package/dist/src/usage-reset.mjs +17 -17
- package/dist/src/workstation-downloads.mjs +120 -120
- package/package.json +4 -4
- package/tools/postinstall.mjs +186 -186
|
@@ -1,63 +1,63 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { createHash } from 'node:crypto';
|
|
5
|
-
import { androidReleaseStorage } from '../relay-admin/android-release-storage.mjs';
|
|
6
|
-
import { readAndroidCatalog } from '../src/self-relay/android-releases.mjs';
|
|
7
|
-
import { readPublicManifest, updatePublicAndroid } from '../src/self-relay/public-releases.mjs';
|
|
8
|
-
|
|
9
|
-
// This administrative command runs only on the deployment host; no keys cross SSH.
|
|
10
|
-
const [apkFile, metadataFile, mode] = process.argv.slice(2);
|
|
11
|
-
if (mode && mode !== '--existing-object') throw new Error('Invalid publication mode');
|
|
12
|
-
if (!apkFile || !metadataFile) throw new Error('Usage: publish-android-release.mjs APK METADATA');
|
|
13
|
-
const storage = androidReleaseStorage('/etc/codex-relay/android-releases.json', '/etc/codex-relay/tencent-monitor.json');
|
|
14
|
-
const metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf8'));
|
|
15
|
-
if (metadata.packageName !== 'com.walkhi.codexremote' || !/^[a-f0-9]{64}$/.test(metadata.certificateSha256)) throw new Error('Invalid APK identity');
|
|
16
|
-
const { certificateSha256: expectedCertificate } = JSON.parse(fs.readFileSync('/etc/codex-relay/android-releases.json', 'utf8'));
|
|
17
|
-
if (!/^[a-f0-9]{64}$/.test(expectedCertificate) || metadata.certificateSha256 !== expectedCertificate) throw new Error('APK signing certificate does not match release configuration');
|
|
18
|
-
const bytes = fs.readFileSync(apkFile), sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
19
|
-
if (sha256 !== metadata.sha256 || bytes.length !== metadata.size) throw new Error('Uploaded APK integrity mismatch');
|
|
20
|
-
const { versionCode, versionName, size, notes, certificateSha256 } = metadata;
|
|
21
|
-
const release = { versionCode, versionName, size, sha256, notes, certificateSha256,
|
|
22
|
-
objectKey: `code-relax/android/${versionCode}/Code-Relax-${sha256.slice(0, 16)}.apk` };
|
|
23
|
-
const catalogFile = storage.catalogFile, temporary = catalogFile + '.new', lock = catalogFile + '.lock';
|
|
24
|
-
const lockHandle = fs.openSync(lock, 'wx', 0o600);
|
|
25
|
-
try {
|
|
26
|
-
readPublicManifest();
|
|
27
|
-
const catalog = fs.existsSync(catalogFile) ? readAndroidCatalog(catalogFile) : { releases: [] };
|
|
28
|
-
const previous = catalog.releases.find(item => item.versionCode === versionCode);
|
|
29
|
-
if (previous && previous.sha256 !== sha256) throw new Error('A different APK already uses this versionCode');
|
|
30
|
-
if (catalog.latestVersionCode > versionCode) throw new Error('Refusing to publish an older version as latest');
|
|
31
|
-
const next = { latestVersionCode: versionCode, releases: [...catalog.releases.filter(item => item.versionCode !== versionCode), release] };
|
|
32
|
-
fs.writeFileSync(temporary, JSON.stringify(next, null, 2) + '\n', { mode: 0o640 });
|
|
33
|
-
readAndroidCatalog(temporary);
|
|
34
|
-
if (!previous && mode !== '--existing-object') {
|
|
35
|
-
await new Promise((resolve, reject) => storage.cos.putObject({ ...storage.object(release), Body: bytes,
|
|
36
|
-
ContentLength: bytes.length, ContentType: 'application/vnd.android.package-archive',
|
|
37
|
-
ContentDisposition: `attachment; filename="Code-Relax-${versionName}.apk"`
|
|
38
|
-
}, (error, value) => error ? reject(error) : resolve(value)));
|
|
39
|
-
}
|
|
40
|
-
// Validate the exact signed GET that website and App use, before switching latest.
|
|
41
|
-
const ticket = await storage.signDownload(release);
|
|
42
|
-
const response = await fetch(ticket.url, { signal: AbortSignal.timeout(120000) });
|
|
43
|
-
if (!response.ok) throw new Error(`Signed download failed: HTTP ${response.status}`);
|
|
44
|
-
const downloaded = Buffer.from(await response.arrayBuffer());
|
|
45
|
-
if (downloaded.length !== size || createHash('sha256').update(downloaded).digest('hex') !== sha256) throw new Error('Stored APK integrity mismatch');
|
|
46
|
-
const unsigned = new URL(ticket.url); unsigned.search = '';
|
|
47
|
-
const denied = await fetch(unsigned, { method: 'HEAD', signal: AbortSignal.timeout(15000) });
|
|
48
|
-
if (denied.status !== 403) throw new Error(`Private object check failed: HTTP ${denied.status}`);
|
|
49
|
-
const owner = fs.statSync(path.dirname(catalogFile));
|
|
50
|
-
fs.chownSync(temporary, owner.uid, owner.gid);
|
|
51
|
-
fs.renameSync(temporary, catalogFile);
|
|
52
|
-
updatePublicAndroid(release);
|
|
53
|
-
console.log(JSON.stringify({ published: true, versionCode, versionName, size, sha256, unsignedStatus: denied.status,
|
|
54
|
-
entry: 'https://www.coderelax.cn/code-relax/android' }));
|
|
55
|
-
} catch (error) {
|
|
56
|
-
// COS errors can carry signed request details. Keep CLI output credential-free.
|
|
57
|
-
console.error('Android publication failed:', error?.code || (error?.name === 'Error' ? error.message : 'storage-request-failed'));
|
|
58
|
-
process.exitCode = 1;
|
|
59
|
-
} finally {
|
|
60
|
-
fs.closeSync(lockHandle);
|
|
61
|
-
fs.unlinkSync(lock);
|
|
62
|
-
if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
|
|
63
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { androidReleaseStorage } from '../relay-admin/android-release-storage.mjs';
|
|
6
|
+
import { readAndroidCatalog } from '../src/self-relay/android-releases.mjs';
|
|
7
|
+
import { readPublicManifest, updatePublicAndroid } from '../src/self-relay/public-releases.mjs';
|
|
8
|
+
|
|
9
|
+
// This administrative command runs only on the deployment host; no keys cross SSH.
|
|
10
|
+
const [apkFile, metadataFile, mode] = process.argv.slice(2);
|
|
11
|
+
if (mode && mode !== '--existing-object') throw new Error('Invalid publication mode');
|
|
12
|
+
if (!apkFile || !metadataFile) throw new Error('Usage: publish-android-release.mjs APK METADATA');
|
|
13
|
+
const storage = androidReleaseStorage('/etc/codex-relay/android-releases.json', '/etc/codex-relay/tencent-monitor.json');
|
|
14
|
+
const metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf8'));
|
|
15
|
+
if (metadata.packageName !== 'com.walkhi.codexremote' || !/^[a-f0-9]{64}$/.test(metadata.certificateSha256)) throw new Error('Invalid APK identity');
|
|
16
|
+
const { certificateSha256: expectedCertificate } = JSON.parse(fs.readFileSync('/etc/codex-relay/android-releases.json', 'utf8'));
|
|
17
|
+
if (!/^[a-f0-9]{64}$/.test(expectedCertificate) || metadata.certificateSha256 !== expectedCertificate) throw new Error('APK signing certificate does not match release configuration');
|
|
18
|
+
const bytes = fs.readFileSync(apkFile), sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
19
|
+
if (sha256 !== metadata.sha256 || bytes.length !== metadata.size) throw new Error('Uploaded APK integrity mismatch');
|
|
20
|
+
const { versionCode, versionName, size, notes, certificateSha256 } = metadata;
|
|
21
|
+
const release = { versionCode, versionName, size, sha256, notes, certificateSha256,
|
|
22
|
+
objectKey: `code-relax/android/${versionCode}/Code-Relax-${sha256.slice(0, 16)}.apk` };
|
|
23
|
+
const catalogFile = storage.catalogFile, temporary = catalogFile + '.new', lock = catalogFile + '.lock';
|
|
24
|
+
const lockHandle = fs.openSync(lock, 'wx', 0o600);
|
|
25
|
+
try {
|
|
26
|
+
readPublicManifest();
|
|
27
|
+
const catalog = fs.existsSync(catalogFile) ? readAndroidCatalog(catalogFile) : { releases: [] };
|
|
28
|
+
const previous = catalog.releases.find(item => item.versionCode === versionCode);
|
|
29
|
+
if (previous && previous.sha256 !== sha256) throw new Error('A different APK already uses this versionCode');
|
|
30
|
+
if (catalog.latestVersionCode > versionCode) throw new Error('Refusing to publish an older version as latest');
|
|
31
|
+
const next = { latestVersionCode: versionCode, releases: [...catalog.releases.filter(item => item.versionCode !== versionCode), release] };
|
|
32
|
+
fs.writeFileSync(temporary, JSON.stringify(next, null, 2) + '\n', { mode: 0o640 });
|
|
33
|
+
readAndroidCatalog(temporary);
|
|
34
|
+
if (!previous && mode !== '--existing-object') {
|
|
35
|
+
await new Promise((resolve, reject) => storage.cos.putObject({ ...storage.object(release), Body: bytes,
|
|
36
|
+
ContentLength: bytes.length, ContentType: 'application/vnd.android.package-archive',
|
|
37
|
+
ContentDisposition: `attachment; filename="Code-Relax-${versionName}.apk"`
|
|
38
|
+
}, (error, value) => error ? reject(error) : resolve(value)));
|
|
39
|
+
}
|
|
40
|
+
// Validate the exact signed GET that website and App use, before switching latest.
|
|
41
|
+
const ticket = await storage.signDownload(release);
|
|
42
|
+
const response = await fetch(ticket.url, { signal: AbortSignal.timeout(120000) });
|
|
43
|
+
if (!response.ok) throw new Error(`Signed download failed: HTTP ${response.status}`);
|
|
44
|
+
const downloaded = Buffer.from(await response.arrayBuffer());
|
|
45
|
+
if (downloaded.length !== size || createHash('sha256').update(downloaded).digest('hex') !== sha256) throw new Error('Stored APK integrity mismatch');
|
|
46
|
+
const unsigned = new URL(ticket.url); unsigned.search = '';
|
|
47
|
+
const denied = await fetch(unsigned, { method: 'HEAD', signal: AbortSignal.timeout(15000) });
|
|
48
|
+
if (denied.status !== 403) throw new Error(`Private object check failed: HTTP ${denied.status}`);
|
|
49
|
+
const owner = fs.statSync(path.dirname(catalogFile));
|
|
50
|
+
fs.chownSync(temporary, owner.uid, owner.gid);
|
|
51
|
+
fs.renameSync(temporary, catalogFile);
|
|
52
|
+
updatePublicAndroid(release);
|
|
53
|
+
console.log(JSON.stringify({ published: true, versionCode, versionName, size, sha256, unsignedStatus: denied.status,
|
|
54
|
+
entry: 'https://www.coderelax.cn/code-relax/android' }));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
// COS errors can carry signed request details. Keep CLI output credential-free.
|
|
57
|
+
console.error('Android publication failed:', error?.code || (error?.name === 'Error' ? error.message : 'storage-request-failed'));
|
|
58
|
+
process.exitCode = 1;
|
|
59
|
+
} finally {
|
|
60
|
+
fs.closeSync(lockHandle);
|
|
61
|
+
fs.unlinkSync(lock);
|
|
62
|
+
if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
|
|
63
|
+
}
|
|
@@ -1,190 +1,190 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import http from 'node:http';
|
|
6
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
-
import { pipeline } from 'node:stream/promises';
|
|
8
|
-
import { Transform, Readable } from 'node:stream';
|
|
9
|
-
import { execFile } from 'node:child_process';
|
|
10
|
-
import { promisify } from 'node:util';
|
|
11
|
-
import { fileURLToPath } from 'node:url';
|
|
12
|
-
import { verifyReleaseIdentity, createUploadSigner } from '../relay-admin/release-oidc.mjs';
|
|
13
|
-
import { androidReleaseStorage } from '../relay-admin/android-release-storage.mjs';
|
|
14
|
-
import { readAndroidCatalog } from '../src/self-relay/android-releases.mjs';
|
|
15
|
-
import { updatePublicWorkstations } from '../src/self-relay/public-releases.mjs';
|
|
16
|
-
|
|
17
|
-
import { readHarmonyConfig, validateHarmonyMetadata, checkHarmonyVersion, verifyHarmony, activateHarmony } from '../relay-admin/harmony-release.mjs';
|
|
18
|
-
|
|
19
|
-
const execute = promisify(execFile);
|
|
20
|
-
const config = JSON.parse(fs.readFileSync('/etc/codex-relay/android-releases.json', 'utf8'));
|
|
21
|
-
const credential = JSON.parse(fs.readFileSync('/etc/codex-relay/tencent-monitor.json', 'utf8'));
|
|
22
|
-
const storage = androidReleaseStorage('/etc/codex-relay/android-releases.json', '/etc/codex-relay/tencent-monitor.json');
|
|
23
|
-
const signUpload = createUploadSigner(config, { secretId: credential.secretId, secretKey: credential.secretKey });
|
|
24
|
-
const stateDir = '/var/lib/codex-relay/release-publisher';
|
|
25
|
-
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
26
|
-
const cosCall = (method, args) => new Promise((resolve, reject) => storage.cos[method](args, (e, r) => e ? reject(e) : resolve(r)));
|
|
27
|
-
const object = key => ({ Bucket: config.bucket, Region: config.region, Key: key });
|
|
28
|
-
const identityKey = p => `${p.repository_id}/${p.run_id}/${p.run_attempt}/${p.sha}`;
|
|
29
|
-
const filename = id => path.join(stateDir, id + '.json');
|
|
30
|
-
function save(row) {
|
|
31
|
-
const temp = filename(row.intentId) + '.' + randomUUID();
|
|
32
|
-
fs.writeFileSync(temp, JSON.stringify(row), { mode: 0o600 }); fs.renameSync(temp, filename(row.intentId));
|
|
33
|
-
}
|
|
34
|
-
const read = id => fs.existsSync(filename(id)) ? JSON.parse(fs.readFileSync(filename(id))) : null;
|
|
35
|
-
const publicState = row => ({ ok: true, intentId: row.intentId, status: row.status, result: row.result, error: row.error });
|
|
36
|
-
async function readJson(req) {
|
|
37
|
-
const chunks = []; let size = 0;
|
|
38
|
-
for await (const chunk of req) { size += chunk.length; if (size > 16384) throw Error('Request too large'); chunks.push(chunk); }
|
|
39
|
-
return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
40
|
-
}
|
|
41
|
-
function metadataFrom(data, claims, target) {
|
|
42
|
-
const { packageName, versionCode, versionName, size, sha256, contentMd5, certificateSha256, notes,
|
|
43
|
-
buildCommit, artifactId, runId, sourceCommit, verifyOnly } = data;
|
|
44
|
-
if (!Number.isSafeInteger(versionCode) || versionCode < 1 || versionCode > 2100000000
|
|
45
|
-
|| !/^[a-f0-9]{64}$/.test(sha256)
|
|
46
|
-
|| !Number.isSafeInteger(size) || size < 1 || size > 200 * 1024 * 1024
|
|
47
|
-
|| typeof notes !== 'string' || notes.length > 2000 || !/^[A-Za-z0-9+/]{22}==$/.test(contentMd5)
|
|
48
|
-
|| !/^\d+$/.test(artifactId) || runId !== claims.run_id || sourceCommit !== claims.sha
|
|
49
|
-
|| typeof verifyOnly !== 'boolean' || !/^[a-f0-9]{40}$/.test(buildCommit)) throw Error('Invalid metadata');
|
|
50
|
-
if (target === 'hap') validateHarmonyMetadata(data, readHarmonyConfig());
|
|
51
|
-
else if (packageName !== 'com.walkhi.codexremote' || !/^\d+\.\d+\.\d+\.\d+$/.test(versionName)
|
|
52
|
-
|| !versionName.endsWith('.' + versionCode) || certificateSha256 !== config.certificateSha256) throw Error('Invalid Android identity');
|
|
53
|
-
return { target, ...(target === 'hap' ? { files: data.files } : {}), packageName, versionCode, versionName, size, sha256, contentMd5, certificateSha256, notes,
|
|
54
|
-
buildCommit, artifactId, runId, sourceCommit, verifyOnly };
|
|
55
|
-
}
|
|
56
|
-
let busy = false;
|
|
57
|
-
const server = http.createServer(async (req, res) => {
|
|
58
|
-
const reply = (code, data) => { if (!res.destroyed) { res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(data)); } };
|
|
59
|
-
let claims;
|
|
60
|
-
const npmStatus = req.url === '/code-relax/publish/npm/status';
|
|
61
|
-
try { claims = await verifyReleaseIdentity((req.headers.authorization || '').replace(/^Bearer /, ''), npmStatus ? 'npm' : 'mobile'); }
|
|
62
|
-
catch { reply(401, { ok: false, error: 'OIDC_REJECTED' }); return; }
|
|
63
|
-
if (npmStatus) {
|
|
64
|
-
try {
|
|
65
|
-
if (req.method !== 'POST') { reply(405, { ok: false, error: 'METHOD_NOT_ALLOWED' }); return; }
|
|
66
|
-
const { version } = await readJson(req);
|
|
67
|
-
const response = await fetch('https://registry.npmjs.org/@walkhi%2fcode-relax', { signal: AbortSignal.timeout(15000) });
|
|
68
|
-
if (!response.ok) throw Error('Registry unavailable');
|
|
69
|
-
const metadata = await response.json();
|
|
70
|
-
if (metadata['dist-tags']?.latest !== version || metadata['dist-tags']?.beta !== version
|
|
71
|
-
|| !metadata.versions?.[version]) { reply(409, { ok: false, error: 'NPM_VERSION_MISMATCH' }); return; }
|
|
72
|
-
updatePublicWorkstations(version);
|
|
73
|
-
reply(200, { ok: true, version });
|
|
74
|
-
} catch { reply(500, { ok: false, error: 'NPM_STATUS_SYNC_FAILED' }); }
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
const target = /^\/code-relax\/publish\/(android|hap)\/intents(?:\/|$)/.exec(req.url || '')?.[1];
|
|
78
|
-
if (!target) { reply(404, { ok: false, error: 'NOT_FOUND' }); return; }
|
|
79
|
-
const prefix = `/code-relax/publish/${target}/intents`;
|
|
80
|
-
try {
|
|
81
|
-
if (req.method === 'POST' && req.url === prefix) {
|
|
82
|
-
let metadata;
|
|
83
|
-
try { metadata = metadataFrom(await readJson(req), claims, target); } catch { reply(400, { ok: false, error: 'INVALID_RELEASE' }); return; }
|
|
84
|
-
if (!metadata.verifyOnly && target === 'hap') {
|
|
85
|
-
try { checkHarmonyVersion(metadata); } catch { reply(409, { ok: false, error: 'VERSION_CONFLICT' }); return; }
|
|
86
|
-
}
|
|
87
|
-
if (!metadata.verifyOnly && target === 'android') {
|
|
88
|
-
const catalog = readAndroidCatalog(config.catalogFile);
|
|
89
|
-
const previous = catalog.releases.find(x => x.versionCode === metadata.versionCode);
|
|
90
|
-
if (catalog.latestVersionCode > metadata.versionCode || (previous && previous.sha256 !== metadata.sha256)) {
|
|
91
|
-
reply(409, { ok: false, error: 'VERSION_CONFLICT' }); return;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
const identity = identityKey(claims);
|
|
95
|
-
const intentId = createHash('sha256').update(JSON.stringify({ identity, metadata })).digest('hex');
|
|
96
|
-
let row = read(intentId);
|
|
97
|
-
if (row && row.status !== 'prepared') { reply(200, publicState(row)); return; }
|
|
98
|
-
const key = metadata.verifyOnly ? `code-relax/android/ci-verification/${claims.run_id}/${intentId}.apk`
|
|
99
|
-
: `code-relax/android/${metadata.versionCode}/Code-Relax-${metadata.sha256.slice(0, 16)}.apk`;
|
|
100
|
-
const keys = target === 'hap' ? metadata.files.map(f => metadata.verifyOnly
|
|
101
|
-
? `code-relax/harmony/ci-verification/${claims.run_id}/${intentId}/${f.name}`
|
|
102
|
-
: `code-relax/harmony/${metadata.versionCode}/${f.name}`) : [key];
|
|
103
|
-
row ??= { intentId, identity, metadata, key, keys, status: 'prepared' };
|
|
104
|
-
save(row);
|
|
105
|
-
const grant = target === 'hap' ? { uploads: await Promise.all(metadata.files.map(async (f, i) => ({ name: f.name,
|
|
106
|
-
...await signUpload(keys[i], f, i === 1 ? 'application/json' : i === 2 ? 'image/png' : 'application/octet-stream') }))) }
|
|
107
|
-
: await signUpload(key, metadata);
|
|
108
|
-
reply(200, { ...publicState(row), ...grant }); return;
|
|
109
|
-
}
|
|
110
|
-
const match = new RegExp(`^${prefix}/([a-f0-9]{64})(/complete)?$`).exec(req.url || '');
|
|
111
|
-
if (!match || !((!match[2] && req.method === 'GET') || (match[2] && req.method === 'POST'))) {
|
|
112
|
-
reply(404, { ok: false, error: 'NOT_FOUND' }); return;
|
|
113
|
-
}
|
|
114
|
-
const row = read(match[1]);
|
|
115
|
-
if (!row || row.identity !== identityKey(claims) || (row.metadata.target || 'android') !== target) { reply(404, { ok: false, error: 'INTENT_NOT_FOUND' }); return; }
|
|
116
|
-
if (match[2] && row.status === 'prepared') {
|
|
117
|
-
if (busy) { reply(409, { ok: false, error: 'PUBLICATION_BUSY' }); return; }
|
|
118
|
-
busy = true; row.status = 'validating'; save(row);
|
|
119
|
-
void complete(row).finally(() => { busy = false; });
|
|
120
|
-
}
|
|
121
|
-
reply(row.status === 'validating' ? 202 : 200, publicState(row));
|
|
122
|
-
} catch (error) {
|
|
123
|
-
console.error(JSON.stringify({ event: 'release-api-failed', code: /^[A-Za-z.]+$/.test(error.code || '') ? error.code : 'io-or-provider' }));
|
|
124
|
-
reply(503, { ok: false, error: 'RELEASE_UNAVAILABLE' });
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
async function complete(row) {
|
|
128
|
-
const { metadata, key } = row;
|
|
129
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'code-relax-release-'));
|
|
130
|
-
const apk = path.join(dir, 'release.apk'), json = path.join(dir, 'release.json');
|
|
131
|
-
const hap = metadata.target === 'hap';
|
|
132
|
-
const files = hap ? metadata.files : [{ ...metadata, name: 'release.apk' }];
|
|
133
|
-
let stage = 'download';
|
|
134
|
-
try {
|
|
135
|
-
for (const [i, file] of files.entries()) {
|
|
136
|
-
const ticket = await cosCall('getObjectUrl', { ...object(row.keys?.[i] || key), Sign: true, Method: 'GET', Protocol: 'https:', Expires: 300 });
|
|
137
|
-
const response = await fetch(ticket.Url, { signal: AbortSignal.timeout(120000) });
|
|
138
|
-
if (!response.ok || Number(response.headers.get('content-length')) !== file.size) throw Error();
|
|
139
|
-
let size = 0; const hash = createHash('sha256'), md5 = createHash('md5');
|
|
140
|
-
await pipeline(Readable.fromWeb(response.body), new Transform({ transform(chunk, encoding, next) {
|
|
141
|
-
size += chunk.length; if (size > file.size) return next(Error('Oversize'));
|
|
142
|
-
hash.update(chunk); md5.update(chunk); next(null, chunk);
|
|
143
|
-
} }), fs.createWriteStream(path.join(dir, file.name), { flags: 'wx', mode: 0o600 }));
|
|
144
|
-
if (size !== file.size || hash.digest('hex') !== file.sha256 || md5.digest('base64') !== file.contentMd5) throw Error();
|
|
145
|
-
const unsigned = new URL(ticket.Url); unsigned.search = '';
|
|
146
|
-
if (!hap && (await fetch(unsigned, { method: 'HEAD', signal: AbortSignal.timeout(15000) })).status !== 403) throw Error();
|
|
147
|
-
}
|
|
148
|
-
if (hap) {
|
|
149
|
-
stage = 'harmony-signature';
|
|
150
|
-
await verifyHarmony(dir, metadata, readHarmonyConfig());
|
|
151
|
-
row.result = { sha256: metadata.sha256, versionCode: metadata.versionCode, size: metadata.size };
|
|
152
|
-
if (metadata.verifyOnly) row.status = 'verified';
|
|
153
|
-
else { stage = 'harmony-publish'; row.result = await activateHarmony(metadata, storage, row.keys); row.status = 'published'; }
|
|
154
|
-
save(row); return;
|
|
155
|
-
}
|
|
156
|
-
stage = 'signature';
|
|
157
|
-
const { stdout: signature } = await execute('/usr/bin/apksigner', ['verify', '--print-certs', apk], {
|
|
158
|
-
timeout: 30000, env: { ...process.env, JAVA_TOOL_OPTIONS: '-Xmx256m' } });
|
|
159
|
-
const certificates = [...signature.matchAll(/^Signer #\d+ certificate SHA-256 digest: ([a-f0-9]{64})$/gm)];
|
|
160
|
-
if (certificates.length !== 1 || certificates[0][1] !== metadata.certificateSha256) throw Error();
|
|
161
|
-
stage = 'identity';
|
|
162
|
-
const { stdout: badging } = await execute('/usr/bin/aapt', ['dump', 'badging', apk], { timeout: 30000 });
|
|
163
|
-
const identity = /^package: name='([^']+)' versionCode='(\d+)' versionName='([^']+)'/m.exec(badging);
|
|
164
|
-
if (!identity || identity[1] !== metadata.packageName || Number(identity[2]) !== metadata.versionCode
|
|
165
|
-
|| identity[3] !== metadata.versionName || /^application-debuggable/m.test(badging)) throw Error();
|
|
166
|
-
row.result = { sha256: metadata.sha256, versionCode: metadata.versionCode, size: metadata.size };
|
|
167
|
-
if (metadata.verifyOnly) row.status = 'verified';
|
|
168
|
-
else {
|
|
169
|
-
stage = 'publish';
|
|
170
|
-
fs.writeFileSync(json, JSON.stringify(metadata), { mode: 0o600 });
|
|
171
|
-
const { stdout } = await execute(process.execPath, [fileURLToPath(new URL('./publish-android-release.mjs', import.meta.url)), apk, json, '--existing-object'], { timeout: 240000 });
|
|
172
|
-
row.result = JSON.parse(stdout.trim()); row.status = 'published';
|
|
173
|
-
}
|
|
174
|
-
save(row);
|
|
175
|
-
} catch (error) {
|
|
176
|
-
row.status = 'failed'; row.error = `RELEASE_${stage.toUpperCase()}_FAILED`; save(row);
|
|
177
|
-
console.error(JSON.stringify({ event: 'release-validation-failed', intentId: row.intentId, stage, code: /^[A-Za-z0-9_.-]+$/.test(error.code || '') ? error.code : 'validation' }));
|
|
178
|
-
} finally {
|
|
179
|
-
for (const file of [...files.map(f => path.join(dir, f.name)), json]) fs.rmSync(file, { force: true }); fs.rmdirSync(dir);
|
|
180
|
-
// Verification objects remain private evidence; the deployment identity has no DeleteObject grant.
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
// A restart never blindly launches an interrupted publisher a second time.
|
|
184
|
-
for (const name of fs.readdirSync(stateDir).filter(x => /^[a-f0-9]{64}\.json$/.test(x))) {
|
|
185
|
-
const row = read(name.slice(0, -5));
|
|
186
|
-
if (row.status === 'validating') { row.status = 'failed'; row.error = 'INTERRUPTED_CHECK_LATEST'; save(row); }
|
|
187
|
-
}
|
|
188
|
-
server.requestTimeout = 15000;
|
|
189
|
-
server.listen(45852, '127.0.0.1');
|
|
190
|
-
process.on('SIGTERM', () => { server.close(); const timer = setInterval(() => { if (!busy) { clearInterval(timer); process.exit(0); } }, 250); });
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
import { pipeline } from 'node:stream/promises';
|
|
8
|
+
import { Transform, Readable } from 'node:stream';
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { verifyReleaseIdentity, createUploadSigner } from '../relay-admin/release-oidc.mjs';
|
|
13
|
+
import { androidReleaseStorage } from '../relay-admin/android-release-storage.mjs';
|
|
14
|
+
import { readAndroidCatalog } from '../src/self-relay/android-releases.mjs';
|
|
15
|
+
import { updatePublicWorkstations } from '../src/self-relay/public-releases.mjs';
|
|
16
|
+
|
|
17
|
+
import { readHarmonyConfig, validateHarmonyMetadata, checkHarmonyVersion, verifyHarmony, activateHarmony } from '../relay-admin/harmony-release.mjs';
|
|
18
|
+
|
|
19
|
+
const execute = promisify(execFile);
|
|
20
|
+
const config = JSON.parse(fs.readFileSync('/etc/codex-relay/android-releases.json', 'utf8'));
|
|
21
|
+
const credential = JSON.parse(fs.readFileSync('/etc/codex-relay/tencent-monitor.json', 'utf8'));
|
|
22
|
+
const storage = androidReleaseStorage('/etc/codex-relay/android-releases.json', '/etc/codex-relay/tencent-monitor.json');
|
|
23
|
+
const signUpload = createUploadSigner(config, { secretId: credential.secretId, secretKey: credential.secretKey });
|
|
24
|
+
const stateDir = '/var/lib/codex-relay/release-publisher';
|
|
25
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
26
|
+
const cosCall = (method, args) => new Promise((resolve, reject) => storage.cos[method](args, (e, r) => e ? reject(e) : resolve(r)));
|
|
27
|
+
const object = key => ({ Bucket: config.bucket, Region: config.region, Key: key });
|
|
28
|
+
const identityKey = p => `${p.repository_id}/${p.run_id}/${p.run_attempt}/${p.sha}`;
|
|
29
|
+
const filename = id => path.join(stateDir, id + '.json');
|
|
30
|
+
function save(row) {
|
|
31
|
+
const temp = filename(row.intentId) + '.' + randomUUID();
|
|
32
|
+
fs.writeFileSync(temp, JSON.stringify(row), { mode: 0o600 }); fs.renameSync(temp, filename(row.intentId));
|
|
33
|
+
}
|
|
34
|
+
const read = id => fs.existsSync(filename(id)) ? JSON.parse(fs.readFileSync(filename(id))) : null;
|
|
35
|
+
const publicState = row => ({ ok: true, intentId: row.intentId, status: row.status, result: row.result, error: row.error });
|
|
36
|
+
async function readJson(req) {
|
|
37
|
+
const chunks = []; let size = 0;
|
|
38
|
+
for await (const chunk of req) { size += chunk.length; if (size > 16384) throw Error('Request too large'); chunks.push(chunk); }
|
|
39
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
40
|
+
}
|
|
41
|
+
function metadataFrom(data, claims, target) {
|
|
42
|
+
const { packageName, versionCode, versionName, size, sha256, contentMd5, certificateSha256, notes,
|
|
43
|
+
buildCommit, artifactId, runId, sourceCommit, verifyOnly } = data;
|
|
44
|
+
if (!Number.isSafeInteger(versionCode) || versionCode < 1 || versionCode > 2100000000
|
|
45
|
+
|| !/^[a-f0-9]{64}$/.test(sha256)
|
|
46
|
+
|| !Number.isSafeInteger(size) || size < 1 || size > 200 * 1024 * 1024
|
|
47
|
+
|| typeof notes !== 'string' || notes.length > 2000 || !/^[A-Za-z0-9+/]{22}==$/.test(contentMd5)
|
|
48
|
+
|| !/^\d+$/.test(artifactId) || runId !== claims.run_id || sourceCommit !== claims.sha
|
|
49
|
+
|| typeof verifyOnly !== 'boolean' || !/^[a-f0-9]{40}$/.test(buildCommit)) throw Error('Invalid metadata');
|
|
50
|
+
if (target === 'hap') validateHarmonyMetadata(data, readHarmonyConfig());
|
|
51
|
+
else if (packageName !== 'com.walkhi.codexremote' || !/^\d+\.\d+\.\d+\.\d+$/.test(versionName)
|
|
52
|
+
|| !versionName.endsWith('.' + versionCode) || certificateSha256 !== config.certificateSha256) throw Error('Invalid Android identity');
|
|
53
|
+
return { target, ...(target === 'hap' ? { files: data.files } : {}), packageName, versionCode, versionName, size, sha256, contentMd5, certificateSha256, notes,
|
|
54
|
+
buildCommit, artifactId, runId, sourceCommit, verifyOnly };
|
|
55
|
+
}
|
|
56
|
+
let busy = false;
|
|
57
|
+
const server = http.createServer(async (req, res) => {
|
|
58
|
+
const reply = (code, data) => { if (!res.destroyed) { res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(data)); } };
|
|
59
|
+
let claims;
|
|
60
|
+
const npmStatus = req.url === '/code-relax/publish/npm/status';
|
|
61
|
+
try { claims = await verifyReleaseIdentity((req.headers.authorization || '').replace(/^Bearer /, ''), npmStatus ? 'npm' : 'mobile'); }
|
|
62
|
+
catch { reply(401, { ok: false, error: 'OIDC_REJECTED' }); return; }
|
|
63
|
+
if (npmStatus) {
|
|
64
|
+
try {
|
|
65
|
+
if (req.method !== 'POST') { reply(405, { ok: false, error: 'METHOD_NOT_ALLOWED' }); return; }
|
|
66
|
+
const { version } = await readJson(req);
|
|
67
|
+
const response = await fetch('https://registry.npmjs.org/@walkhi%2fcode-relax', { signal: AbortSignal.timeout(15000) });
|
|
68
|
+
if (!response.ok) throw Error('Registry unavailable');
|
|
69
|
+
const metadata = await response.json();
|
|
70
|
+
if (metadata['dist-tags']?.latest !== version || metadata['dist-tags']?.beta !== version
|
|
71
|
+
|| !metadata.versions?.[version]) { reply(409, { ok: false, error: 'NPM_VERSION_MISMATCH' }); return; }
|
|
72
|
+
updatePublicWorkstations(version);
|
|
73
|
+
reply(200, { ok: true, version });
|
|
74
|
+
} catch { reply(500, { ok: false, error: 'NPM_STATUS_SYNC_FAILED' }); }
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const target = /^\/code-relax\/publish\/(android|hap)\/intents(?:\/|$)/.exec(req.url || '')?.[1];
|
|
78
|
+
if (!target) { reply(404, { ok: false, error: 'NOT_FOUND' }); return; }
|
|
79
|
+
const prefix = `/code-relax/publish/${target}/intents`;
|
|
80
|
+
try {
|
|
81
|
+
if (req.method === 'POST' && req.url === prefix) {
|
|
82
|
+
let metadata;
|
|
83
|
+
try { metadata = metadataFrom(await readJson(req), claims, target); } catch { reply(400, { ok: false, error: 'INVALID_RELEASE' }); return; }
|
|
84
|
+
if (!metadata.verifyOnly && target === 'hap') {
|
|
85
|
+
try { checkHarmonyVersion(metadata); } catch { reply(409, { ok: false, error: 'VERSION_CONFLICT' }); return; }
|
|
86
|
+
}
|
|
87
|
+
if (!metadata.verifyOnly && target === 'android') {
|
|
88
|
+
const catalog = readAndroidCatalog(config.catalogFile);
|
|
89
|
+
const previous = catalog.releases.find(x => x.versionCode === metadata.versionCode);
|
|
90
|
+
if (catalog.latestVersionCode > metadata.versionCode || (previous && previous.sha256 !== metadata.sha256)) {
|
|
91
|
+
reply(409, { ok: false, error: 'VERSION_CONFLICT' }); return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const identity = identityKey(claims);
|
|
95
|
+
const intentId = createHash('sha256').update(JSON.stringify({ identity, metadata })).digest('hex');
|
|
96
|
+
let row = read(intentId);
|
|
97
|
+
if (row && row.status !== 'prepared') { reply(200, publicState(row)); return; }
|
|
98
|
+
const key = metadata.verifyOnly ? `code-relax/android/ci-verification/${claims.run_id}/${intentId}.apk`
|
|
99
|
+
: `code-relax/android/${metadata.versionCode}/Code-Relax-${metadata.sha256.slice(0, 16)}.apk`;
|
|
100
|
+
const keys = target === 'hap' ? metadata.files.map(f => metadata.verifyOnly
|
|
101
|
+
? `code-relax/harmony/ci-verification/${claims.run_id}/${intentId}/${f.name}`
|
|
102
|
+
: `code-relax/harmony/${metadata.versionCode}/${f.name}`) : [key];
|
|
103
|
+
row ??= { intentId, identity, metadata, key, keys, status: 'prepared' };
|
|
104
|
+
save(row);
|
|
105
|
+
const grant = target === 'hap' ? { uploads: await Promise.all(metadata.files.map(async (f, i) => ({ name: f.name,
|
|
106
|
+
...await signUpload(keys[i], f, i === 1 ? 'application/json' : i === 2 ? 'image/png' : 'application/octet-stream') }))) }
|
|
107
|
+
: await signUpload(key, metadata);
|
|
108
|
+
reply(200, { ...publicState(row), ...grant }); return;
|
|
109
|
+
}
|
|
110
|
+
const match = new RegExp(`^${prefix}/([a-f0-9]{64})(/complete)?$`).exec(req.url || '');
|
|
111
|
+
if (!match || !((!match[2] && req.method === 'GET') || (match[2] && req.method === 'POST'))) {
|
|
112
|
+
reply(404, { ok: false, error: 'NOT_FOUND' }); return;
|
|
113
|
+
}
|
|
114
|
+
const row = read(match[1]);
|
|
115
|
+
if (!row || row.identity !== identityKey(claims) || (row.metadata.target || 'android') !== target) { reply(404, { ok: false, error: 'INTENT_NOT_FOUND' }); return; }
|
|
116
|
+
if (match[2] && row.status === 'prepared') {
|
|
117
|
+
if (busy) { reply(409, { ok: false, error: 'PUBLICATION_BUSY' }); return; }
|
|
118
|
+
busy = true; row.status = 'validating'; save(row);
|
|
119
|
+
void complete(row).finally(() => { busy = false; });
|
|
120
|
+
}
|
|
121
|
+
reply(row.status === 'validating' ? 202 : 200, publicState(row));
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error(JSON.stringify({ event: 'release-api-failed', code: /^[A-Za-z.]+$/.test(error.code || '') ? error.code : 'io-or-provider' }));
|
|
124
|
+
reply(503, { ok: false, error: 'RELEASE_UNAVAILABLE' });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
async function complete(row) {
|
|
128
|
+
const { metadata, key } = row;
|
|
129
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'code-relax-release-'));
|
|
130
|
+
const apk = path.join(dir, 'release.apk'), json = path.join(dir, 'release.json');
|
|
131
|
+
const hap = metadata.target === 'hap';
|
|
132
|
+
const files = hap ? metadata.files : [{ ...metadata, name: 'release.apk' }];
|
|
133
|
+
let stage = 'download';
|
|
134
|
+
try {
|
|
135
|
+
for (const [i, file] of files.entries()) {
|
|
136
|
+
const ticket = await cosCall('getObjectUrl', { ...object(row.keys?.[i] || key), Sign: true, Method: 'GET', Protocol: 'https:', Expires: 300 });
|
|
137
|
+
const response = await fetch(ticket.Url, { signal: AbortSignal.timeout(120000) });
|
|
138
|
+
if (!response.ok || Number(response.headers.get('content-length')) !== file.size) throw Error();
|
|
139
|
+
let size = 0; const hash = createHash('sha256'), md5 = createHash('md5');
|
|
140
|
+
await pipeline(Readable.fromWeb(response.body), new Transform({ transform(chunk, encoding, next) {
|
|
141
|
+
size += chunk.length; if (size > file.size) return next(Error('Oversize'));
|
|
142
|
+
hash.update(chunk); md5.update(chunk); next(null, chunk);
|
|
143
|
+
} }), fs.createWriteStream(path.join(dir, file.name), { flags: 'wx', mode: 0o600 }));
|
|
144
|
+
if (size !== file.size || hash.digest('hex') !== file.sha256 || md5.digest('base64') !== file.contentMd5) throw Error();
|
|
145
|
+
const unsigned = new URL(ticket.Url); unsigned.search = '';
|
|
146
|
+
if (!hap && (await fetch(unsigned, { method: 'HEAD', signal: AbortSignal.timeout(15000) })).status !== 403) throw Error();
|
|
147
|
+
}
|
|
148
|
+
if (hap) {
|
|
149
|
+
stage = 'harmony-signature';
|
|
150
|
+
await verifyHarmony(dir, metadata, readHarmonyConfig());
|
|
151
|
+
row.result = { sha256: metadata.sha256, versionCode: metadata.versionCode, size: metadata.size };
|
|
152
|
+
if (metadata.verifyOnly) row.status = 'verified';
|
|
153
|
+
else { stage = 'harmony-publish'; row.result = await activateHarmony(metadata, storage, row.keys); row.status = 'published'; }
|
|
154
|
+
save(row); return;
|
|
155
|
+
}
|
|
156
|
+
stage = 'signature';
|
|
157
|
+
const { stdout: signature } = await execute('/usr/bin/apksigner', ['verify', '--print-certs', apk], {
|
|
158
|
+
timeout: 30000, env: { ...process.env, JAVA_TOOL_OPTIONS: '-Xmx256m' } });
|
|
159
|
+
const certificates = [...signature.matchAll(/^Signer #\d+ certificate SHA-256 digest: ([a-f0-9]{64})$/gm)];
|
|
160
|
+
if (certificates.length !== 1 || certificates[0][1] !== metadata.certificateSha256) throw Error();
|
|
161
|
+
stage = 'identity';
|
|
162
|
+
const { stdout: badging } = await execute('/usr/bin/aapt', ['dump', 'badging', apk], { timeout: 30000 });
|
|
163
|
+
const identity = /^package: name='([^']+)' versionCode='(\d+)' versionName='([^']+)'/m.exec(badging);
|
|
164
|
+
if (!identity || identity[1] !== metadata.packageName || Number(identity[2]) !== metadata.versionCode
|
|
165
|
+
|| identity[3] !== metadata.versionName || /^application-debuggable/m.test(badging)) throw Error();
|
|
166
|
+
row.result = { sha256: metadata.sha256, versionCode: metadata.versionCode, size: metadata.size };
|
|
167
|
+
if (metadata.verifyOnly) row.status = 'verified';
|
|
168
|
+
else {
|
|
169
|
+
stage = 'publish';
|
|
170
|
+
fs.writeFileSync(json, JSON.stringify(metadata), { mode: 0o600 });
|
|
171
|
+
const { stdout } = await execute(process.execPath, [fileURLToPath(new URL('./publish-android-release.mjs', import.meta.url)), apk, json, '--existing-object'], { timeout: 240000 });
|
|
172
|
+
row.result = JSON.parse(stdout.trim()); row.status = 'published';
|
|
173
|
+
}
|
|
174
|
+
save(row);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
row.status = 'failed'; row.error = `RELEASE_${stage.toUpperCase()}_FAILED`; save(row);
|
|
177
|
+
console.error(JSON.stringify({ event: 'release-validation-failed', intentId: row.intentId, stage, code: /^[A-Za-z0-9_.-]+$/.test(error.code || '') ? error.code : 'validation' }));
|
|
178
|
+
} finally {
|
|
179
|
+
for (const file of [...files.map(f => path.join(dir, f.name)), json]) fs.rmSync(file, { force: true }); fs.rmdirSync(dir);
|
|
180
|
+
// Verification objects remain private evidence; the deployment identity has no DeleteObject grant.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// A restart never blindly launches an interrupted publisher a second time.
|
|
184
|
+
for (const name of fs.readdirSync(stateDir).filter(x => /^[a-f0-9]{64}\.json$/.test(x))) {
|
|
185
|
+
const row = read(name.slice(0, -5));
|
|
186
|
+
if (row.status === 'validating') { row.status = 'failed'; row.error = 'INTERRUPTED_CHECK_LATEST'; save(row); }
|
|
187
|
+
}
|
|
188
|
+
server.requestTimeout = 15000;
|
|
189
|
+
server.listen(45852, '127.0.0.1');
|
|
190
|
+
process.on('SIGTERM', () => { server.close(); const timer = setInterval(() => { if (!busy) { clearInterval(timer); process.exit(0); } }, 250); });
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { passwordRecord } from '../src/self-relay/admin-auth.mjs';
|
|
4
|
+
|
|
5
|
+
// Run as the management service user (or root and then chown root:codexrelay).
|
|
6
|
+
// The plaintext is printed once for handoff; only its salted scrypt hash is saved.
|
|
7
|
+
const file = process.argv[2] || '/etc/codex-relay/admin-password.json';
|
|
8
|
+
const password = randomBytes(18).toString('base64url');
|
|
9
|
+
const record = await passwordRecord(password);
|
|
10
|
+
fs.writeFileSync(file, JSON.stringify(record) + '\n', { mode: 0o640, flag: 'wx' });
|
|
11
|
+
console.log(password);
|
package/dist/build-platform.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"layout":"npm-platform-packages","version":"0.1.0-beta.
|
|
1
|
+
{"layout":"npm-platform-packages","version":"0.1.0-beta.59"}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
param([Parameter(Mandatory=$true)][string]$AppServerUrl)
|
|
2
|
-
$ErrorActionPreference = 'Stop'
|
|
3
|
-
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
|
4
|
-
if ($AppServerUrl -cnotmatch '^ws://127\.0\.0\.1:([1-9][0-9]{0,4})\z' -or [int]$Matches[1] -gt 65535) { throw 'Invalid managed app-server URL.' }
|
|
5
|
-
$port = [int]$Matches[1]
|
|
6
|
-
$package = Get-AppxPackage OpenAI.Codex | Select-Object -First 1
|
|
7
|
-
if (-not $package) { exit }
|
|
8
|
-
$desktop = Join-Path $package.InstallLocation 'app/ChatGPT.exe'
|
|
9
|
-
# Match an installed Desktop process connected to the selected loopback port.
|
|
10
|
-
$connections = @(Get-NetTCPConnection -RemotePort $port -State Established -ErrorAction SilentlyContinue | Where-Object {
|
|
11
|
-
$_.RemoteAddress -eq '127.0.0.1' -and
|
|
12
|
-
(Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Path -eq $desktop
|
|
13
|
-
})
|
|
14
|
-
if ($connections.Count) { $AppServerUrl }
|
|
1
|
+
param([Parameter(Mandatory=$true)][string]$AppServerUrl)
|
|
2
|
+
$ErrorActionPreference = 'Stop'
|
|
3
|
+
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
|
4
|
+
if ($AppServerUrl -cnotmatch '^ws://127\.0\.0\.1:([1-9][0-9]{0,4})\z' -or [int]$Matches[1] -gt 65535) { throw 'Invalid managed app-server URL.' }
|
|
5
|
+
$port = [int]$Matches[1]
|
|
6
|
+
$package = Get-AppxPackage OpenAI.Codex | Select-Object -First 1
|
|
7
|
+
if (-not $package) { exit }
|
|
8
|
+
$desktop = Join-Path $package.InstallLocation 'app/ChatGPT.exe'
|
|
9
|
+
# Match an installed Desktop process connected to the selected loopback port.
|
|
10
|
+
$connections = @(Get-NetTCPConnection -RemotePort $port -State Established -ErrorAction SilentlyContinue | Where-Object {
|
|
11
|
+
$_.RemoteAddress -eq '127.0.0.1' -and
|
|
12
|
+
(Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Path -eq $desktop
|
|
13
|
+
})
|
|
14
|
+
if ($connections.Count) { $AppServerUrl }
|