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,166 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createServer } from 'node:net';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { NpmPackageVerifier } from '../service/update/verifier.js';
|
|
9
|
+
import { boundedRegistryFetch } from '../service/update/network.js';
|
|
10
|
+
import { artifactIntegrity, inspectRelease } from '../service/update/release.js';
|
|
11
|
+
import { atomicJson, boundedJson, noLinks } from '../service/update/journal.js';
|
|
12
|
+
import { readBootIdentity } from '../service/update/files.js';
|
|
13
|
+
import { idSchema, identitySchema, sameIdentity } from '../service/update/protocol.js';
|
|
14
|
+
import { isElevated, configureCodex } from './codex-setup.js';
|
|
15
|
+
import { configureClaude } from './claude-setup.js';
|
|
16
|
+
import { configureWindowsCommand } from './windows-command.js';
|
|
17
|
+
import { loadConfig } from '../core/config.js';
|
|
18
|
+
import { defaultEndpoint } from '../ipc/endpoint.js';
|
|
19
|
+
const checkpointSchema = z.strictObject({ id: idSchema, target: identitySchema });
|
|
20
|
+
const desktopCheckpointSchema = z.strictObject({ stageId: idSchema, target: identitySchema, backup: z.string().regex(/^u-chan-secrets-[A-Za-z0-9.+-]+\.tgz$/) });
|
|
21
|
+
export async function acquireDesktopLock(directory) {
|
|
22
|
+
const name = process.platform === 'win32' ? '\\\\.\\pipe\\u-chan-update-' + createHash('sha256').update(directory.toLowerCase()).digest('hex') : join(directory, 'update.sock');
|
|
23
|
+
const server = createServer(socket => socket.destroy());
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
server.once('error', (error) => error.code === 'EADDRINUSE' ? resolve(null) : reject(error));
|
|
26
|
+
server.listen({ path: name, exclusive: true }, () => resolve(() => new Promise((done, fail) => server.close(error => error ? fail(error) : done()))));
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export async function verifyDesktopFiles(program, tarball) {
|
|
30
|
+
for (const [relative, bytes] of inspectRelease(tarball).files) {
|
|
31
|
+
const path = join(program, relative);
|
|
32
|
+
await noLinks(path);
|
|
33
|
+
const stat = await lstat(path);
|
|
34
|
+
if (!stat.isFile() || stat.size !== bytes.length || !(await readFile(path)).equals(bytes))
|
|
35
|
+
throw new Error('desktop_artifact_mismatch');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** npm runs only as the desktop user. No caller artifact reaches the privileged service. */
|
|
39
|
+
export function desktopUpdateAdapter(env = process.env) {
|
|
40
|
+
const state = join(env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'u-chan-secrets');
|
|
41
|
+
let program = '', stage = '', artifact, npmEntry = '';
|
|
42
|
+
async function npm(args) {
|
|
43
|
+
if (await isElevated())
|
|
44
|
+
throw new Error('normal_desktop_user_required');
|
|
45
|
+
if (!npmEntry) {
|
|
46
|
+
for (const candidate of [join(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'), join(dirname(process.execPath), '../lib/node_modules/npm/bin/npm-cli.js')]) {
|
|
47
|
+
try {
|
|
48
|
+
await noLinks(candidate);
|
|
49
|
+
if ((await lstat(candidate)).isFile()) {
|
|
50
|
+
npmEntry = candidate;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
55
|
+
}
|
|
56
|
+
if (!npmEntry)
|
|
57
|
+
throw new Error('trusted_npm_unavailable');
|
|
58
|
+
}
|
|
59
|
+
return new Promise((resolve, reject) => execFile(process.execPath, [npmEntry, ...args], { env, windowsHide: true, timeout: 120000, maxBuffer: 1024 * 1024 }, (error, stdout) => error ? reject(new Error('desktop_npm_failed')) : resolve(stdout.trim())));
|
|
60
|
+
}
|
|
61
|
+
async function installed() {
|
|
62
|
+
if (!program) {
|
|
63
|
+
const root = await npm(['root', '--global', '--loglevel=error']);
|
|
64
|
+
if (!isAbsolute(root) || /[\r\n]/.test(root))
|
|
65
|
+
throw new Error('invalid_npm_root');
|
|
66
|
+
program = join(root, 'u-chan-secrets');
|
|
67
|
+
await noLinks(program);
|
|
68
|
+
}
|
|
69
|
+
return program;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
identity: async () => readBootIdentity(await installed()),
|
|
73
|
+
acquire: async () => { if (await isElevated())
|
|
74
|
+
throw new Error('normal_desktop_user_required'); await noLinks(state); await mkdir(state, { recursive: true, mode: 0o700 }); return acquireDesktopLock(state); },
|
|
75
|
+
checkpoint: async (value) => {
|
|
76
|
+
const path = join(state, 'update-checkpoint.json');
|
|
77
|
+
if (value) {
|
|
78
|
+
await atomicJson(path, checkpointSchema.parse(value));
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
return checkpointSchema.parse(await boundedJson(path));
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (error.code === 'ENOENT')
|
|
86
|
+
return null;
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
recover: async (target) => {
|
|
91
|
+
const saved = desktopCheckpointSchema.parse(await boundedJson(join(state, 'desktop-update.json')));
|
|
92
|
+
if (!sameIdentity(saved.target, target))
|
|
93
|
+
throw new Error('desktop_recovery_target_mismatch');
|
|
94
|
+
const backup = join(state, '#0 backup', 'update-' + saved.stageId, 'before', saved.backup);
|
|
95
|
+
await noLinks(backup);
|
|
96
|
+
const stat = await lstat(backup);
|
|
97
|
+
if (!stat.isFile() || !stat.size)
|
|
98
|
+
throw new Error('desktop_recovery_backup_missing');
|
|
99
|
+
const verified = await new NpmPackageVerifier({ fetcher: boundedRegistryFetch }).verify(target.version);
|
|
100
|
+
if (!verified.ok || !verified.tarball || artifactIntegrity(verified.tarball) !== target.integrity)
|
|
101
|
+
throw new Error('desktop_recovery_verification_failed');
|
|
102
|
+
await verifyDesktopFiles(await installed(), verified.tarball);
|
|
103
|
+
await atomicJson(join(program, 'release.json'), target);
|
|
104
|
+
return readBootIdentity(program);
|
|
105
|
+
},
|
|
106
|
+
prepare: async (target) => {
|
|
107
|
+
const result = await new NpmPackageVerifier({ fetcher: boundedRegistryFetch }).verify(target.version);
|
|
108
|
+
if (!result.ok || !result.tarball || artifactIntegrity(result.tarball) !== target.integrity)
|
|
109
|
+
throw new Error('desktop_release_verification_failed');
|
|
110
|
+
const manifest = inspectRelease(result.tarball);
|
|
111
|
+
if (manifest.version !== target.version)
|
|
112
|
+
throw new Error('desktop_version_mismatch');
|
|
113
|
+
artifact = result.tarball;
|
|
114
|
+
const stageId = randomUUID();
|
|
115
|
+
stage = join(state, '#0 backup', 'update-' + stageId);
|
|
116
|
+
await noLinks(stage);
|
|
117
|
+
await mkdir(join(stage, 'before'), { recursive: true, mode: 0o700 });
|
|
118
|
+
const packed = JSON.parse(await npm(['pack', await installed(), '--ignore-scripts', '--pack-destination', join(stage, 'before'), '--json']));
|
|
119
|
+
if (!Array.isArray(packed) || packed.length !== 1 || !/^u-chan-secrets-[A-Za-z0-9.+-]+\.tgz$/.test(packed[0]?.filename))
|
|
120
|
+
throw new Error('desktop_backup_failed');
|
|
121
|
+
const backup = join(stage, 'before', packed[0].filename);
|
|
122
|
+
await noLinks(backup);
|
|
123
|
+
if (!(await lstat(backup)).isFile())
|
|
124
|
+
throw new Error('desktop_backup_failed');
|
|
125
|
+
await writeFile(join(stage, 'target.tgz'), artifact, { flag: 'wx', mode: 0o600 });
|
|
126
|
+
await atomicJson(join(stage, 'target.json'), target);
|
|
127
|
+
await atomicJson(join(state, 'desktop-update.json'), { stageId, target, backup: packed[0].filename });
|
|
128
|
+
},
|
|
129
|
+
install: async (target) => {
|
|
130
|
+
if (!artifact || !stage)
|
|
131
|
+
throw new Error('desktop_not_prepared');
|
|
132
|
+
const path = join(stage, 'target.tgz');
|
|
133
|
+
await noLinks(path);
|
|
134
|
+
if ((await lstat(path)).size > 32 * 1024 * 1024)
|
|
135
|
+
throw new Error('desktop_artifact_too_large');
|
|
136
|
+
const bytes = await readFile(path);
|
|
137
|
+
if (artifactIntegrity(bytes) !== target.integrity || !bytes.equals(artifact))
|
|
138
|
+
throw new Error('desktop_artifact_changed');
|
|
139
|
+
await npm(['install', '--global', '--ignore-scripts', '--no-audit', '--no-fund', path]);
|
|
140
|
+
await verifyDesktopFiles(await installed(), bytes);
|
|
141
|
+
await atomicJson(join(program, 'release.json'), target);
|
|
142
|
+
const identity = await readBootIdentity(program);
|
|
143
|
+
if (!sameIdentity(identity, target))
|
|
144
|
+
throw new Error('desktop_identity_mismatch');
|
|
145
|
+
return identity;
|
|
146
|
+
},
|
|
147
|
+
repair: async () => {
|
|
148
|
+
if (await isElevated())
|
|
149
|
+
return false;
|
|
150
|
+
const config = loadConfig(env, process.platform), common = { apply: false, replaceExisting: false, browser: config.browser, endpoint: config.cdpEndpoint, ipcEndpoint: defaultEndpoint(env, process.platform), nodePath: process.execPath, entryPath: join(await installed(), 'dist/index.js') };
|
|
151
|
+
const claudeDir = env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude');
|
|
152
|
+
const handlers = [(apply) => configureCodex({ ...common, apply, replaceExisting: apply, configDir: env.CODEX_HOME ?? join(homedir(), '.codex') }), (apply) => configureClaude({ ...common, apply, replaceExisting: apply, configDir: claudeDir, configPath: env.CLAUDE_CONFIG_DIR ? join(claudeDir, '.claude.json') : join(homedir(), '.claude.json') })];
|
|
153
|
+
let ready = (await configureWindowsCommand(true, env.PATH ?? env.Path ?? '')).ready;
|
|
154
|
+
for (const configure of handlers) {
|
|
155
|
+
const before = await configure(false);
|
|
156
|
+
if (before.state === 'needs_update') {
|
|
157
|
+
const repaired = await configure(true);
|
|
158
|
+
ready = repaired.state === 'ready' && ready;
|
|
159
|
+
}
|
|
160
|
+
else if (!['ready', 'missing'].includes(before.state))
|
|
161
|
+
ready = false;
|
|
162
|
+
}
|
|
163
|
+
return ready;
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { performance } from 'node:perf_hooks';
|
|
2
|
+
import { observedIdentitySchema, planSchema, publicJobSchema, sameIdentity, planDecision, terminal } from '../service/update/protocol.js';
|
|
3
|
+
export async function runCoordinatedUpdate(options, d) {
|
|
4
|
+
const now = d.now ?? (() => performance.now()), sleep = d.sleep ?? (ms => new Promise(r => setTimeout(r, ms))), start = now();
|
|
5
|
+
let release = null, serviceChanged = false, lastPhase = '', lastPrinted = -1;
|
|
6
|
+
const progress = (phase) => { const seconds = Math.floor((now() - start) / 1000); if (phase !== lastPhase || seconds - lastPrinted >= 5) {
|
|
7
|
+
d.progress(phase, seconds);
|
|
8
|
+
lastPhase = phase;
|
|
9
|
+
lastPrinted = seconds;
|
|
10
|
+
} };
|
|
11
|
+
const failure = (message) => ({ ok: false, message });
|
|
12
|
+
const capabilities = async () => { const r = await d.client.capabilities(); if (!r.ok)
|
|
13
|
+
throw new Error(); return JSON.parse(r.message); };
|
|
14
|
+
try {
|
|
15
|
+
progress('checking');
|
|
16
|
+
const initial = await capabilities();
|
|
17
|
+
if (initial.protocol !== 3 || !initial.features?.includes('coordinated_update') || !d.client.planUpdate || !d.client.beginUpdate || !d.client.updateStatus || !d.client.activateUpdate || !d.client.confirmUpdateReady)
|
|
18
|
+
return failure('coordinated updater bootstrap required; install the matching trusted service first');
|
|
19
|
+
const response = await d.client.planUpdate();
|
|
20
|
+
if (!response.ok)
|
|
21
|
+
return response;
|
|
22
|
+
const plan = planSchema.parse(JSON.parse(response.message));
|
|
23
|
+
let desktop = observedIdentitySchema.parse(await d.desktop.identity()), desktopDecision = planDecision(desktop, plan.target);
|
|
24
|
+
if (options.checkOnly)
|
|
25
|
+
return { ok: true, message: JSON.stringify({ ...plan, desktop, desktopDecision, readOnly: true }) };
|
|
26
|
+
if (plan.platform !== 'win32')
|
|
27
|
+
return failure('coordinated application requires Windows; no components changed');
|
|
28
|
+
if (!plan.compatible || ['local_ahead', 'identity_unknown'].includes(plan.decision) || desktopDecision === 'local_ahead' || (desktopDecision === 'identity_unknown' && !d.desktop.recover))
|
|
29
|
+
return failure((plan.reason ?? 'local build identity is unknown or ahead') + '; no components changed');
|
|
30
|
+
release = await d.desktop.acquire();
|
|
31
|
+
if (!release)
|
|
32
|
+
return failure('another desktop update is running; reconnect after it finishes');
|
|
33
|
+
let desktopRecovered = false;
|
|
34
|
+
if (desktopDecision === 'identity_unknown') {
|
|
35
|
+
try {
|
|
36
|
+
desktop = observedIdentitySchema.parse(await d.desktop.recover(plan.target));
|
|
37
|
+
desktopDecision = planDecision(desktop, plan.target);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return failure('desktop identity unknown; restore the retained desktop package or perform a trusted desktop reinstall');
|
|
41
|
+
}
|
|
42
|
+
if (desktopDecision !== 'up_to_date')
|
|
43
|
+
return failure('desktop recovery did not establish target identity');
|
|
44
|
+
desktopRecovered = true;
|
|
45
|
+
}
|
|
46
|
+
let job = plan.activeJob ?? undefined;
|
|
47
|
+
const checkpoint = await d.desktop.checkpoint?.();
|
|
48
|
+
if (!job && checkpoint && sameIdentity(checkpoint.target, plan.target)) {
|
|
49
|
+
const r = await d.client.updateStatus(checkpoint.id);
|
|
50
|
+
if (r.ok) {
|
|
51
|
+
const saved = publicJobSchema.parse(JSON.parse(r.message));
|
|
52
|
+
if (sameIdentity(saved.target, plan.target) && saved.phase !== 'failed')
|
|
53
|
+
job = saved;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const desktopNeeded = desktopDecision === 'update_required';
|
|
57
|
+
if (desktopNeeded) {
|
|
58
|
+
progress('preparing_desktop');
|
|
59
|
+
await d.desktop.prepare(plan.target);
|
|
60
|
+
}
|
|
61
|
+
if (!job && plan.decision === 'update_required') {
|
|
62
|
+
progress('preparing_service');
|
|
63
|
+
const result = await d.client.beginUpdate(plan.target);
|
|
64
|
+
if (!result.ok)
|
|
65
|
+
return result;
|
|
66
|
+
job = publicJobSchema.parse(JSON.parse(result.message));
|
|
67
|
+
await d.desktop.checkpoint?.({ id: job.id, target: plan.target });
|
|
68
|
+
}
|
|
69
|
+
if (job) {
|
|
70
|
+
await d.desktop.checkpoint?.({ id: job.id, target: plan.target });
|
|
71
|
+
while (now() - start < 15 * 60 * 1000) {
|
|
72
|
+
if (!sameIdentity(job.target, plan.target))
|
|
73
|
+
return failure('update transaction target changed; no further work dispatched');
|
|
74
|
+
progress(job.phase);
|
|
75
|
+
if (job.phase === 'service_ready') {
|
|
76
|
+
serviceChanged = true;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
if (terminal(job.phase))
|
|
80
|
+
return failure('service update ' + job.phase + '; retained recovery program and transaction ' + job.id);
|
|
81
|
+
if (job.phase === 'ready_to_apply') {
|
|
82
|
+
const r = await d.client.activateUpdate(job.id);
|
|
83
|
+
if (!r.ok)
|
|
84
|
+
return r;
|
|
85
|
+
}
|
|
86
|
+
if (job.phase === 'waiting_for_service' || job.phase === 'rolling_back') {
|
|
87
|
+
try {
|
|
88
|
+
const cap = await capabilities(), expected = job.phase === 'rolling_back' ? job.source : job.target;
|
|
89
|
+
if (cap.release && sameIdentity(cap.release, expected))
|
|
90
|
+
await d.client.confirmUpdateReady(job.id);
|
|
91
|
+
}
|
|
92
|
+
catch { }
|
|
93
|
+
}
|
|
94
|
+
await sleep(1000);
|
|
95
|
+
try {
|
|
96
|
+
const r = await d.client.updateStatus(job.id);
|
|
97
|
+
if (r.ok)
|
|
98
|
+
job = publicJobSchema.parse(JSON.parse(r.message));
|
|
99
|
+
}
|
|
100
|
+
catch { }
|
|
101
|
+
}
|
|
102
|
+
if (job.phase !== 'service_ready')
|
|
103
|
+
return failure('update still in progress; run u-chan update to reconnect to transaction ' + job.id);
|
|
104
|
+
}
|
|
105
|
+
let finalDesktop = desktop;
|
|
106
|
+
if (desktopNeeded) {
|
|
107
|
+
progress('updating_desktop');
|
|
108
|
+
try {
|
|
109
|
+
finalDesktop = await d.desktop.install(plan.target);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return failure('partial update: service verified; desktop installation failed. Run u-chan update to resume; desktop rollback package retained');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const cap = await capabilities();
|
|
116
|
+
if (!cap.release || !sameIdentity(cap.release, plan.target) || !sameIdentity(finalDesktop, plan.target))
|
|
117
|
+
return failure('partial update: component identities do not match the verified target; retry update or inspect retained recovery copies');
|
|
118
|
+
let registrationsReady = true;
|
|
119
|
+
if ((desktopNeeded || desktopRecovered) && d.desktop.repair)
|
|
120
|
+
registrationsReady = await d.desktop.repair().catch(() => false);
|
|
121
|
+
progress('complete');
|
|
122
|
+
let vault = 'unknown';
|
|
123
|
+
try {
|
|
124
|
+
const state = await d.client.status?.();
|
|
125
|
+
const match = state?.message.match(/\bvault: (unlocked|locked|unauthenticated|unavailable)\b/);
|
|
126
|
+
if (match)
|
|
127
|
+
vault = match[1];
|
|
128
|
+
}
|
|
129
|
+
catch { }
|
|
130
|
+
return { ok: true, message: JSON.stringify({ status: 'complete', desktop: finalDesktop, service: cap.release, serviceTransactionObserved: serviceChanged, vault, agentSessionRestartRequired: desktopNeeded || desktopRecovered, manualSetupRequired: !registrationsReady, rollbackRetained: serviceChanged || desktopNeeded || desktopRecovered }) };
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return failure(serviceChanged ? 'partial update; service ready, finish desktop verification before claiming completion' : 'update check or preparation failed; inspect transaction status before retrying');
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
await release?.().catch(() => { });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, rename, realpath } from 'node:fs/promises';
|
|
2
|
+
import { delimiter, join, resolve } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
/** Let PowerShell resolve the npm .cmd launcher without changing execution policy or loading a profile. */
|
|
5
|
+
export async function configureWindowsCommand(apply, searchPath) {
|
|
6
|
+
const result = (ready, message) => ({ ready, changed: false, message });
|
|
7
|
+
try {
|
|
8
|
+
for (const entry of searchPath.split(delimiter).filter(Boolean)) {
|
|
9
|
+
const directory = resolve(entry.replace(/^"|"$/g, ''));
|
|
10
|
+
const script = join(directory, 'u-chan.ps1'), command = join(directory, 'u-chan.cmd');
|
|
11
|
+
const exists = async (file) => {
|
|
12
|
+
try {
|
|
13
|
+
return await lstat(file);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error.code === 'ENOENT')
|
|
17
|
+
return undefined;
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const ps = await exists(script), cmd = await exists(command);
|
|
22
|
+
if (!ps && !cmd)
|
|
23
|
+
continue;
|
|
24
|
+
if (!cmd?.isFile() || cmd.isSymbolicLink() || (ps && (!ps.isFile() || ps.isSymbolicLink()))) {
|
|
25
|
+
return result(false, 'Custom or redirected u-chan launcher preserved; use u-chan.cmd unlock.');
|
|
26
|
+
}
|
|
27
|
+
const batch = await readFile(command, 'utf8');
|
|
28
|
+
if (!batch.includes('"%dp0%\\node_modules\\u-chan-secrets\\dist\\cli.js" %*')) {
|
|
29
|
+
return result(false, 'Unrecognized u-chan launcher preserved; inspect command installation manually.');
|
|
30
|
+
}
|
|
31
|
+
if (!ps)
|
|
32
|
+
return result(true, 'PowerShell can use the u-chan.cmd launcher as u-chan.');
|
|
33
|
+
const source = await readFile(script, 'utf8');
|
|
34
|
+
if (!source.startsWith('#!/usr/bin/env pwsh') || !source.includes('"$basedir/node_modules/u-chan-secrets/dist/cli.js" $args') || !source.trimEnd().endsWith('exit $ret')) {
|
|
35
|
+
return result(false, 'Custom PowerShell launcher preserved; use u-chan.cmd unlock.');
|
|
36
|
+
}
|
|
37
|
+
if (!apply)
|
|
38
|
+
return result(false, 'Run u-chan.cmd setup --apply to make u-chan work without PowerShell scripts.');
|
|
39
|
+
if ((await realpath(directory)).toLowerCase() !== directory.toLowerCase())
|
|
40
|
+
throw new Error('Redirected launcher directory.');
|
|
41
|
+
const backupDirectory = join(directory, '#0 backup');
|
|
42
|
+
await mkdir(backupDirectory, { recursive: true });
|
|
43
|
+
if ((await lstat(backupDirectory)).isSymbolicLink() || (await realpath(backupDirectory)).toLowerCase() !== backupDirectory.toLowerCase())
|
|
44
|
+
throw new Error('Redirected backup directory.');
|
|
45
|
+
const backupPath = join(backupDirectory, `u-chan-${randomUUID()}.ps1`);
|
|
46
|
+
await rename(script, backupPath);
|
|
47
|
+
return { ready: true, changed: true, backupPath, message: 'PowerShell now uses u-chan.cmd as u-chan; original .ps1 launcher retained in #0 backup.' };
|
|
48
|
+
}
|
|
49
|
+
return result(true, 'No npm u-chan launcher found on PATH; source checkout commands remain unchanged.');
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return result(false, 'Could not configure the PowerShell command; use u-chan.cmd unlock and inspect launcher permissions.');
|
|
53
|
+
}
|
|
54
|
+
}
|
package/dist/core/browser.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
1
|
import { chromium } from 'playwright';
|
|
3
2
|
import { UserFacingError } from './types.js';
|
|
4
3
|
import { isHostAllowed } from './allowlist.js';
|
|
@@ -22,10 +21,6 @@ async function restrictPageNavigation(page, allowed, signal) {
|
|
|
22
21
|
await session.send('Network.setBypassServiceWorker', { bypass: true });
|
|
23
22
|
await session.send('Fetch.enable', { patterns: [{ resourceType: 'Document', requestStage: 'Request' }] });
|
|
24
23
|
}
|
|
25
|
-
/** Runs scripts/start-chrome.ps1 (idempotent: it exits early if the port is already open). */
|
|
26
|
-
export const startChromeScript = (scriptPath) => () => new Promise((resolve, reject) => {
|
|
27
|
-
execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath], { timeout: 30000, windowsHide: true }, (err) => (err ? reject(err) : resolve()));
|
|
28
|
-
});
|
|
29
24
|
/** Attaches to the user's Chrome over CDP so cookies are shared with Playwright MCP. Never closes the browser. */
|
|
30
25
|
export class CdpBrowserProvider {
|
|
31
26
|
endpoint;
|
package/dist/core/config.js
CHANGED
|
@@ -12,6 +12,7 @@ export function serviceConfig(env, platform) {
|
|
|
12
12
|
return {
|
|
13
13
|
home,
|
|
14
14
|
allowlistPath: path.join(home, 'allowlist.json'),
|
|
15
|
+
discordAllowlistPath: path.join(home, 'discord-allowlist.json'),
|
|
15
16
|
auditPath: path.join(home, 'audit.log'),
|
|
16
17
|
settingsPath: path.join(home, 'settings.json'),
|
|
17
18
|
bwDataDir: path.join(home, 'bw-data'),
|
package/dist/core/doctor.js
CHANGED
|
@@ -108,6 +108,10 @@ export async function runDoctor(d) {
|
|
|
108
108
|
add('uac', elevation, 'Windows administrator prompt policy checked.', 'Set UAC to Always notify in Windows settings.');
|
|
109
109
|
add('assets', state(service?.assets), 'Private service assets checked.', 'Repair the installation from a trusted administrator terminal.');
|
|
110
110
|
add('permissions', state(service?.permissions), 'Protected service permissions checked.', 'Repair protected permissions from a trusted administrator terminal.');
|
|
111
|
+
if (service?.discordPolicy !== undefined)
|
|
112
|
+
add('discord_policy', state(service.discordPolicy), 'Protected Discord connection policy checked.', 'Repair Discord connection state from a trusted administrator terminal; never include tokens in policy JSON.');
|
|
113
|
+
if (service?.discordBroker !== undefined)
|
|
114
|
+
add('discord_broker', service.discordBroker === 'available' ? 'pass' : 'unknown', service.discordBroker === 'available' ? 'Discord broker source support available; native rendering and active-session ownership require separate verification.' : 'Discord broker requires Windows and complete protected review.', 'Use an upgraded Windows service with the complete protected review helper.', false);
|
|
111
115
|
const v = service?.vault;
|
|
112
116
|
add('vault', v === 'locked' ? 'pass' : v === 'unauthenticated' ? 'fail' : state(v), v === 'locked' ? 'Vault is locked.' : v === 'unauthenticated' ? 'Vault is not authenticated.' : 'Vault availability checked.', 'Complete Bitwarden service login during trusted setup.');
|
|
113
117
|
if (v === 'locked')
|
|
@@ -117,7 +121,7 @@ export async function runDoctor(d) {
|
|
|
117
121
|
const kindMismatch = d.browser && (service?.browserKind === 'chrome' || service?.browserKind === 'edge') && service.browserKind !== d.browser;
|
|
118
122
|
if (d.browserIdentity)
|
|
119
123
|
add('browser_identity', kindMismatch ? 'fail' : identity, `Dedicated ${label} process, owner and profile checked.`, 'Check the selected browser, dedicated profile and occupied endpoint; close only the previous dedicated U-Chan instance before switching.');
|
|
120
|
-
add('playwright', playwright, 'Playwright shared-browser configuration checked.', 'Configure Playwright MCP with --cdp-endpoint and restart the AI session.', playwright !== 'unknown');
|
|
124
|
+
add('playwright', playwright, 'Playwright shared-browser configuration checked.', 'Configure Playwright MCP with --cdp-endpoint (u-chan setup --apply --replace-existing) and restart the AI session.', playwright !== 'unknown');
|
|
121
125
|
return { version: 1, checks, ...(d.browser ? { browser: d.browser, observedBrowser: service?.browserKind === 'edge' || service?.browserKind === 'chrome' ? service.browserKind : 'unknown' } : {}) };
|
|
122
126
|
}
|
|
123
127
|
export async function observeCdpBrowser(endpoint) {
|
package/dist/core/vault.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { UserFacingError } from './types.js';
|
|
3
3
|
import { redact } from './redact.js';
|
|
4
|
+
import { snowflake, tokenFieldSchema } from '../discord/policy.js';
|
|
4
5
|
/**
|
|
5
6
|
* Default exec: runs bw directly, never through a shell (item names come from the AI).
|
|
6
7
|
* On Windows this requires the native bw.exe (e.g. `winget install Bitwarden.CLI`),
|
|
@@ -28,6 +29,7 @@ export class BitwardenVault {
|
|
|
28
29
|
idleLockMs;
|
|
29
30
|
now;
|
|
30
31
|
sessionKey = '';
|
|
32
|
+
authorizationVersion = 0;
|
|
31
33
|
lastUse = 0;
|
|
32
34
|
constructor(opts) {
|
|
33
35
|
this.dataDir = opts.dataDir;
|
|
@@ -37,10 +39,14 @@ export class BitwardenVault {
|
|
|
37
39
|
}
|
|
38
40
|
/** Returns the in-memory session, dropping it if the idle timeout elapsed. */
|
|
39
41
|
session() {
|
|
40
|
-
if (this.sessionKey && this.now() - this.lastUse > this.idleLockMs)
|
|
42
|
+
if (this.sessionKey && this.now() - this.lastUse > this.idleLockMs) {
|
|
41
43
|
this.sessionKey = '';
|
|
44
|
+
this.authorizationVersion++;
|
|
45
|
+
}
|
|
42
46
|
return this.sessionKey;
|
|
43
47
|
}
|
|
48
|
+
/** In-process authorization generation; never a credential or an IPC capability. */
|
|
49
|
+
authorizationEpoch() { this.session(); return this.authorizationVersion; }
|
|
44
50
|
run(args, session, extra = {}, signal) {
|
|
45
51
|
const env = { ...process.env, BW_NOINTERACTION: 'true', BITWARDENCLI_APPDATA_DIR: this.dataDir, ...extra };
|
|
46
52
|
delete env.BW_SESSION;
|
|
@@ -72,6 +78,7 @@ export class BitwardenVault {
|
|
|
72
78
|
if (!key)
|
|
73
79
|
return false; // keep any previous session until a new one succeeds
|
|
74
80
|
this.sessionKey = key;
|
|
81
|
+
this.authorizationVersion++;
|
|
75
82
|
this.lastUse = this.now();
|
|
76
83
|
return true;
|
|
77
84
|
}
|
|
@@ -79,6 +86,7 @@ export class BitwardenVault {
|
|
|
79
86
|
return this.unlock(password);
|
|
80
87
|
}
|
|
81
88
|
async lock() {
|
|
89
|
+
this.authorizationVersion++;
|
|
82
90
|
const session = this.sessionKey;
|
|
83
91
|
this.sessionKey = '';
|
|
84
92
|
try {
|
|
@@ -104,7 +112,7 @@ export class BitwardenVault {
|
|
|
104
112
|
return 'unavailable';
|
|
105
113
|
}
|
|
106
114
|
}
|
|
107
|
-
async
|
|
115
|
+
async getItem(item, signal, quiet = false) {
|
|
108
116
|
const session = this.session();
|
|
109
117
|
if (!session)
|
|
110
118
|
throw new UserFacingError((await this.status(signal)) === 'unauthenticated' ? LOGIN_MSG : UNLOCK_MSG);
|
|
@@ -135,16 +143,113 @@ export class BitwardenVault {
|
|
|
135
143
|
}
|
|
136
144
|
if (err.includes('you are not logged in'))
|
|
137
145
|
throw new UserFacingError(LOGIN_MSG);
|
|
138
|
-
|
|
146
|
+
if (!quiet)
|
|
147
|
+
console.error(`bw failed (exit ${r.code}): ${redact(r.stderr.trim(), [session])}`);
|
|
139
148
|
throw new UserFacingError(`bw failed (exit ${r.code})`);
|
|
140
149
|
}
|
|
141
|
-
let login;
|
|
142
150
|
try {
|
|
143
|
-
|
|
151
|
+
const value = JSON.parse(r.stdout);
|
|
152
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
153
|
+
throw new Error();
|
|
154
|
+
return value;
|
|
144
155
|
}
|
|
145
156
|
catch {
|
|
146
157
|
throw new UserFacingError('unexpected bw output');
|
|
147
158
|
}
|
|
159
|
+
}
|
|
160
|
+
/** Protected discovery only: return scoped setup metadata, never note contents or token values. */
|
|
161
|
+
async getDiscordSetup(item, signal) {
|
|
162
|
+
const session = this.session();
|
|
163
|
+
if (!session)
|
|
164
|
+
throw new UserFacingError(UNLOCK_MSG);
|
|
165
|
+
try {
|
|
166
|
+
const result = await this.run(['sync'], session, {}, signal);
|
|
167
|
+
if (result.code !== 0)
|
|
168
|
+
throw new Error();
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
throw new UserFacingError('Bitwarden vault sync failed; check the private vault connection');
|
|
172
|
+
}
|
|
173
|
+
if (this.sessionKey !== session)
|
|
174
|
+
throw new UserFacingError(UNLOCK_MSG);
|
|
175
|
+
const data = await this.getItem(item, signal, true);
|
|
176
|
+
if (typeof data.id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(data.id))
|
|
177
|
+
throw new UserFacingError('vault item has no stable item ID');
|
|
178
|
+
const normalize = (value) => value.toLowerCase().replace(/[ _-]/g, '');
|
|
179
|
+
const guildKeys = new Set(['guildid', 'serverid', 'discordguildid', 'discordserverid']);
|
|
180
|
+
const tokenKeys = new Set(['token', 'bottoken', 'discordtoken', 'discordbottoken']);
|
|
181
|
+
const guildIds = new Set(), fields = [];
|
|
182
|
+
const addGuild = (value) => {
|
|
183
|
+
if (typeof value !== 'string')
|
|
184
|
+
throw new UserFacingError('vault item has invalid Discord server metadata');
|
|
185
|
+
const id = value.trim().replace(/^[`"']|[`"']$/g, '');
|
|
186
|
+
if (!snowflake.safeParse(id).success)
|
|
187
|
+
throw new UserFacingError('vault item has invalid Discord server metadata');
|
|
188
|
+
guildIds.add(id);
|
|
189
|
+
};
|
|
190
|
+
const password = data.login?.password;
|
|
191
|
+
if (typeof password === 'string' && password)
|
|
192
|
+
fields.push({ kind: 'password' });
|
|
193
|
+
for (const field of Array.isArray(data.fields) ? data.fields : []) {
|
|
194
|
+
if (!field || typeof field !== 'object' || typeof field.name !== 'string')
|
|
195
|
+
continue;
|
|
196
|
+
const key = normalize(field.name);
|
|
197
|
+
if (guildKeys.has(key))
|
|
198
|
+
addGuild(field.value);
|
|
199
|
+
if (tokenKeys.has(key) && field.type === 1 && typeof field.value === 'string' && field.value)
|
|
200
|
+
fields.push(tokenFieldSchema.parse({ kind: 'hidden', name: field.name }));
|
|
201
|
+
}
|
|
202
|
+
if (typeof data.notes === 'string') {
|
|
203
|
+
for (const line of data.notes.split(/\r?\n/)) {
|
|
204
|
+
const match = /^\s*(?:[-*#]\s*)?([a-z _-]+)\s*[:=]\s*(.*?)\s*$/i.exec(line);
|
|
205
|
+
if (match && guildKeys.has(normalize(match[1].trim())))
|
|
206
|
+
addGuild(match[2]);
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const object = JSON.parse(data.notes);
|
|
210
|
+
if (object && typeof object === 'object' && !Array.isArray(object))
|
|
211
|
+
for (const [key, value] of Object.entries(object))
|
|
212
|
+
if (guildKeys.has(normalize(key)))
|
|
213
|
+
addGuild(value);
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
if (e instanceof UserFacingError)
|
|
217
|
+
throw e;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (guildIds.size !== 1)
|
|
221
|
+
throw new UserFacingError('vault item does not contain one unambiguous labeled Discord server ID');
|
|
222
|
+
if (fields.length !== 1)
|
|
223
|
+
throw new UserFacingError('vault item does not contain one unambiguous password or named hidden bot-token field');
|
|
224
|
+
signal?.throwIfAborted();
|
|
225
|
+
this.lastUse = this.now();
|
|
226
|
+
return { item: data.id, guildId: [...guildIds][0], field: fields[0] };
|
|
227
|
+
}
|
|
228
|
+
/** Privileged service only. Never expose this method through IPC or MCP. */
|
|
229
|
+
async getToken(item, field, signal) {
|
|
230
|
+
const parsed = tokenFieldSchema.safeParse(field);
|
|
231
|
+
if (!parsed.success)
|
|
232
|
+
throw new UserFacingError('invalid token field');
|
|
233
|
+
const data = await this.getItem(item, signal, true);
|
|
234
|
+
let token;
|
|
235
|
+
if (field.kind === 'password')
|
|
236
|
+
token = data.login?.password;
|
|
237
|
+
else {
|
|
238
|
+
const fields = Array.isArray(data.fields) ? data.fields.filter(f => f && typeof f === 'object' && f.name === field.name) : [];
|
|
239
|
+
if (fields.length !== 1 || fields[0].type !== 1)
|
|
240
|
+
throw new UserFacingError('token field is missing or ambiguous');
|
|
241
|
+
token = fields[0].value;
|
|
242
|
+
}
|
|
243
|
+
if (typeof token !== 'string' || !token || token.length > 4096 || /[\s\p{Cc}\p{Cf}]/u.test(token))
|
|
244
|
+
throw new UserFacingError('token field is missing or invalid');
|
|
245
|
+
signal?.throwIfAborted();
|
|
246
|
+
this.lastUse = this.now();
|
|
247
|
+
return token;
|
|
248
|
+
}
|
|
249
|
+
async getLogin(item, signal) {
|
|
250
|
+
const data = await this.getItem(item, signal);
|
|
251
|
+
let login;
|
|
252
|
+
login = data.login;
|
|
148
253
|
if (typeof login?.username !== 'string' || typeof login?.password !== 'string' || !login.username || !login.password) {
|
|
149
254
|
throw new UserFacingError('item has no username/password');
|
|
150
255
|
}
|