@ai-workbench/connect 0.0.0-stage → 0.7.5
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/NOTICE.md +18 -0
- package/README.md +24 -2
- package/client/index.cjs +123 -0
- package/cordis.patch.yml +3 -0
- package/helper/auth-status.cjs +18 -0
- package/helper/claude-callback.cjs +29 -0
- package/helper/main.cjs +19 -0
- package/helper/projects.cjs +57 -0
- package/helper/remote-command.cjs +15 -0
- package/helper/remote-login.cjs +25 -0
- package/helper/window.cjs +52 -0
- package/lib/api.d.ts +196 -0
- package/lib/api.js +1 -0
- package/lib/archive.d.ts +1 -0
- package/lib/archive.js +63 -0
- package/lib/auth-status.d.ts +3 -0
- package/lib/auth-status.js +18 -0
- package/lib/cache.d.ts +21 -0
- package/lib/cache.js +156 -0
- package/lib/component-download.d.ts +25 -0
- package/lib/component-download.js +73 -0
- package/lib/components.d.ts +9 -0
- package/lib/components.js +121 -0
- package/lib/connect.d.ts +10 -0
- package/lib/connect.js +246 -0
- package/lib/cordis.d.ts +15 -0
- package/lib/cordis.js +1 -0
- package/lib/defaults.d.ts +29 -0
- package/lib/defaults.js +4 -0
- package/lib/electron.d.ts +8 -0
- package/lib/electron.js +83 -0
- package/lib/enrollment.d.ts +3 -0
- package/lib/enrollment.js +78 -0
- package/lib/index.d.ts +40 -0
- package/lib/index.js +48 -0
- package/lib/integration.d.ts +5 -0
- package/lib/integration.js +47 -0
- package/lib/login.d.ts +35 -0
- package/lib/login.js +81 -0
- package/lib/native-import-picker.d.ts +11 -0
- package/lib/native-import-picker.js +49 -0
- package/lib/needs.d.ts +9 -0
- package/lib/needs.js +52 -0
- package/lib/network.d.ts +26 -0
- package/lib/network.js +226 -0
- package/lib/physical-fs.d.ts +3 -0
- package/lib/physical-fs.js +7 -0
- package/lib/ports.d.ts +43 -0
- package/lib/ports.js +1 -0
- package/lib/private-files.d.ts +3 -0
- package/lib/private-files.js +41 -0
- package/lib/processes.d.ts +5 -0
- package/lib/processes.js +147 -0
- package/lib/profile-activation.d.ts +32 -0
- package/lib/profile-activation.js +118 -0
- package/lib/profile-enrollment.d.ts +21 -0
- package/lib/profile-enrollment.js +275 -0
- package/lib/profile-runtime.d.ts +6 -0
- package/lib/profile-runtime.js +47 -0
- package/lib/profile-settings.d.ts +2 -0
- package/lib/profile-settings.js +10 -0
- package/lib/projects.d.ts +4 -0
- package/lib/projects.js +41 -0
- package/lib/provider.d.ts +73 -0
- package/lib/provider.js +39 -0
- package/lib/remote-login.d.ts +2 -0
- package/lib/remote-login.js +9 -0
- package/lib/ssh.d.ts +10 -0
- package/lib/ssh.js +78 -0
- package/lib/startup.d.ts +18 -0
- package/lib/startup.js +211 -0
- package/lib/workspace-bindings.d.ts +32 -0
- package/lib/workspace-bindings.js +328 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/node_modules/argparse/LICENSE +254 -0
- package/node_modules/argparse/README.md +84 -0
- package/node_modules/argparse/argparse.js +3707 -0
- package/node_modules/argparse/lib/sub.js +67 -0
- package/node_modules/argparse/lib/textwrap.js +440 -0
- package/node_modules/argparse/package.json +31 -0
- package/node_modules/js-yaml/LICENSE +21 -0
- package/node_modules/js-yaml/README.md +233 -0
- package/node_modules/js-yaml/bin/js-yaml.js +117 -0
- package/node_modules/js-yaml/dist/js-yaml.js +3022 -0
- package/node_modules/js-yaml/dist/js-yaml.js.map +1 -0
- package/node_modules/js-yaml/dist/js-yaml.min.js +2 -0
- package/node_modules/js-yaml/dist/js-yaml.min.js.map +1 -0
- package/node_modules/js-yaml/dist/js-yaml.mjs +3082 -0
- package/node_modules/js-yaml/dist/js-yaml.mjs.map +1 -0
- package/node_modules/js-yaml/index.js +44 -0
- package/node_modules/js-yaml/lib/common.js +50 -0
- package/node_modules/js-yaml/lib/dumper.js +937 -0
- package/node_modules/js-yaml/lib/exception.js +51 -0
- package/node_modules/js-yaml/lib/index_vite_proxy.tmp.mjs +37 -0
- package/node_modules/js-yaml/lib/loader.js +1803 -0
- package/node_modules/js-yaml/lib/schema/core.js +9 -0
- package/node_modules/js-yaml/lib/schema/default.js +20 -0
- package/node_modules/js-yaml/lib/schema/failsafe.js +14 -0
- package/node_modules/js-yaml/lib/schema/json.js +17 -0
- package/node_modules/js-yaml/lib/schema.js +109 -0
- package/node_modules/js-yaml/lib/snippet.js +96 -0
- package/node_modules/js-yaml/lib/type/binary.js +122 -0
- package/node_modules/js-yaml/lib/type/bool.js +35 -0
- package/node_modules/js-yaml/lib/type/float.js +99 -0
- package/node_modules/js-yaml/lib/type/int.js +142 -0
- package/node_modules/js-yaml/lib/type/map.js +8 -0
- package/node_modules/js-yaml/lib/type/merge.js +12 -0
- package/node_modules/js-yaml/lib/type/null.js +35 -0
- package/node_modules/js-yaml/lib/type/omap.js +45 -0
- package/node_modules/js-yaml/lib/type/pairs.js +50 -0
- package/node_modules/js-yaml/lib/type/seq.js +8 -0
- package/node_modules/js-yaml/lib/type/set.js +29 -0
- package/node_modules/js-yaml/lib/type/str.js +8 -0
- package/node_modules/js-yaml/lib/type/timestamp.js +88 -0
- package/node_modules/js-yaml/lib/type.js +66 -0
- package/node_modules/js-yaml/package.json +83 -0
- package/node_modules/pend/LICENSE +23 -0
- package/node_modules/pend/README.md +41 -0
- package/node_modules/pend/index.js +55 -0
- package/node_modules/pend/package.json +18 -0
- package/node_modules/pend/test.js +137 -0
- package/node_modules/yauzl/LICENSE +21 -0
- package/node_modules/yauzl/README.md +1015 -0
- package/node_modules/yauzl/crc32.js +45 -0
- package/node_modules/yauzl/fd-slicer.js +188 -0
- package/node_modules/yauzl/index.js +1096 -0
- package/node_modules/yauzl/package.json +40 -0
- package/package.json +89 -4
package/lib/archive.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import yauzl from 'yauzl';
|
|
2
|
+
import { createWriteStream, mkdir, symlink } from "./physical-fs.js";
|
|
3
|
+
import { dirname, resolve, relative, isAbsolute } from 'node:path';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
5
|
+
export async function unpackZip(zipPath, root, maxBytes, signal) {
|
|
6
|
+
signal?.throwIfAborted();
|
|
7
|
+
const zip = await new Promise((yes, no) => yauzl.open(zipPath, { lazyEntries: true, validateEntrySizes: true }, (e, z) => e ? no(e) : yes(z)));
|
|
8
|
+
const links = [], seen = new Set();
|
|
9
|
+
let total = 0;
|
|
10
|
+
const inside = (path) => { const r = relative(root, path); return r !== '' && !r.startsWith('..') && !isAbsolute(r); };
|
|
11
|
+
try {
|
|
12
|
+
await new Promise((yes, no) => {
|
|
13
|
+
zip.on('error', no);
|
|
14
|
+
zip.on('end', yes);
|
|
15
|
+
zip.on('entry', (entry) => {
|
|
16
|
+
void (async () => {
|
|
17
|
+
signal?.throwIfAborted();
|
|
18
|
+
const path = resolve(root, entry.fileName);
|
|
19
|
+
if (!inside(path) || seen.has(path))
|
|
20
|
+
throw new Error('ZIP_PATH_INVALID');
|
|
21
|
+
seen.add(path);
|
|
22
|
+
total += entry.uncompressedSize;
|
|
23
|
+
if (total > maxBytes)
|
|
24
|
+
throw new Error('EXTRACTED_SIZE_EXCEEDED');
|
|
25
|
+
const mode = (entry.externalFileAttributes >>> 16) & 0xffff, type = mode & 0o170000;
|
|
26
|
+
if (entry.fileName.endsWith('/')) {
|
|
27
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
28
|
+
zip.readEntry();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (type !== 0 && type !== 0o100000 && type !== 0o120000)
|
|
32
|
+
throw new Error('ZIP_TYPE_INVALID');
|
|
33
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
34
|
+
const stream = await new Promise((res, rej) => zip.openReadStream(entry, (e, s) => e ? rej(e) : res(s)));
|
|
35
|
+
if (type === 0o120000) {
|
|
36
|
+
const chunks = [];
|
|
37
|
+
for await (const c of stream) {
|
|
38
|
+
signal?.throwIfAborted();
|
|
39
|
+
chunks.push(Buffer.from(c));
|
|
40
|
+
}
|
|
41
|
+
const target = Buffer.concat(chunks).toString('utf8');
|
|
42
|
+
if (isAbsolute(target) || !inside(resolve(dirname(path), target)))
|
|
43
|
+
throw new Error('ZIP_LINK_INVALID');
|
|
44
|
+
links.push({ path, target });
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
await pipeline(stream, createWriteStream(path, { flags: 'wx', mode: mode & 0o111 ? 0o700 : 0o600 }), { signal });
|
|
48
|
+
}
|
|
49
|
+
zip.readEntry();
|
|
50
|
+
})().catch(no);
|
|
51
|
+
});
|
|
52
|
+
zip.readEntry();
|
|
53
|
+
});
|
|
54
|
+
// All ordinary entries are written before symlinks, so no write can traverse an archive symlink.
|
|
55
|
+
for (const link of links) {
|
|
56
|
+
signal?.throwIfAborted();
|
|
57
|
+
await symlink(link.target, link.path);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
zip.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { fail, ok } from "./needs.js";
|
|
2
|
+
export function parseLoginStatus(provider, result) {
|
|
3
|
+
let v;
|
|
4
|
+
try {
|
|
5
|
+
v = JSON.parse(result.stdout);
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return fail('AUTH_STATUS_INVALID');
|
|
9
|
+
}
|
|
10
|
+
if (!v || typeof v !== 'object' || Array.isArray(v) || Object.keys(v).sort().join(',') !== 'exitCode,loggedIn,protocolVersion,provider')
|
|
11
|
+
return fail('AUTH_STATUS_INVALID');
|
|
12
|
+
const status = v;
|
|
13
|
+
if (status.protocolVersion !== 1 || status.provider !== provider || status.exitCode !== result.exitCode || typeof status.loggedIn !== 'boolean' || result.stderr !== '')
|
|
14
|
+
return fail('AUTH_STATUS_INVALID');
|
|
15
|
+
if (status.loggedIn ? result.exitCode !== 0 : provider === 'codex' ? result.exitCode !== 1 : result.exitCode !== 0 && result.exitCode !== 1)
|
|
16
|
+
return fail('AUTH_STATUS_INVALID');
|
|
17
|
+
return ok({ provider, loggedIn: status.loggedIn });
|
|
18
|
+
}
|
package/lib/cache.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Result } from './api.ts';
|
|
2
|
+
import { type ComponentProgress } from './component-download.ts';
|
|
3
|
+
export interface HelperSettings {
|
|
4
|
+
source: string;
|
|
5
|
+
cacheDir: string;
|
|
6
|
+
sha256: string;
|
|
7
|
+
maxDownloadBytes: number;
|
|
8
|
+
maxExtractedBytes: number;
|
|
9
|
+
executable: string;
|
|
10
|
+
}
|
|
11
|
+
interface CacheIo {
|
|
12
|
+
download: (source: string) => Promise<Response>;
|
|
13
|
+
unpack: (zip: string, dir: string, maxBytes: number) => Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
export declare function ensureHelper(s: HelperSettings | undefined, io?: {
|
|
16
|
+
download: CacheIo['download'];
|
|
17
|
+
unpack?: CacheIo['unpack'];
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
onProgress?: (progress: ComponentProgress) => void;
|
|
20
|
+
}): Promise<Result<string>>;
|
|
21
|
+
export {};
|
package/lib/cache.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { open, lstat, readFile, writeFile, readdir, readlink, realpath, rename, rm, mkdir } from "./physical-fs.js";
|
|
3
|
+
import { join, isAbsolute, relative } from 'node:path';
|
|
4
|
+
import { dependency, fail, ok, need } from "./needs.js";
|
|
5
|
+
import { ComponentDownloadError } from "./component-download.js";
|
|
6
|
+
import { privateDirectory } from "./private-files.js";
|
|
7
|
+
import { unpackZip } from "./archive.js";
|
|
8
|
+
async function treeHash(root) {
|
|
9
|
+
const hash = createHash('sha256');
|
|
10
|
+
const walk = async (dir) => {
|
|
11
|
+
for (const name of (await readdir(dir)).sort()) {
|
|
12
|
+
const p = join(dir, name), s = await lstat(p), rel = relative(root, p);
|
|
13
|
+
hash.update(rel + '\0' + (s.mode & 0o777) + '\0');
|
|
14
|
+
if (s.isDirectory())
|
|
15
|
+
await walk(p);
|
|
16
|
+
else if (s.isSymbolicLink()) {
|
|
17
|
+
const dest = await realpath(p);
|
|
18
|
+
if (!dest.startsWith(root + '/'))
|
|
19
|
+
throw new Error('CACHE_LINK_ESCAPE');
|
|
20
|
+
hash.update(await readlink(p));
|
|
21
|
+
}
|
|
22
|
+
else if (s.isFile())
|
|
23
|
+
hash.update(await readFile(p));
|
|
24
|
+
else
|
|
25
|
+
throw new Error('CACHE_TYPE_INVALID');
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
await walk(root);
|
|
29
|
+
return hash.digest('hex');
|
|
30
|
+
}
|
|
31
|
+
function valid(s) {
|
|
32
|
+
try {
|
|
33
|
+
return new URL(s.source).protocol === 'https:' && !new URL(s.source).username && !new URL(s.source).password && isAbsolute(s.cacheDir) && /^[a-f0-9]{64}$/.test(s.sha256) && Number.isSafeInteger(s.maxDownloadBytes) && s.maxDownloadBytes > 0 && Number.isSafeInteger(s.maxExtractedBytes) && s.maxExtractedBytes > 0 && !isAbsolute(s.executable) && !s.executable.split('/').includes('..') && s.executable !== '';
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export async function ensureHelper(s, io) {
|
|
40
|
+
if (!s || !valid(s))
|
|
41
|
+
return fail('SETTINGS_REQUIRED');
|
|
42
|
+
if (!io?.download)
|
|
43
|
+
return dependency('组件官方地址的直接HTTPS下载通道');
|
|
44
|
+
const final = join(s.cacheDir, s.sha256), payload = join(final, 'runtime'), executable = join(payload, s.executable);
|
|
45
|
+
let temp, receivedBytes = 0, totalBytes = null;
|
|
46
|
+
const emit = (phase, errorCode) => io.onProgress?.({ phase, receivedBytes, totalBytes, ...errorCode ? { errorCode } : {} });
|
|
47
|
+
const cancelled = () => { if (io.signal?.aborted)
|
|
48
|
+
throw new ComponentDownloadError('COMPONENT_DOWNLOAD_CANCELLED'); };
|
|
49
|
+
try {
|
|
50
|
+
cancelled();
|
|
51
|
+
await privateDirectory(s.cacheDir);
|
|
52
|
+
let existing = false;
|
|
53
|
+
try {
|
|
54
|
+
const st = await lstat(final);
|
|
55
|
+
if (st.isSymbolicLink() || !st.isDirectory())
|
|
56
|
+
throw new ComponentDownloadError('COMPONENT_CACHE_INVALID');
|
|
57
|
+
existing = true;
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
if (e.code !== 'ENOENT')
|
|
61
|
+
throw e;
|
|
62
|
+
}
|
|
63
|
+
if (existing) {
|
|
64
|
+
const receipt = JSON.parse(await readFile(join(final, 'receipt.json'), 'utf8'));
|
|
65
|
+
if (receipt.sha256 !== s.sha256 || receipt.treeHash !== await treeHash(payload))
|
|
66
|
+
throw new ComponentDownloadError('COMPONENT_CACHE_INVALID');
|
|
67
|
+
const file = await lstat(executable);
|
|
68
|
+
if (!file.isFile() || (file.mode & 0o100) === 0)
|
|
69
|
+
throw new ComponentDownloadError('COMPONENT_CACHE_INVALID');
|
|
70
|
+
cancelled();
|
|
71
|
+
emit('ready');
|
|
72
|
+
return ok(executable);
|
|
73
|
+
}
|
|
74
|
+
temp = join(s.cacheDir, 'download-' + randomUUID());
|
|
75
|
+
await mkdir(temp, { mode: 0o700 });
|
|
76
|
+
const archive = join(temp, 'electron.zip');
|
|
77
|
+
emit('downloading');
|
|
78
|
+
const response = await io.download(s.source);
|
|
79
|
+
cancelled();
|
|
80
|
+
if (!response.ok || !response.body)
|
|
81
|
+
throw new ComponentDownloadError('COMPONENT_DOWNLOAD_RESPONSE');
|
|
82
|
+
const length = response.headers.get('content-length');
|
|
83
|
+
if (length !== null) {
|
|
84
|
+
const n = Number(length);
|
|
85
|
+
if (!/^\d+$/.test(length) || !Number.isSafeInteger(n) || n < 0)
|
|
86
|
+
throw new ComponentDownloadError('COMPONENT_DOWNLOAD_LENGTH_INVALID');
|
|
87
|
+
totalBytes = n;
|
|
88
|
+
if (n > s.maxDownloadBytes) {
|
|
89
|
+
await response.body.cancel();
|
|
90
|
+
throw new ComponentDownloadError('COMPONENT_DOWNLOAD_SIZE');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
emit('downloading');
|
|
94
|
+
const file = await open(archive, 'wx', 0o600), hash = createHash('sha256');
|
|
95
|
+
let total = 0;
|
|
96
|
+
try {
|
|
97
|
+
for await (const chunk of response.body) {
|
|
98
|
+
cancelled();
|
|
99
|
+
total += chunk.byteLength;
|
|
100
|
+
receivedBytes = total;
|
|
101
|
+
if (total > s.maxDownloadBytes)
|
|
102
|
+
throw new ComponentDownloadError('COMPONENT_DOWNLOAD_SIZE');
|
|
103
|
+
hash.update(chunk);
|
|
104
|
+
await file.write(chunk);
|
|
105
|
+
emit('downloading');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
await file.close();
|
|
110
|
+
}
|
|
111
|
+
cancelled();
|
|
112
|
+
emit('verifying');
|
|
113
|
+
if (hash.digest('hex') !== s.sha256)
|
|
114
|
+
throw new ComponentDownloadError('COMPONENT_SHA256_MISMATCH');
|
|
115
|
+
const runtime = join(temp, 'runtime');
|
|
116
|
+
await privateDirectory(runtime);
|
|
117
|
+
emit('unpacking');
|
|
118
|
+
cancelled();
|
|
119
|
+
if (io.unpack)
|
|
120
|
+
await io.unpack(archive, runtime, s.maxExtractedBytes);
|
|
121
|
+
else
|
|
122
|
+
await unpackZip(archive, runtime, s.maxExtractedBytes, io.signal);
|
|
123
|
+
cancelled();
|
|
124
|
+
let unpacked = 0;
|
|
125
|
+
const measure = async (dir) => { for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
126
|
+
const p = join(dir, entry.name);
|
|
127
|
+
if (entry.isDirectory())
|
|
128
|
+
await measure(p);
|
|
129
|
+
else if (entry.isFile())
|
|
130
|
+
unpacked += (await lstat(p)).size;
|
|
131
|
+
if (unpacked > s.maxExtractedBytes)
|
|
132
|
+
throw new Error('EXTRACTED_SIZE_EXCEEDED');
|
|
133
|
+
} };
|
|
134
|
+
await measure(runtime);
|
|
135
|
+
const exe = join(runtime, s.executable), st = await lstat(exe);
|
|
136
|
+
if (!st.isFile() || (st.mode & 0o100) === 0)
|
|
137
|
+
throw new ComponentDownloadError('COMPONENT_EXECUTABLE_INVALID');
|
|
138
|
+
await writeFile(join(temp, 'receipt.json'), JSON.stringify({ sha256: s.sha256, treeHash: await treeHash(runtime) }), { mode: 0o600 });
|
|
139
|
+
await rm(archive);
|
|
140
|
+
cancelled();
|
|
141
|
+
await rename(temp, final);
|
|
142
|
+
temp = undefined;
|
|
143
|
+
cancelled();
|
|
144
|
+
emit('ready');
|
|
145
|
+
return ok(executable);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
const code = io.signal?.aborted ? 'COMPONENT_DOWNLOAD_CANCELLED' : error instanceof ComponentDownloadError ? error.message : 'COMPONENT_CACHE_IO';
|
|
149
|
+
emit(code === 'COMPONENT_DOWNLOAD_CANCELLED' ? 'cancelled' : 'failed', code);
|
|
150
|
+
return { ok: false, error: { ...need('LOGIN_FAILED'), requires: '登录组件从官方地址下载并通过完整校验;未重试或换源', officialReason: code } };
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
if (temp)
|
|
154
|
+
await rm(temp, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type ComponentPhase = 'idle' | 'downloading' | 'verifying' | 'unpacking' | 'ready' | 'failed' | 'cancelled';
|
|
2
|
+
export interface ComponentProgress {
|
|
3
|
+
phase: ComponentPhase;
|
|
4
|
+
receivedBytes: number;
|
|
5
|
+
totalBytes: number | null;
|
|
6
|
+
errorCode?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ComponentDownloadState extends ComponentProgress {
|
|
9
|
+
instanceId: string;
|
|
10
|
+
revision: number;
|
|
11
|
+
attemptId: string | null;
|
|
12
|
+
}
|
|
13
|
+
export declare function createComponentProgress(): {
|
|
14
|
+
getState: () => ComponentDownloadState;
|
|
15
|
+
onState(fn: () => void): () => void;
|
|
16
|
+
begin(): {
|
|
17
|
+
attemptId: `${string}-${string}-${string}-${string}-${string}`;
|
|
18
|
+
update: (progress: ComponentProgress) => void;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
export type ComponentProgressStore = ReturnType<typeof createComponentProgress>;
|
|
22
|
+
export declare class ComponentDownloadError extends Error {
|
|
23
|
+
constructor(code: string);
|
|
24
|
+
}
|
|
25
|
+
export declare function downloadComponent(source: string, signal: AbortSignal, maxRedirects: number): Promise<Response>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import https from 'node:https';
|
|
2
|
+
import { Readable } from 'node:stream';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
export function createComponentProgress() {
|
|
5
|
+
let state = { instanceId: randomUUID(), revision: 0, attemptId: null, phase: 'idle', receivedBytes: 0, totalBytes: null };
|
|
6
|
+
const listeners = new Set();
|
|
7
|
+
return { getState: () => structuredClone(state), onState(fn) { listeners.add(fn); return () => { listeners.delete(fn); }; }, begin() { const attemptId = randomUUID(); const update = (progress) => { if (state.attemptId !== attemptId)
|
|
8
|
+
return; state = { ...progress, instanceId: state.instanceId, revision: state.revision + 1, attemptId }; for (const fn of listeners)
|
|
9
|
+
fn(); }; state = { ...state, attemptId }; update({ phase: 'downloading', receivedBytes: 0, totalBytes: null }); return { attemptId, update }; } };
|
|
10
|
+
}
|
|
11
|
+
export class ComponentDownloadError extends Error {
|
|
12
|
+
constructor(code) { super(code); this.name = 'ComponentDownloadError'; }
|
|
13
|
+
}
|
|
14
|
+
const sourceUrl = (source) => { let u; try {
|
|
15
|
+
u = new URL(source);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new ComponentDownloadError('COMPONENT_SOURCE_INVALID');
|
|
19
|
+
} if (u.protocol !== 'https:' || u.username || u.password)
|
|
20
|
+
throw new ComponentDownloadError('COMPONENT_SOURCE_INVALID'); return u; };
|
|
21
|
+
// 仅固定组件供给。不接收OAuth URL、ProxyLease、认证头或cookie;不读取系统/N代理。
|
|
22
|
+
export async function downloadComponent(source, signal, maxRedirects) {
|
|
23
|
+
if (!Number.isSafeInteger(maxRedirects) || maxRedirects < 0)
|
|
24
|
+
throw new ComponentDownloadError('COMPONENT_REDIRECT_LIMIT_INVALID');
|
|
25
|
+
let url = sourceUrl(source);
|
|
26
|
+
for (let redirects = 0;; redirects++) {
|
|
27
|
+
if (signal.aborted)
|
|
28
|
+
throw new ComponentDownloadError('COMPONENT_DOWNLOAD_CANCELLED');
|
|
29
|
+
const response = await new Promise((yes, no) => {
|
|
30
|
+
const req = https.request(url, { signal, headers: { Accept: 'application/octet-stream' } }, yes);
|
|
31
|
+
req.once('error', () => no(new ComponentDownloadError(signal.aborted ? 'COMPONENT_DOWNLOAD_CANCELLED' : 'COMPONENT_DOWNLOAD_NETWORK')));
|
|
32
|
+
req.end();
|
|
33
|
+
});
|
|
34
|
+
const status = response.statusCode ?? 0;
|
|
35
|
+
if ([301, 302, 303, 307, 308].includes(status)) {
|
|
36
|
+
const location = response.headers.location;
|
|
37
|
+
response.destroy();
|
|
38
|
+
if (redirects >= maxRedirects)
|
|
39
|
+
throw new ComponentDownloadError('COMPONENT_REDIRECT_LIMIT');
|
|
40
|
+
if (!location)
|
|
41
|
+
throw new ComponentDownloadError('COMPONENT_REDIRECT_INVALID');
|
|
42
|
+
try {
|
|
43
|
+
url = sourceUrl(new URL(location, url).href);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw new ComponentDownloadError('COMPONENT_REDIRECT_INVALID');
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (status !== 200) {
|
|
51
|
+
response.destroy();
|
|
52
|
+
throw new ComponentDownloadError('COMPONENT_DOWNLOAD_HTTP_' + status);
|
|
53
|
+
}
|
|
54
|
+
const headers = new Headers();
|
|
55
|
+
for (const name of ['content-length', 'content-type']) {
|
|
56
|
+
const v = response.headers[name];
|
|
57
|
+
if (typeof v === 'string')
|
|
58
|
+
headers.set(name, v);
|
|
59
|
+
}
|
|
60
|
+
const reader = Readable.toWeb(response).getReader();
|
|
61
|
+
const body = new ReadableStream({ async pull(controller) { try {
|
|
62
|
+
const part = await reader.read();
|
|
63
|
+
if (part.done)
|
|
64
|
+
controller.close();
|
|
65
|
+
else
|
|
66
|
+
controller.enqueue(part.value);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
controller.error(new ComponentDownloadError(signal.aborted ? 'COMPONENT_DOWNLOAD_CANCELLED' : 'COMPONENT_DOWNLOAD_BODY'));
|
|
70
|
+
} }, cancel: reason => reader.cancel(reason) });
|
|
71
|
+
return new Response(body, { status, headers });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ComponentProgressStore } from './component-download.ts';
|
|
2
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
3
|
+
export interface ComponentInventory {
|
|
4
|
+
network: string;
|
|
5
|
+
connect: string;
|
|
6
|
+
claude: string;
|
|
7
|
+
codex?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function registerComponents(ctx: Context, inventory?: ComponentInventory, temporaryDataParent?: string, component?: ComponentProgressStore): void;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { fail } from "./needs.js";
|
|
2
|
+
import { employeeMessage } from "./startup.js";
|
|
3
|
+
export function registerComponents(ctx, inventory, temporaryDataParent, component) {
|
|
4
|
+
const api = () => ctx.get('aiWorkbenchConnect');
|
|
5
|
+
const getState = () => {
|
|
6
|
+
const network = ctx.get('aiWorkbenchNetwork'), connect = api(), state = connect?.getState(), networkState = network?.getState();
|
|
7
|
+
return { workspaceBindings: connect?.getWorkspaceBindingState?.() ?? null, loginComponent: { temporaryDataParent: temporaryDataParent ?? null, download: component?.getState() ?? null }, startup: connect?.getLoginPromptState?.() ?? null, inventory: inventory ?? null, network: { serviceProvided: !!network, phase: networkState?.phase ?? 'missing', needCode: networkState?.error?.code ?? networkState?.need?.code }, enrollment: connect?.getEnrollmentState?.() ?? null, connection: connect?.getConnectionState?.() ?? null, connect: { serviceProvided: !!connect, phase: state?.phase ?? 'missing', message: state?.need ? employeeMessage(state.need.code) : '连接服务等待就绪' }, codex: inventory ? { installed: !!inventory.codex, message: inventory.codex ? 'Codex 组件已安装' : 'Codex 组件未安装' } : { installed: null, message: '组件清单未装配' } };
|
|
8
|
+
};
|
|
9
|
+
ctx.provide('aiWorkbenchComponents', { getState });
|
|
10
|
+
ctx.inject(['webServer', 'connection'], host => {
|
|
11
|
+
const web = host.get('webServer'), connection = host.get('connection');
|
|
12
|
+
host.effect(() => {
|
|
13
|
+
const streams = new Set();
|
|
14
|
+
const off = web.register({ kind: 'prefix', path: '/ai-workbench-connect', handler: async (req, res) => {
|
|
15
|
+
const rejected = connection.requestRejection(req);
|
|
16
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
17
|
+
if (rejected !== undefined) {
|
|
18
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
19
|
+
res.statusCode = rejected;
|
|
20
|
+
res.end(JSON.stringify({ error: 'forbidden' }));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const path = new URL(req.url ?? '/', 'http://dsh').pathname, service = api();
|
|
24
|
+
if (req.method === 'GET' && path === '/ai-workbench-connect/events' && service) {
|
|
25
|
+
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
|
26
|
+
res.statusCode = 200;
|
|
27
|
+
let closed = false;
|
|
28
|
+
let unprompt = () => { }, unconnection = () => { }, unenrollment = () => { }, unnative = () => { }, undownload = () => { };
|
|
29
|
+
const close = () => { if (closed)
|
|
30
|
+
return; closed = true; unprompt(); unconnection(); unenrollment(); unnative(); undownload(); streams.delete(close); res.end(); };
|
|
31
|
+
const send = () => { if (closed)
|
|
32
|
+
return; try {
|
|
33
|
+
if (!res.write('data: ' + JSON.stringify(getState()) + '\n\n'))
|
|
34
|
+
close();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
close();
|
|
38
|
+
} };
|
|
39
|
+
unprompt = service.onLoginPromptState(send);
|
|
40
|
+
unconnection = service.onConnectionState(send);
|
|
41
|
+
unenrollment = service.onEnrollmentState?.(send) ?? (() => { });
|
|
42
|
+
unnative = service.onWorkspaceBindingState?.(send) ?? (() => { });
|
|
43
|
+
undownload = component?.onState(send) ?? (() => { });
|
|
44
|
+
streams.add(close);
|
|
45
|
+
res.once('close', close);
|
|
46
|
+
send();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
50
|
+
let actionError, actionErrorCode;
|
|
51
|
+
if (req.method === 'POST' && service) {
|
|
52
|
+
let result;
|
|
53
|
+
if (path === '/ai-workbench-connect/enrollment/import') {
|
|
54
|
+
if (Number(req.headers?.['content-length'] ?? 0) > 0 || req.headers?.['transfer-encoding']) {
|
|
55
|
+
res.statusCode = 400;
|
|
56
|
+
res.end(JSON.stringify({ error: 'no-input-accepted' }));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
result = await service.importEnrollment();
|
|
60
|
+
}
|
|
61
|
+
else if (/^\/ai-workbench-connect\/workspace-notice\/[a-zA-Z0-9-]+\/displayed$/.test(path)) {
|
|
62
|
+
if (Number(req.headers?.['content-length'] ?? 0) > 0 || req.headers?.['transfer-encoding']) {
|
|
63
|
+
res.statusCode = 400;
|
|
64
|
+
res.end(JSON.stringify({ error: 'no-input-accepted' }));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const id = /\/workspace-notice\/([a-zA-Z0-9-]+)\/displayed$/.exec(path)[1];
|
|
68
|
+
result = await service.acknowledgeWorkspaceNotice({ noticeId: id, surface: 'settings', outcome: 'displayed' });
|
|
69
|
+
}
|
|
70
|
+
else if (path === '/ai-workbench-connect/check')
|
|
71
|
+
result = await service.refreshLoginPrompts();
|
|
72
|
+
else if (/^\/ai-workbench-connect\/start-login\/(claude|codex)\/[a-zA-Z0-9-]+\/\d+$/.test(path)) {
|
|
73
|
+
const [, provider, instance, revision] = /\/start-login\/(claude|codex)\/([a-zA-Z0-9-]+)\/(\d+)$/.exec(path);
|
|
74
|
+
const brand = provider, shown = service.getLoginPromptState(), before = service.getConnectionState();
|
|
75
|
+
if (before.phase !== 'connected' || shown.instanceId !== instance || shown.revision !== Number(revision) || shown.phase === 'authorizing' || shown.logins[brand] !== false || shown.checkingProviders.includes(brand) || shown.providerNeeds[brand])
|
|
76
|
+
result = fail('LOGIN_PROMPT_INVALID');
|
|
77
|
+
else {
|
|
78
|
+
const requested = await service.requestLoginPrompt(brand, 'user'), after = service.getConnectionState();
|
|
79
|
+
if (after.phase !== 'connected' || after.instanceId !== before.instanceId || after.epoch !== before.epoch)
|
|
80
|
+
result = fail('LOGIN_PROMPT_INVALID');
|
|
81
|
+
else if (!requested.ok)
|
|
82
|
+
result = requested;
|
|
83
|
+
else if (requested.value.phase !== 'login-required' || requested.value.provider !== brand || !requested.value.requestId)
|
|
84
|
+
result = fail('LOGIN_PROMPT_INVALID');
|
|
85
|
+
else
|
|
86
|
+
result = await service.respondLoginPrompt(requested.value.requestId, 'authorize');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else if (/^\/ai-workbench-connect\/(request|authorize)\/(claude|codex)$/.test(path)) {
|
|
90
|
+
const provider = /\/(claude|codex)$/.exec(path)[1];
|
|
91
|
+
result = await service.requestLoginPrompt(provider, 'user');
|
|
92
|
+
}
|
|
93
|
+
else if (/^\/ai-workbench-connect\/respond\/[a-zA-Z0-9-]+\/(authorize|defer)$/.test(path)) {
|
|
94
|
+
const [, id, action] = /\/respond\/([a-zA-Z0-9-]+)\/(authorize|defer)$/.exec(path);
|
|
95
|
+
result = await service.respondLoginPrompt(id, action);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
res.statusCode = 404;
|
|
99
|
+
res.end(JSON.stringify({ error: 'not-found' }));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!result.ok) {
|
|
103
|
+
res.statusCode = 409;
|
|
104
|
+
actionErrorCode = result.error.code;
|
|
105
|
+
actionError = result.error.code === 'LOGIN_PROMPT_INVALID' ? result.error.message : employeeMessage(result.error.code);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else if (req.method !== 'GET' || path !== '/ai-workbench-connect/status') {
|
|
109
|
+
res.statusCode = 404;
|
|
110
|
+
res.end(JSON.stringify({ error: 'not-found' }));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (!actionError)
|
|
114
|
+
res.statusCode = 200;
|
|
115
|
+
res.end(JSON.stringify({ ...getState(), ...(actionError ? { actionError, actionErrorCode } : {}) }));
|
|
116
|
+
} });
|
|
117
|
+
return () => { off(); for (const close of [...streams])
|
|
118
|
+
close(); };
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
package/lib/connect.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ConnectApi, ConnectState, Provider, ConnectionState, LoginPromptState, LoginPromptResult } from './api.ts';
|
|
2
|
+
import type { Enrollment, Ports } from './ports.ts';
|
|
3
|
+
export declare function validEnrollment(e: Enrollment): boolean;
|
|
4
|
+
export interface ConnectOptions {
|
|
5
|
+
providers?: Provider[];
|
|
6
|
+
onConnectionState?: (s: ConnectionState) => void;
|
|
7
|
+
onPromptState?: (s: LoginPromptState) => void;
|
|
8
|
+
onPromptResult?: (r: LoginPromptResult) => void;
|
|
9
|
+
}
|
|
10
|
+
export declare function createConnect(ports: Ports, emit: (s: ConnectState) => void, options?: ConnectOptions): ConnectApi;
|