u-chan-secrets 5.0.0 → 5.1.0
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 +233 -91
- package/dist/cli.js +115 -19
- package/dist/client/claude-setup.js +97 -0
- package/dist/client/codex-setup.js +6 -5
- package/dist/client/discord.js +67 -0
- package/dist/client/github.js +28 -0
- package/dist/client/setup.js +40 -6
- package/dist/client/update-package.js +166 -0
- package/dist/client/update.js +138 -0
- package/dist/client/windows-command.js +54 -0
- package/dist/core/browser.js +0 -5
- package/dist/core/config.js +1 -0
- package/dist/core/doctor.js +5 -1
- package/dist/core/vault.js +110 -5
- package/dist/discord/http.js +84 -0
- package/dist/discord/policy.js +47 -0
- package/dist/discord/service.js +349 -0
- package/dist/discord/store.js +85 -0
- package/dist/github/http.js +93 -0
- package/dist/github/policy.js +24 -0
- package/dist/github/service.js +185 -0
- package/dist/github/store.js +54 -0
- package/dist/index.js +1 -1
- package/dist/ipc/client.js +74 -6
- package/dist/ipc/server.js +38 -3
- package/dist/mcp/server.js +25 -2
- package/dist/platform/windows/assets/prompt-src.js +160 -38
- package/dist/platform/windows/doctor.js +2 -2
- package/dist/platform/windows/install.js +4 -0
- package/dist/service/control.js +47 -5
- package/dist/service/doctor.js +3 -0
- package/dist/service/main.js +34 -17
- package/dist/service/manage.js +20 -16
- package/dist/service/state.js +28 -0
- package/dist/service/update/coordinator.js +171 -0
- package/dist/service/update/files.js +47 -0
- package/dist/service/update/gate.js +21 -0
- package/dist/service/update/journal.js +157 -0
- package/dist/service/update/native.js +50 -0
- package/dist/service/update/network.js +28 -0
- package/dist/service/update/protocol.js +28 -0
- package/dist/service/update/release.js +102 -0
- package/dist/service/update/worker-main.js +116 -0
- package/dist/service/update/worker.js +58 -0
- package/package.json +1 -1
- package/scripts/debug-install.bat +69 -69
- package/scripts/desktop-browser.ps1 +110 -103
- package/scripts/login-bw.bat +21 -21
- package/scripts/migrate.bat +4 -4
- package/scripts/start-browser.ps1 +33 -33
- package/scripts/windows-security/desktop-matrix.mjs +54 -54
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { mkdir, writeFile, lstat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { atomicJson } from './journal.js';
|
|
4
|
+
import { assertCompatibleState } from '../state.js';
|
|
5
|
+
import { artifactIntegrity, compatibleRelease, inspectRelease } from './release.js';
|
|
6
|
+
import { installedRelease, copyPrivate, writeRelease } from './files.js';
|
|
7
|
+
import { identitySchema, planDecision, publicJob, sameIdentity, terminal } from './protocol.js';
|
|
8
|
+
const answer = (value) => ({ ok: true, message: JSON.stringify(value) });
|
|
9
|
+
const failure = (code) => ({ ok: false, message: code });
|
|
10
|
+
export class UpdateCoordinator {
|
|
11
|
+
d;
|
|
12
|
+
staging = Promise.resolve();
|
|
13
|
+
beginChain = Promise.resolve();
|
|
14
|
+
pending;
|
|
15
|
+
constructor(d) {
|
|
16
|
+
this.d = d;
|
|
17
|
+
}
|
|
18
|
+
async check() {
|
|
19
|
+
const active = await this.d.journal.latest();
|
|
20
|
+
if (active && (!terminal(active.phase) || active.phase === 'recovery_required'))
|
|
21
|
+
return { service: this.d.boot, target: active.target, decision: planDecision(this.d.boot, active.target), compatible: active.phase !== 'recovery_required', reason: active.phase === 'recovery_required' ? 'recovery_required' : null, platform: this.d.platform === 'win32' ? 'win32' : 'unsupported', coordinator: 1, activeJob: publicJob(active) };
|
|
22
|
+
const latest = await this.d.verifier.checkLatest(this.d.boot.version);
|
|
23
|
+
const verified = await this.d.verifier.verify(latest.latestVersion);
|
|
24
|
+
if (!verified.ok || !verified.tarball)
|
|
25
|
+
throw new Error('update_verification_failed');
|
|
26
|
+
const target = identitySchema.parse({ version: latest.latestVersion, integrity: artifactIntegrity(verified.tarball) });
|
|
27
|
+
const decision = planDecision(this.d.boot, target);
|
|
28
|
+
let reason = null;
|
|
29
|
+
if (this.d.platform !== 'win32')
|
|
30
|
+
reason = 'unsupported_platform';
|
|
31
|
+
else if (decision === 'update_required') {
|
|
32
|
+
await this.d.verifyActive();
|
|
33
|
+
await assertCompatibleState(this.d.home, Number(target.version.split('.')[0]));
|
|
34
|
+
const manifest = inspectRelease(verified.tarball);
|
|
35
|
+
if (manifest.version !== target.version)
|
|
36
|
+
throw new Error('update_target_mismatch');
|
|
37
|
+
reason = compatibleRelease(manifest, await installedRelease(join(this.d.home, 'program')), process.versions.node);
|
|
38
|
+
}
|
|
39
|
+
else if (decision !== 'up_to_date')
|
|
40
|
+
reason = decision;
|
|
41
|
+
return { service: this.d.boot, target, decision, compatible: reason === null, reason, platform: this.d.platform === 'win32' ? 'win32' : 'unsupported', coordinator: 1 };
|
|
42
|
+
}
|
|
43
|
+
async planUpdate() { try {
|
|
44
|
+
return answer(await this.check());
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return failure('update_check_failed; no components changed');
|
|
48
|
+
} }
|
|
49
|
+
async beginUpdate(target) {
|
|
50
|
+
const work = async () => {
|
|
51
|
+
try {
|
|
52
|
+
if (this.d.platform !== 'win32')
|
|
53
|
+
return failure('coordinated updates require Windows');
|
|
54
|
+
identitySchema.parse(target);
|
|
55
|
+
const existing = await this.d.journal.latest();
|
|
56
|
+
if (existing?.phase === 'recovery_required')
|
|
57
|
+
return failure('recovery_required; restore the retained program before starting another update');
|
|
58
|
+
if (existing && !terminal(existing.phase))
|
|
59
|
+
return sameIdentity(existing.target, target) ? answer(publicJob(existing)) : failure('update_busy');
|
|
60
|
+
const plan = await this.check();
|
|
61
|
+
if (!sameIdentity(plan.target, target))
|
|
62
|
+
return failure('update_target_changed; run update --check again');
|
|
63
|
+
if (!plan.compatible || plan.decision !== 'update_required')
|
|
64
|
+
return failure(plan.reason ?? plan.decision);
|
|
65
|
+
const job = await this.d.journal.create(this.d.boot, target, process.pid);
|
|
66
|
+
this.staging = this.stage(job).catch(async () => { await this.d.journal.transition(job.id, 'failed', 'staging_failed').catch(() => { }); });
|
|
67
|
+
return answer(publicJob(job));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return failure('update_prepare_failed; active installation unchanged');
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const result = this.beginChain.then(work, work);
|
|
74
|
+
this.beginChain = result.catch(() => { });
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
async stage(job) {
|
|
78
|
+
await this.d.journal.transition(job.id, 'verifying');
|
|
79
|
+
const verified = await this.d.verifier.verify(job.target.version);
|
|
80
|
+
if (!verified.ok || !verified.tarball || artifactIntegrity(verified.tarball) !== job.target.integrity)
|
|
81
|
+
throw new Error('verification_failed');
|
|
82
|
+
const manifest = inspectRelease(verified.tarball);
|
|
83
|
+
if (manifest.version !== job.target.version)
|
|
84
|
+
throw new Error('target_mismatch');
|
|
85
|
+
await this.d.verifyActive();
|
|
86
|
+
await assertCompatibleState(this.d.home, Number(job.target.version.split('.')[0]));
|
|
87
|
+
const program = join(this.d.home, 'program');
|
|
88
|
+
if (compatibleRelease(manifest, await installedRelease(program), process.versions.node))
|
|
89
|
+
throw new Error('incompatible');
|
|
90
|
+
await this.d.journal.transition(job.id, 'staging');
|
|
91
|
+
const dir = this.d.journal.directory(job.id), next = join(dir, 'next'), runtime = join(dir, 'runtime');
|
|
92
|
+
await writeFile(join(dir, 'release.tgz'), verified.tarball, { flag: 'wx', mode: 0o600 });
|
|
93
|
+
await writeRelease(next, manifest);
|
|
94
|
+
await copyPrivate(join(program, 'node_modules'), join(next, 'node_modules'));
|
|
95
|
+
await atomicJson(join(next, 'release.json'), job.target);
|
|
96
|
+
await mkdir(runtime, { mode: 0o700 });
|
|
97
|
+
await this.d.secure(runtime);
|
|
98
|
+
await copyPrivate(program, runtime);
|
|
99
|
+
if (!(await lstat(join(runtime, 'dist/service/update/worker-main.js'))).isFile())
|
|
100
|
+
throw new Error('worker_bootstrap_missing');
|
|
101
|
+
await this.d.verifyActive(next);
|
|
102
|
+
await this.d.verifyActive(runtime);
|
|
103
|
+
await this.d.audit('staged ' + job.id);
|
|
104
|
+
await this.d.journal.transition(job.id, 'ready_to_apply');
|
|
105
|
+
}
|
|
106
|
+
async waitForStage() { await this.staging; }
|
|
107
|
+
async updateStatus(id) { try {
|
|
108
|
+
await this.refreshGate();
|
|
109
|
+
return answer(publicJob(await this.d.journal.read(id)));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return failure('update_status_unavailable');
|
|
113
|
+
} }
|
|
114
|
+
async activateUpdate(id) {
|
|
115
|
+
try {
|
|
116
|
+
const job = await this.d.journal.read(id);
|
|
117
|
+
if (job.phase !== 'ready_to_apply')
|
|
118
|
+
return answer(publicJob(job));
|
|
119
|
+
if (this.pending === id)
|
|
120
|
+
return answer(publicJob(job));
|
|
121
|
+
if (!this.d.gate.tryPause())
|
|
122
|
+
return failure('update_busy; finish active work before retrying');
|
|
123
|
+
try {
|
|
124
|
+
await this.d.audit('activate ' + id);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
this.d.gate.resume();
|
|
128
|
+
return failure('update_audit_failed; no switch dispatched');
|
|
129
|
+
}
|
|
130
|
+
this.pending = id;
|
|
131
|
+
return answer(publicJob(job));
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return failure('update_activation_failed');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async afterResponse(method, id) {
|
|
138
|
+
if (method !== 'activate_update' || this.pending !== id)
|
|
139
|
+
return;
|
|
140
|
+
this.pending = undefined;
|
|
141
|
+
try {
|
|
142
|
+
const job = await this.d.journal.transition(id, 'switching');
|
|
143
|
+
await this.d.launch(job);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
await this.d.journal.transition(id, 'failed', 'worker_failed').catch(() => { });
|
|
147
|
+
this.d.gate.resume();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async confirmUpdateReady(id) { try {
|
|
151
|
+
await this.d.journal.acknowledge(id, this.d.boot, process.pid);
|
|
152
|
+
return answer({ id, confirmed: true });
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return failure('update_readiness_identity_mismatch');
|
|
156
|
+
} }
|
|
157
|
+
async refreshGate() {
|
|
158
|
+
const job = await this.d.journal.latest();
|
|
159
|
+
if (!job)
|
|
160
|
+
return;
|
|
161
|
+
if (job.parentPid !== process.pid && ['checking', 'verifying', 'staging', 'ready_to_apply'].includes(job.phase)) {
|
|
162
|
+
await this.d.journal.transition(job.id, 'failed', 'interrupted');
|
|
163
|
+
this.d.gate.resume();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (['switching', 'waiting_for_service', 'rolling_back', 'recovery_required'].includes(job.phase))
|
|
167
|
+
this.d.gate.blocked = true;
|
|
168
|
+
else if (['service_ready', 'rolled_back', 'failed'].includes(job.phase))
|
|
169
|
+
this.d.gate.resume();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { cp, lstat, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { noLinks, boundedJson } from './journal.js';
|
|
4
|
+
import { observedIdentitySchema } from './protocol.js';
|
|
5
|
+
import { nativeFiles } from './release.js';
|
|
6
|
+
export async function readBootIdentity(program) {
|
|
7
|
+
const pkg = await boundedJson(join(program, 'package.json'));
|
|
8
|
+
let integrity = null;
|
|
9
|
+
try {
|
|
10
|
+
const receipt = observedIdentitySchema.parse(await boundedJson(join(program, 'release.json')));
|
|
11
|
+
if (receipt.version !== pkg.version || !receipt.integrity)
|
|
12
|
+
throw new Error('invalid_release_receipt');
|
|
13
|
+
integrity = receipt.integrity;
|
|
14
|
+
}
|
|
15
|
+
catch (e) {
|
|
16
|
+
if (e.code !== 'ENOENT')
|
|
17
|
+
throw e;
|
|
18
|
+
}
|
|
19
|
+
return observedIdentitySchema.parse({ version: pkg.version, integrity });
|
|
20
|
+
}
|
|
21
|
+
export async function installedRelease(program) {
|
|
22
|
+
const pkg = await boundedJson(join(program, 'package.json'));
|
|
23
|
+
const native = {};
|
|
24
|
+
for (const name of nativeFiles) {
|
|
25
|
+
const path = join(program, 'dist/platform/windows/assets', name);
|
|
26
|
+
await noLinks(path);
|
|
27
|
+
if ((await lstat(path)).size > 1024 * 1024)
|
|
28
|
+
throw new Error('invalid_native_template');
|
|
29
|
+
native[name] = await readFile(path);
|
|
30
|
+
}
|
|
31
|
+
return { dependencies: pkg.dependencies ?? {}, native };
|
|
32
|
+
}
|
|
33
|
+
export async function writeRelease(directory, manifest) {
|
|
34
|
+
await noLinks(directory);
|
|
35
|
+
await mkdir(directory, { mode: 0o700 });
|
|
36
|
+
for (const [relative, bytes] of manifest.files) {
|
|
37
|
+
const path = join(directory, relative);
|
|
38
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
39
|
+
await writeFile(path, bytes, { flag: 'wx', mode: 0o600 });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function copyPrivate(source, destination) {
|
|
43
|
+
await noLinks(source);
|
|
44
|
+
await noLinks(destination);
|
|
45
|
+
await cp(source, destination, { recursive: true, filter: async (path) => { if ((await lstat(path)).isSymbolicLink())
|
|
46
|
+
throw new Error('unsafe_update_path'); return true; } });
|
|
47
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const safeMethods = new Set(['capabilities', 'diagnose', 'audit', 'plan_update', 'begin_update', 'update_status', 'activate_update', 'confirm_update_ready']);
|
|
2
|
+
/** One admission boundary covers grants, unlock prompts and management windows over authenticated IPC. */
|
|
3
|
+
export class UpdateGate {
|
|
4
|
+
active = 0;
|
|
5
|
+
blocked = false;
|
|
6
|
+
enter(method) {
|
|
7
|
+
if (safeMethods.has(method))
|
|
8
|
+
return () => { };
|
|
9
|
+
if (this.blocked)
|
|
10
|
+
return null;
|
|
11
|
+
this.active++;
|
|
12
|
+
let left = false;
|
|
13
|
+
return () => { if (!left) {
|
|
14
|
+
left = true;
|
|
15
|
+
this.active--;
|
|
16
|
+
} };
|
|
17
|
+
}
|
|
18
|
+
tryPause() { if (this.active || this.blocked)
|
|
19
|
+
return false; this.blocked = true; return true; }
|
|
20
|
+
resume() { this.blocked = false; }
|
|
21
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { lstat, mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, resolve, parse } from 'node:path';
|
|
3
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
4
|
+
import { idSchema, jobSchema, markerSchema, sameIdentity, terminal } from './protocol.js';
|
|
5
|
+
export async function noLinks(path) {
|
|
6
|
+
for (let p = resolve(path);;) {
|
|
7
|
+
try {
|
|
8
|
+
if ((await lstat(p)).isSymbolicLink())
|
|
9
|
+
throw new Error('unsafe_update_path');
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
if (e.code !== 'ENOENT')
|
|
13
|
+
throw e;
|
|
14
|
+
}
|
|
15
|
+
if (p === parse(p).root)
|
|
16
|
+
break;
|
|
17
|
+
p = dirname(p);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function boundedJson(path, limit = 16384) {
|
|
21
|
+
await noLinks(path);
|
|
22
|
+
const stat = await lstat(path);
|
|
23
|
+
if (!stat.isFile() || stat.size > limit)
|
|
24
|
+
throw new Error('invalid_update_state');
|
|
25
|
+
const bytes = await readFile(path);
|
|
26
|
+
if (bytes.length > limit)
|
|
27
|
+
throw new Error('invalid_update_state');
|
|
28
|
+
return JSON.parse(bytes.toString('utf8'));
|
|
29
|
+
}
|
|
30
|
+
export async function atomicJson(path, value) {
|
|
31
|
+
await noLinks(path);
|
|
32
|
+
const bytes = JSON.stringify(value);
|
|
33
|
+
if (Buffer.byteLength(bytes) > 16384)
|
|
34
|
+
throw new Error('invalid_update_state');
|
|
35
|
+
const temp = path + '.' + randomUUID() + '.tmp', file = await open(temp, 'wx', 0o600);
|
|
36
|
+
try {
|
|
37
|
+
await file.writeFile(bytes);
|
|
38
|
+
await file.sync();
|
|
39
|
+
await file.close();
|
|
40
|
+
await rename(temp, path);
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
await file.close();
|
|
44
|
+
await unlink(temp).catch(() => { });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const transitions = {
|
|
48
|
+
checking: ['verifying', 'failed'], verifying: ['staging', 'failed'], staging: ['ready_to_apply', 'failed'],
|
|
49
|
+
ready_to_apply: ['switching', 'failed'], switching: ['waiting_for_service', 'rolling_back', 'failed', 'recovery_required'],
|
|
50
|
+
waiting_for_service: ['service_ready', 'rolling_back', 'recovery_required'], rolling_back: ['rolled_back', 'recovery_required'],
|
|
51
|
+
service_ready: [], rolled_back: [], failed: [], recovery_required: [],
|
|
52
|
+
};
|
|
53
|
+
export class UpdateJournal {
|
|
54
|
+
home;
|
|
55
|
+
secure;
|
|
56
|
+
verify;
|
|
57
|
+
initialized = false;
|
|
58
|
+
verified = new Set();
|
|
59
|
+
chain = Promise.resolve();
|
|
60
|
+
root;
|
|
61
|
+
constructor(home, secure = async () => { }, verify = async () => true) {
|
|
62
|
+
this.home = home;
|
|
63
|
+
this.secure = secure;
|
|
64
|
+
this.verify = verify;
|
|
65
|
+
this.root = join(home, 'updates');
|
|
66
|
+
}
|
|
67
|
+
directory(id) { return join(this.root, idSchema.parse(id)); }
|
|
68
|
+
async directoryReady(path, create = false) {
|
|
69
|
+
await noLinks(path);
|
|
70
|
+
let exists = true;
|
|
71
|
+
try {
|
|
72
|
+
if (!(await lstat(path)).isDirectory())
|
|
73
|
+
throw new Error('unsafe_update_path');
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
if (e.code !== 'ENOENT')
|
|
77
|
+
throw e;
|
|
78
|
+
exists = false;
|
|
79
|
+
}
|
|
80
|
+
if (!exists) {
|
|
81
|
+
if (!create)
|
|
82
|
+
throw Object.assign(new Error('update_state_not_found'), { code: 'ENOENT' });
|
|
83
|
+
await mkdir(path, { mode: 0o700 });
|
|
84
|
+
await this.secure(path);
|
|
85
|
+
}
|
|
86
|
+
if (!await this.verify(path))
|
|
87
|
+
throw new Error('unsafe_update_permissions');
|
|
88
|
+
}
|
|
89
|
+
async init(create = false) { if (!this.initialized) {
|
|
90
|
+
await this.directoryReady(this.root, create);
|
|
91
|
+
this.initialized = true;
|
|
92
|
+
} }
|
|
93
|
+
async checked(id, create = false) {
|
|
94
|
+
await this.init(create);
|
|
95
|
+
const dir = this.directory(id);
|
|
96
|
+
if (!this.verified.has(id)) {
|
|
97
|
+
await this.directoryReady(dir, create);
|
|
98
|
+
this.verified.add(id);
|
|
99
|
+
}
|
|
100
|
+
return dir;
|
|
101
|
+
}
|
|
102
|
+
async read(id) { const dir = await this.checked(id); const job = jobSchema.parse(await boundedJson(join(dir, 'job.json'))); if (job.id !== id)
|
|
103
|
+
throw new Error('invalid_update_state'); return job; }
|
|
104
|
+
async latest() {
|
|
105
|
+
let pointer;
|
|
106
|
+
try {
|
|
107
|
+
await this.init();
|
|
108
|
+
pointer = await boundedJson(join(this.root, 'current.json'), 1024);
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
if (e.code === 'ENOENT')
|
|
112
|
+
return null;
|
|
113
|
+
throw e;
|
|
114
|
+
}
|
|
115
|
+
const id = idSchema.parse(pointer?.id);
|
|
116
|
+
return this.read(id);
|
|
117
|
+
}
|
|
118
|
+
async create(source, target, parentPid) {
|
|
119
|
+
const operation = async () => {
|
|
120
|
+
await this.init(true);
|
|
121
|
+
if (await this.latest().then(j => j && (!terminal(j.phase) || j.phase === 'recovery_required')))
|
|
122
|
+
throw new Error('update_busy');
|
|
123
|
+
const job = jobSchema.parse({ id: randomUUID(), phase: 'checking', source, target, parentPid, nonce: randomBytes(32).toString('hex'), createdAt: Date.now(), updatedAt: Date.now() });
|
|
124
|
+
const dir = await this.checked(job.id, true);
|
|
125
|
+
await atomicJson(join(dir, 'job.json'), job);
|
|
126
|
+
await atomicJson(join(this.root, 'current.json'), { id: job.id });
|
|
127
|
+
return job;
|
|
128
|
+
};
|
|
129
|
+
const result = this.chain.then(operation, operation);
|
|
130
|
+
this.chain = result.catch(() => { });
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
async transition(id, phase, error) {
|
|
134
|
+
const job = await this.read(id);
|
|
135
|
+
if (phase !== job.phase && !transitions[job.phase].includes(phase))
|
|
136
|
+
throw new Error('invalid_update_transition');
|
|
137
|
+
const next = jobSchema.parse({ ...job, phase, updatedAt: Date.now(), ...(error ? { error } : {}) });
|
|
138
|
+
await atomicJson(join(this.directory(id), 'job.json'), next);
|
|
139
|
+
return next;
|
|
140
|
+
}
|
|
141
|
+
async acknowledge(id, boot, pid) {
|
|
142
|
+
const job = await this.read(id), kind = job.phase === 'waiting_for_service' ? 'target' : job.phase === 'rolling_back' ? 'source' : null;
|
|
143
|
+
if (!kind || !sameIdentity(boot, kind === 'target' ? job.target : job.source))
|
|
144
|
+
throw new Error('readiness_identity_mismatch');
|
|
145
|
+
await atomicJson(join(this.directory(id), 'ready.json'), markerSchema.parse({ id, nonce: job.nonce, boot, pid, kind }));
|
|
146
|
+
}
|
|
147
|
+
async marker(id) {
|
|
148
|
+
try {
|
|
149
|
+
return markerSchema.parse(await boundedJson(join(await this.checked(id), 'ready.json'), 4096));
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
if (e.code === 'ENOENT')
|
|
153
|
+
return null;
|
|
154
|
+
throw e;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { fork } from 'node:child_process';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { checkWindowsPermissions } from '../../platform/windows/doctor.js';
|
|
4
|
+
import { setAdminOnlyAcl } from '../../platform/windows/acl.js';
|
|
5
|
+
import { UpdateJournal } from './journal.js';
|
|
6
|
+
import { terminal } from './protocol.js';
|
|
7
|
+
export const secureUpdateDirectory = (path) => setAdminOnlyAcl(path, undefined, false);
|
|
8
|
+
export const verifyUpdateDirectory = async (path) => await checkWindowsPermissions(path, false) === 'pass';
|
|
9
|
+
export async function verifyUpdateActive(home, path) {
|
|
10
|
+
const checks = path ? [checkWindowsPermissions(path)] : [checkWindowsPermissions(home, false), checkWindowsPermissions(join(home, 'program')), checkWindowsPermissions(join(home, 'bin'))];
|
|
11
|
+
if ((await Promise.all(checks)).some(value => value !== 'pass'))
|
|
12
|
+
throw new Error('unsafe_update_permissions');
|
|
13
|
+
}
|
|
14
|
+
export function launchUpdateWorker(home, job, exit = () => process.exit(0)) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const runtime = join(home, 'updates', job.id, 'runtime'), env = { ...process.env, U_CHAN_HOME: home };
|
|
17
|
+
delete env.BW_SESSION;
|
|
18
|
+
const child = fork(join(runtime, 'dist/service/update/worker-main.js'), [job.id], { execPath: join(home, 'bin', 'node.exe'), cwd: runtime, env, detached: true, windowsHide: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
|
|
19
|
+
let started = false;
|
|
20
|
+
const timer = setTimeout(() => { if (!started) {
|
|
21
|
+
child.kill();
|
|
22
|
+
reject(new Error('worker_start_failed'));
|
|
23
|
+
} }, 30000);
|
|
24
|
+
child.once('error', () => { clearTimeout(timer); reject(new Error('worker_start_failed')); });
|
|
25
|
+
child.once('exit', () => { if (!started) {
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
reject(new Error('worker_start_failed'));
|
|
28
|
+
}
|
|
29
|
+
else
|
|
30
|
+
void productionJournal(home).read(job.id).then(current => { if (!terminal(current.phase))
|
|
31
|
+
return productionJournal(home).transition(job.id, 'recovery_required', 'worker_failed'); }).catch(() => { }); });
|
|
32
|
+
child.on('message', message => {
|
|
33
|
+
const m = message;
|
|
34
|
+
if (m?.id !== job.id || m.nonce !== job.nonce)
|
|
35
|
+
return;
|
|
36
|
+
if (m.type === 'ready' && !started) {
|
|
37
|
+
started = true;
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
child.send({ type: 'start', id: job.id, nonce: job.nonce });
|
|
40
|
+
child.unref();
|
|
41
|
+
resolve();
|
|
42
|
+
}
|
|
43
|
+
if (m.type === 'activated' && started)
|
|
44
|
+
exit();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
export const productionJournal = (home) => process.platform === 'win32'
|
|
49
|
+
? new UpdateJournal(home, secureUpdateDirectory, verifyUpdateDirectory)
|
|
50
|
+
: new UpdateJournal(home, async () => { throw new Error('unsupported_update_platform'); }, async (path) => (await import('../../platform/darwin/doctor.js')).checkDarwinPermissions(path).then(state => state === 'pass'));
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { abortable } from '../../core/abort.js';
|
|
2
|
+
/** Bound every registry request; credentials are never part of this transport. */
|
|
3
|
+
export const boundedRegistryFetch = async (input, init) => {
|
|
4
|
+
const signal = AbortSignal.timeout(30000);
|
|
5
|
+
const response = await abortable(fetch(input, { ...init, signal, redirect: 'error' }), signal);
|
|
6
|
+
if (Number(response.headers.get('content-length')) > 32 * 1024 * 1024) {
|
|
7
|
+
await response.body?.cancel();
|
|
8
|
+
throw new Error('registry_response_too_large');
|
|
9
|
+
}
|
|
10
|
+
const reader = response.body?.getReader(), chunks = [];
|
|
11
|
+
let size = 0;
|
|
12
|
+
try {
|
|
13
|
+
if (reader)
|
|
14
|
+
for (;;) {
|
|
15
|
+
const result = await abortable(reader.read(), signal);
|
|
16
|
+
if (result.done)
|
|
17
|
+
break;
|
|
18
|
+
size += result.value.length;
|
|
19
|
+
if (size > 32 * 1024 * 1024)
|
|
20
|
+
throw new Error('registry_response_too_large');
|
|
21
|
+
chunks.push(result.value);
|
|
22
|
+
}
|
|
23
|
+
return new Response(response.status === 204 ? null : Buffer.concat(chunks), { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
await reader?.cancel().catch(() => { });
|
|
27
|
+
}
|
|
28
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { semverGt } from './verifier.js';
|
|
3
|
+
export const versionSchema = z.string().regex(/^(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})$/);
|
|
4
|
+
export const integritySchema = z.string().regex(/^sha512-[A-Za-z0-9+/]{86}==$/);
|
|
5
|
+
export const identitySchema = z.strictObject({ version: versionSchema, integrity: integritySchema });
|
|
6
|
+
export const observedIdentitySchema = z.strictObject({ version: versionSchema, integrity: integritySchema.nullable() });
|
|
7
|
+
export const idSchema = z.uuid();
|
|
8
|
+
export const phases = ['checking', 'verifying', 'staging', 'ready_to_apply', 'switching', 'waiting_for_service', 'service_ready', 'rolling_back', 'rolled_back', 'failed', 'recovery_required'];
|
|
9
|
+
export const errors = ['verification_failed', 'staging_failed', 'busy', 'incompatible', 'worker_failed', 'startup_failed', 'rollback_failed', 'interrupted'];
|
|
10
|
+
export const jobSchema = z.strictObject({
|
|
11
|
+
id: idSchema, phase: z.enum(phases), source: observedIdentitySchema, target: identitySchema,
|
|
12
|
+
nonce: z.string().regex(/^[a-f0-9]{64}$/), parentPid: z.number().int().positive(),
|
|
13
|
+
createdAt: z.number().int().nonnegative(), updatedAt: z.number().int().nonnegative(),
|
|
14
|
+
error: z.enum(errors).optional(),
|
|
15
|
+
});
|
|
16
|
+
export const publicJobSchema = jobSchema.omit({ nonce: true, parentPid: true });
|
|
17
|
+
export const markerSchema = z.strictObject({ id: idSchema, nonce: z.string().regex(/^[a-f0-9]{64}$/), boot: observedIdentitySchema, pid: z.number().int().positive(), kind: z.enum(['target', 'source']) });
|
|
18
|
+
export const terminal = (phase) => ['service_ready', 'rolled_back', 'failed', 'recovery_required'].includes(phase);
|
|
19
|
+
export function publicJob(job) { const { nonce, parentPid, ...safe } = job; return safe; }
|
|
20
|
+
export function sameIdentity(a, b) { return a.version === b.version && a.integrity === b.integrity; }
|
|
21
|
+
export function planDecision(current, target) {
|
|
22
|
+
if (semverGt(current.version, target.version))
|
|
23
|
+
return 'local_ahead';
|
|
24
|
+
if (current.version === target.version)
|
|
25
|
+
return current.integrity === target.integrity ? 'up_to_date' : 'identity_unknown';
|
|
26
|
+
return 'update_required';
|
|
27
|
+
}
|
|
28
|
+
export const planSchema = z.strictObject({ service: observedIdentitySchema, target: identitySchema, decision: z.enum(['update_required', 'local_ahead', 'up_to_date', 'identity_unknown']), compatible: z.boolean(), reason: z.string().regex(/^[a-z_]{1,50}$/).nullable(), platform: z.enum(['win32', 'unsupported']), coordinator: z.literal(1), activeJob: publicJobSchema.nullable().optional() });
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { gunzipSync } from 'node:zlib';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { versionSchema } from './protocol.js';
|
|
4
|
+
export const nativeFiles = ['prompt-src.js', 'auth-src.js', 'service-src.js'];
|
|
5
|
+
export const artifactIntegrity = (bytes) => 'sha512-' + createHash('sha512').update(bytes).digest('base64');
|
|
6
|
+
export function safeReleasePath(path) {
|
|
7
|
+
if (!path || path.length > 240 || /[\\:\s\p{Cc}\p{Cf}]/u.test(path))
|
|
8
|
+
return false;
|
|
9
|
+
return path.split('/').every(p => p && p !== '.' && p !== '..' && !/[. ]$/.test(p) && !/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(p));
|
|
10
|
+
}
|
|
11
|
+
/** Conservative npm tar reader: unsupported extended metadata fails closed instead of changing paths. */
|
|
12
|
+
export function inspectRelease(tarball) {
|
|
13
|
+
try {
|
|
14
|
+
if (tarball.length > 32 * 1024 * 1024)
|
|
15
|
+
throw new Error();
|
|
16
|
+
const tar = gunzipSync(tarball, { maxOutputLength: 64 * 1024 * 1024 });
|
|
17
|
+
const files = new Map(), seen = new Set();
|
|
18
|
+
let offset = 0, count = 0, ended = false;
|
|
19
|
+
const string = (buf) => { const zero = buf.indexOf(0); const end = zero < 0 ? buf.length : zero; if (zero >= 0 && buf.subarray(zero).some(n => n !== 0))
|
|
20
|
+
throw new Error(); return buf.subarray(0, end).toString('utf8'); };
|
|
21
|
+
const octal = (buf) => { const v = buf.toString('ascii').replace(/[\0 ]/g, ''); if (!/^[0-7]+$/.test(v))
|
|
22
|
+
throw new Error(); return parseInt(v, 8); };
|
|
23
|
+
while (offset + 512 <= tar.length) {
|
|
24
|
+
const header = tar.subarray(offset, offset + 512);
|
|
25
|
+
if (header.every(n => n === 0)) {
|
|
26
|
+
if (tar.length - offset < 1024 || tar.subarray(offset).some(n => n !== 0))
|
|
27
|
+
throw new Error();
|
|
28
|
+
ended = true;
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
if (++count > 2000)
|
|
32
|
+
throw new Error();
|
|
33
|
+
const sum = [...header].reduce((s, n, i) => s + (i >= 148 && i < 156 ? 32 : n), 0);
|
|
34
|
+
if (sum !== octal(header.subarray(148, 156)))
|
|
35
|
+
throw new Error();
|
|
36
|
+
const prefix = string(header.subarray(345, 500)), name = (prefix ? prefix + '/' : '') + string(header.subarray(0, 100));
|
|
37
|
+
const type = header[156], size = octal(header.subarray(124, 136));
|
|
38
|
+
if (type !== 0 && type !== 48 && type !== 53)
|
|
39
|
+
throw new Error();
|
|
40
|
+
if (string(header.subarray(157, 257)))
|
|
41
|
+
throw new Error();
|
|
42
|
+
const path = name.replace(/\/$/, '');
|
|
43
|
+
if (!path.startsWith('package/') && path !== 'package')
|
|
44
|
+
throw new Error();
|
|
45
|
+
const relative = path.slice(8);
|
|
46
|
+
if (relative && (!safeReleasePath(relative) || !/^(dist\/|scripts\/|package.json$|README.md$|LICENSE$)/.test(relative)))
|
|
47
|
+
throw new Error();
|
|
48
|
+
const key = path.toLowerCase();
|
|
49
|
+
if (seen.has(key))
|
|
50
|
+
throw new Error();
|
|
51
|
+
seen.add(key);
|
|
52
|
+
const end = offset + 512 + size;
|
|
53
|
+
if (!Number.isSafeInteger(size) || size < 0 || end > tar.length || (type === 53 && size !== 0))
|
|
54
|
+
throw new Error();
|
|
55
|
+
if (type !== 53) {
|
|
56
|
+
if (!relative)
|
|
57
|
+
throw new Error();
|
|
58
|
+
files.set(relative, Buffer.from(tar.subarray(offset + 512, end)));
|
|
59
|
+
}
|
|
60
|
+
offset += 512 + Math.ceil(size / 512) * 512;
|
|
61
|
+
}
|
|
62
|
+
if (!ended)
|
|
63
|
+
throw new Error();
|
|
64
|
+
const pkg = JSON.parse(files.get('package.json')?.toString('utf8') ?? '');
|
|
65
|
+
if (pkg.name !== 'u-chan-secrets' || !versionSchema.safeParse(pkg.version).success || !pkg.engines || typeof pkg.engines.node !== 'string')
|
|
66
|
+
throw new Error();
|
|
67
|
+
for (const file of ['dist/index.js', 'dist/cli.js', 'dist/service/main.js', 'dist/service/update/worker-main.js'])
|
|
68
|
+
if (!files.has(file))
|
|
69
|
+
throw new Error();
|
|
70
|
+
const dependencies = pkg.dependencies ?? {};
|
|
71
|
+
if (!dependencies || typeof dependencies !== 'object' || Array.isArray(dependencies) || Object.entries(dependencies).some(([k, v]) => typeof v !== 'string' || !k || k.length > 200))
|
|
72
|
+
throw new Error();
|
|
73
|
+
return { version: pkg.version, files, dependencies, node: pkg.engines.node };
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new Error('invalid_or_unsupported_release');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function compatibleRelease(manifest, installed, nodeVersion) {
|
|
80
|
+
const floor = /^>=\s*(\d+)(?:\.(\d+)(?:\.(\d+))?)?$/.exec(manifest.node), current = nodeVersion.split('.').map(Number);
|
|
81
|
+
if (!floor)
|
|
82
|
+
return 'runtime_changed';
|
|
83
|
+
const wanted = floor.slice(1).map(v => Number(v ?? 0));
|
|
84
|
+
let supported = true;
|
|
85
|
+
for (let i = 0; i < 3; i++) {
|
|
86
|
+
if (current[i] !== wanted[i]) {
|
|
87
|
+
supported = current[i] > wanted[i];
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!supported)
|
|
92
|
+
return 'runtime_changed';
|
|
93
|
+
const sorted = (v) => JSON.stringify(Object.entries(v).sort(([a], [b]) => a.localeCompare(b)));
|
|
94
|
+
if (sorted(manifest.dependencies) !== sorted(installed.dependencies))
|
|
95
|
+
return 'dependencies_changed';
|
|
96
|
+
for (const name of nativeFiles) {
|
|
97
|
+
const next = manifest.files.get('dist/platform/windows/assets/' + name);
|
|
98
|
+
if (!next || !installed.native[name] || !next.equals(installed.native[name]))
|
|
99
|
+
return 'native_helpers_changed';
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|