@ai-workbench/connect 0.0.0-stage → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/NOTICE.md +18 -0
  2. package/README.md +26 -2
  3. package/client/index.cjs +124 -0
  4. package/cordis.patch.yml +3 -0
  5. package/helper/auth-status.cjs +18 -0
  6. package/helper/claude-callback.cjs +29 -0
  7. package/helper/main.cjs +19 -0
  8. package/helper/projects.cjs +57 -0
  9. package/helper/remote-command.cjs +15 -0
  10. package/helper/remote-login.cjs +25 -0
  11. package/helper/window.cjs +52 -0
  12. package/lib/api.d.ts +196 -0
  13. package/lib/api.js +1 -0
  14. package/lib/archive.d.ts +1 -0
  15. package/lib/archive.js +63 -0
  16. package/lib/auth-status.d.ts +3 -0
  17. package/lib/auth-status.js +18 -0
  18. package/lib/cache.d.ts +21 -0
  19. package/lib/cache.js +156 -0
  20. package/lib/component-download.d.ts +32 -0
  21. package/lib/component-download.js +76 -0
  22. package/lib/component-source.d.ts +52 -0
  23. package/lib/component-source.js +146 -0
  24. package/lib/components.d.ts +10 -0
  25. package/lib/components.js +154 -0
  26. package/lib/connect.d.ts +10 -0
  27. package/lib/connect.js +246 -0
  28. package/lib/cordis.d.ts +15 -0
  29. package/lib/cordis.js +1 -0
  30. package/lib/defaults.d.ts +30 -0
  31. package/lib/defaults.js +4 -0
  32. package/lib/electron.d.ts +8 -0
  33. package/lib/electron.js +83 -0
  34. package/lib/enrollment.d.ts +3 -0
  35. package/lib/enrollment.js +78 -0
  36. package/lib/index.d.ts +42 -0
  37. package/lib/index.js +55 -0
  38. package/lib/integration.d.ts +6 -0
  39. package/lib/integration.js +56 -0
  40. package/lib/login.d.ts +35 -0
  41. package/lib/login.js +81 -0
  42. package/lib/native-import-picker.d.ts +11 -0
  43. package/lib/native-import-picker.js +49 -0
  44. package/lib/needs.d.ts +9 -0
  45. package/lib/needs.js +52 -0
  46. package/lib/network.d.ts +26 -0
  47. package/lib/network.js +226 -0
  48. package/lib/physical-fs.d.ts +3 -0
  49. package/lib/physical-fs.js +7 -0
  50. package/lib/ports.d.ts +43 -0
  51. package/lib/ports.js +1 -0
  52. package/lib/private-files.d.ts +3 -0
  53. package/lib/private-files.js +41 -0
  54. package/lib/processes.d.ts +5 -0
  55. package/lib/processes.js +147 -0
  56. package/lib/profile-activation.d.ts +32 -0
  57. package/lib/profile-activation.js +118 -0
  58. package/lib/profile-enrollment.d.ts +21 -0
  59. package/lib/profile-enrollment.js +275 -0
  60. package/lib/profile-runtime.d.ts +6 -0
  61. package/lib/profile-runtime.js +47 -0
  62. package/lib/profile-settings.d.ts +2 -0
  63. package/lib/profile-settings.js +13 -0
  64. package/lib/projects.d.ts +4 -0
  65. package/lib/projects.js +41 -0
  66. package/lib/provider.d.ts +73 -0
  67. package/lib/provider.js +39 -0
  68. package/lib/remote-login.d.ts +2 -0
  69. package/lib/remote-login.js +9 -0
  70. package/lib/ssh.d.ts +10 -0
  71. package/lib/ssh.js +78 -0
  72. package/lib/startup.d.ts +18 -0
  73. package/lib/startup.js +211 -0
  74. package/lib/workspace-bindings.d.ts +32 -0
  75. package/lib/workspace-bindings.js +328 -0
  76. package/locale/en.json +6 -0
  77. package/locale/zh.json +6 -0
  78. package/node_modules/argparse/LICENSE +254 -0
  79. package/node_modules/argparse/README.md +84 -0
  80. package/node_modules/argparse/argparse.js +3707 -0
  81. package/node_modules/argparse/lib/sub.js +67 -0
  82. package/node_modules/argparse/lib/textwrap.js +440 -0
  83. package/node_modules/argparse/package.json +31 -0
  84. package/node_modules/js-yaml/LICENSE +21 -0
  85. package/node_modules/js-yaml/README.md +233 -0
  86. package/node_modules/js-yaml/bin/js-yaml.js +117 -0
  87. package/node_modules/js-yaml/dist/js-yaml.js +3022 -0
  88. package/node_modules/js-yaml/dist/js-yaml.js.map +1 -0
  89. package/node_modules/js-yaml/dist/js-yaml.min.js +2 -0
  90. package/node_modules/js-yaml/dist/js-yaml.min.js.map +1 -0
  91. package/node_modules/js-yaml/dist/js-yaml.mjs +3082 -0
  92. package/node_modules/js-yaml/dist/js-yaml.mjs.map +1 -0
  93. package/node_modules/js-yaml/index.js +44 -0
  94. package/node_modules/js-yaml/lib/common.js +50 -0
  95. package/node_modules/js-yaml/lib/dumper.js +937 -0
  96. package/node_modules/js-yaml/lib/exception.js +51 -0
  97. package/node_modules/js-yaml/lib/index_vite_proxy.tmp.mjs +37 -0
  98. package/node_modules/js-yaml/lib/loader.js +1803 -0
  99. package/node_modules/js-yaml/lib/schema/core.js +9 -0
  100. package/node_modules/js-yaml/lib/schema/default.js +20 -0
  101. package/node_modules/js-yaml/lib/schema/failsafe.js +14 -0
  102. package/node_modules/js-yaml/lib/schema/json.js +17 -0
  103. package/node_modules/js-yaml/lib/schema.js +109 -0
  104. package/node_modules/js-yaml/lib/snippet.js +96 -0
  105. package/node_modules/js-yaml/lib/type/binary.js +122 -0
  106. package/node_modules/js-yaml/lib/type/bool.js +35 -0
  107. package/node_modules/js-yaml/lib/type/float.js +99 -0
  108. package/node_modules/js-yaml/lib/type/int.js +142 -0
  109. package/node_modules/js-yaml/lib/type/map.js +8 -0
  110. package/node_modules/js-yaml/lib/type/merge.js +12 -0
  111. package/node_modules/js-yaml/lib/type/null.js +35 -0
  112. package/node_modules/js-yaml/lib/type/omap.js +45 -0
  113. package/node_modules/js-yaml/lib/type/pairs.js +50 -0
  114. package/node_modules/js-yaml/lib/type/seq.js +8 -0
  115. package/node_modules/js-yaml/lib/type/set.js +29 -0
  116. package/node_modules/js-yaml/lib/type/str.js +8 -0
  117. package/node_modules/js-yaml/lib/type/timestamp.js +88 -0
  118. package/node_modules/js-yaml/lib/type.js +66 -0
  119. package/node_modules/js-yaml/package.json +83 -0
  120. package/node_modules/pend/LICENSE +23 -0
  121. package/node_modules/pend/README.md +41 -0
  122. package/node_modules/pend/index.js +55 -0
  123. package/node_modules/pend/package.json +18 -0
  124. package/node_modules/pend/test.js +137 -0
  125. package/node_modules/yauzl/LICENSE +21 -0
  126. package/node_modules/yauzl/README.md +1015 -0
  127. package/node_modules/yauzl/crc32.js +45 -0
  128. package/node_modules/yauzl/fd-slicer.js +188 -0
  129. package/node_modules/yauzl/index.js +1096 -0
  130. package/node_modules/yauzl/package.json +40 -0
  131. package/package.json +89 -4
package/lib/network.js ADDED
@@ -0,0 +1,226 @@
1
+ import { request } from 'node:http';
2
+ import { lstat, writeFile, rename, rm } from 'node:fs/promises';
3
+ import { join, dirname, isAbsolute } from 'node:path';
4
+ import { hostname } from 'node:os';
5
+ import { StringDecoder } from 'node:string_decoder';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { need, dependency, ok, fail, NeedError } from "./needs.js";
8
+ import { privateDirectory } from "./private-files.js";
9
+ import { proxyCommand } from "./ssh.js";
10
+ function valid(s) { return [s.executable, s.runtimeDir, s.stateDir].every(p => typeof p === 'string' && isAbsolute(p) && !/[\x00-\x1f\x7f]/.test(p)) && typeof s.proxyListen === 'string' && /^127\.0\.0\.1:(?:0|[1-9][0-9]*)$/.test(s.proxyListen) && Number(s.proxyListen.split(':')[1]) <= 65535 && s.logUpload === false && [s.statusRefreshIntervalSeconds, s.dialTimeoutSeconds, s.headerTimeoutSeconds, s.enrollmentObservationSeconds].every(n => typeof n === 'number' && Number.isFinite(n) && n >= 1e-9 && n <= 9223372036) && [s.statusRefreshIntervalSeconds, s.headerTimeoutSeconds].every(n => Number.isInteger(n * 1000) && n * 1000 >= 1 && n * 1000 <= 2147483647) && [s.httpMaxHeaderBytes, s.httpMaxResponseHeaderBytes].every(n => Number.isSafeInteger(n) && n > 0) && Number.isSafeInteger(s.controlMaxBytes) && s.controlMaxBytes > 0 && s.controlMaxBytes <= 2147483647; }
11
+ function mapped(code) { return { NEED_ENROLLMENT: 'ENROLLMENT_REQUIRED', ENROLLMENT_REJECTED: 'NETWORK_KEY_INVALID', ENROLLMENT_RESULT_UNKNOWN: 'ENROLLMENT_INVALID', LOCAL_STATE_INVALID: 'ENROLLMENT_INVALID', HOST_OFFLINE: 'HOST_OFFLINE', HOST_NOT_VISIBLE: 'DEPENDENCY_REQUIRED', CONFIG_INVALID: 'SETTINGS_REQUIRED', CAPABILITY_NOT_IMPLEMENTED: 'DEPENDENCY_REQUIRED', NEED_HOST_CONTRACT: 'DEPENDENCY_REQUIRED', NETWORK_INTERRUPTED: 'CONNECTION_LOST', CONTROL_UNAVAILABLE: 'DEPENDENCY_REQUIRED' }[String(code)] ?? 'DEPENDENCY_REQUIRED'; }
12
+ export async function writeNetworkConfig(file, e, s) {
13
+ if (!valid(s) || !isAbsolute(file))
14
+ return fail('SETTINGS_REQUIRED');
15
+ let temp;
16
+ try {
17
+ await privateDirectory(dirname(file));
18
+ const config = { apiVersion: 1, stateDir: s.stateDir, runtimeDir: s.runtimeDir, nodeName: `aw-${e.username}-${hostname()}-${process.getuid()}`, ...(e.authKeyFile ? { authKeyFile: e.authKeyFile } : {}), host: { name: e.host, sshPort: e.port, employee: e.username, knownHostsFile: e.knownHostsFile }, proxyListen: s.proxyListen, statusRefreshIntervalSeconds: s.statusRefreshIntervalSeconds, dialTimeoutSeconds: s.dialTimeoutSeconds, headerTimeoutSeconds: s.headerTimeoutSeconds, enrollmentObservationSeconds: s.enrollmentObservationSeconds, logUpload: s.logUpload, httpMaxHeaderBytes: s.httpMaxHeaderBytes, httpMaxResponseHeaderBytes: s.httpMaxResponseHeaderBytes };
19
+ temp = join(dirname(file), '.network-' + randomUUID());
20
+ await writeFile(temp, JSON.stringify(config), { flag: 'wx', mode: 0o600 });
21
+ await rename(temp, file);
22
+ temp = undefined;
23
+ return ok(undefined);
24
+ }
25
+ catch {
26
+ return dependency('可写的私有 N 配置目录');
27
+ }
28
+ finally {
29
+ if (temp)
30
+ await rm(temp, { force: true });
31
+ }
32
+ }
33
+ export function createNetworkClient(s) {
34
+ const listeners = new Set();
35
+ let timer, previous, refreshing = false, disposed = false;
36
+ const pending = new Set();
37
+ let expected;
38
+ const bad = (code) => { throw new NeedError(need(code)); };
39
+ async function socket() {
40
+ if (!valid(s))
41
+ bad('SETTINGS_REQUIRED');
42
+ const dir = await lstat(s.runtimeDir), sock = await lstat(join(s.runtimeDir, 'control.sock'));
43
+ if (!dir.isDirectory() || dir.isSymbolicLink() || dir.uid !== process.getuid() || (dir.mode & 0o077) || !sock.isSocket() || sock.isSymbolicLink() || sock.uid !== process.getuid() || (sock.mode & 0o177))
44
+ bad('DEPENDENCY_REQUIRED');
45
+ }
46
+ async function send(method, path, signal) {
47
+ try {
48
+ await socket();
49
+ }
50
+ catch (e) {
51
+ if (e instanceof NeedError)
52
+ throw e;
53
+ bad('DEPENDENCY_REQUIRED');
54
+ }
55
+ if (disposed || signal.aborted)
56
+ bad('CONNECTION_LOST');
57
+ return new Promise((resolve, reject) => { const req = request({ socketPath: join(s.runtimeDir, 'control.sock'), path, method, signal, agent: false }, response => { clearTimeout(deadline); resolve(response); }); const deadline = setTimeout(() => req.destroy(new Error('CONTROL_HEADER_TIMEOUT')), s.headerTimeoutSeconds * 1000); req.on('error', () => { clearTimeout(deadline); reject(new NeedError(need('DEPENDENCY_REQUIRED'))); }); req.end(); });
58
+ }
59
+ async function json(res) { let size = 0; const chunks = []; try {
60
+ for await (const chunk of res) {
61
+ size += chunk.length;
62
+ if (size > s.controlMaxBytes)
63
+ bad('DEPENDENCY_REQUIRED');
64
+ chunks.push(chunk);
65
+ }
66
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
67
+ }
68
+ catch {
69
+ res.destroy();
70
+ bad('DEPENDENCY_REQUIRED');
71
+ } }
72
+ async function status(signal) {
73
+ try {
74
+ const res = await send('GET', '/v1/status', signal), v = await json(res);
75
+ if (v?.apiVersion !== 1)
76
+ return dependency('N v1 状态协议');
77
+ if (res.statusCode !== 200 || v.error) {
78
+ const result = fail(mapped(v.error?.code));
79
+ if (!result.ok && result.error.code === 'HOST_OFFLINE' && v.host?.name === expected?.host && typeof v.host.lastSeenAt === 'string' && !Number.isNaN(Date.parse(v.host.lastSeenAt)))
80
+ result.error.lastSeen = v.host.lastSeenAt;
81
+ return result;
82
+ }
83
+ if (v.enrollment !== 'online')
84
+ return fail(v.enrollment === 'needs_enrollment' ? 'ENROLLMENT_REQUIRED' : v.enrollment === 'rejected' ? 'NETWORK_KEY_INVALID' : 'CONNECTION_LOST');
85
+ if (v.host?.name !== expected?.host)
86
+ return dependency('与开通主机匹配的 N 状态');
87
+ if (v.host.online === false) {
88
+ const result = fail('HOST_OFFLINE');
89
+ if (!result.ok && typeof v.host.lastSeenAt === 'string' && !Number.isNaN(Date.parse(v.host.lastSeenAt)))
90
+ result.error.lastSeen = v.host.lastSeenAt;
91
+ return result;
92
+ }
93
+ if (v.host.online !== true)
94
+ return dependency('N 已核实的主机在线状态');
95
+ return ok(undefined);
96
+ }
97
+ catch (e) {
98
+ return e instanceof NeedError ? { ok: false, error: e.need } : dependency('N 私有控制 socket;未使用直连网络');
99
+ }
100
+ }
101
+ async function refresh() { if (refreshing || !expected || disposed)
102
+ return; refreshing = true; const c = new AbortController(); pending.add(c); try {
103
+ const r = await status(c.signal), online = r.ok;
104
+ if (previous !== undefined && previous !== online)
105
+ for (const listener of listeners)
106
+ listener({ kind: online ? 'restored' : 'lost' });
107
+ previous = online;
108
+ }
109
+ finally {
110
+ pending.delete(c);
111
+ refreshing = false;
112
+ } }
113
+ return {
114
+ async connect(e, signal) {
115
+ expected = e;
116
+ const result = await status(signal);
117
+ previous = result.ok;
118
+ if (!result.ok)
119
+ return result;
120
+ try {
121
+ const exe = await lstat(s.executable);
122
+ if (!exe.isFile() || exe.isSymbolicLink() || !(exe.mode & 0o111))
123
+ return dependency('N 真实 helper 可执行程序');
124
+ return ok({ proxyCommand: proxyCommand(s.executable, s.runtimeDir) });
125
+ }
126
+ catch {
127
+ return dependency('N 真实 helper 可执行程序');
128
+ }
129
+ },
130
+ onState(listener) { listeners.add(listener); if (valid(s) && !timer) {
131
+ timer = setInterval(() => { void refresh(); }, s.statusRefreshIntervalSeconds * 1000);
132
+ timer.unref();
133
+ } return () => { listeners.delete(listener); if (!listeners.size && timer) {
134
+ clearInterval(timer);
135
+ timer = undefined;
136
+ for (const c of pending)
137
+ c.abort();
138
+ } }; },
139
+ async acquireProxy(signal) {
140
+ const res = await send('POST', '/v1/login-proxy', signal);
141
+ if (res.statusCode !== 201) {
142
+ const v = await json(res);
143
+ if (v?.apiVersion !== 1)
144
+ bad('DEPENDENCY_REQUIRED');
145
+ bad(mapped(v.error?.code));
146
+ }
147
+ if (!String(res.headers['content-type']).startsWith('application/x-ndjson')) {
148
+ res.destroy();
149
+ bad('DEPENDENCY_REQUIRED');
150
+ }
151
+ let resolveLine = () => { }, rejectLine = () => { }, rejectClosed = () => { }, resolveClosed = () => { };
152
+ let text = '', size = 0, received = false, released = false;
153
+ const decoder = new StringDecoder('utf8');
154
+ const first = new Promise((yes, no) => { resolveLine = yes; rejectLine = no; });
155
+ const closed = new Promise((yes, no) => { resolveClosed = yes; rejectClosed = no; });
156
+ void closed.catch(() => { });
157
+ const lost = () => { if (!released) {
158
+ const e = new NeedError(need('CONNECTION_LOST'));
159
+ rejectLine(e);
160
+ rejectClosed(e);
161
+ } };
162
+ res.on('error', lost);
163
+ res.on('end', lost);
164
+ res.on('close', lost);
165
+ res.on('data', (chunk) => {
166
+ size += chunk.length;
167
+ if (size > s.controlMaxBytes) {
168
+ lost();
169
+ res.destroy();
170
+ return;
171
+ }
172
+ if (received) {
173
+ if (chunk.toString().trim()) {
174
+ lost();
175
+ res.destroy();
176
+ }
177
+ return;
178
+ }
179
+ text += decoder.write(chunk);
180
+ const end = text.indexOf('\n');
181
+ if (end < 0)
182
+ return;
183
+ received = true;
184
+ try {
185
+ const v = JSON.parse(text.slice(0, end)), url = new URL(v.url);
186
+ if (v.apiVersion !== 1 || typeof v.id !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(v.id) || url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port || url.username || url.password || url.pathname !== '/' || url.search || url.hash || !['username', 'password'].every(k => typeof v[k] === 'string' && v[k].length && !/[\x00-\x1f\x7f]/.test(v[k])) || text.slice(end + 1).trim())
187
+ throw Error();
188
+ resolveLine(v);
189
+ }
190
+ catch {
191
+ rejectLine(new NeedError(need('DEPENDENCY_REQUIRED')));
192
+ res.destroy();
193
+ }
194
+ finally {
195
+ text = '';
196
+ }
197
+ });
198
+ let v;
199
+ try {
200
+ v = await first;
201
+ }
202
+ catch (e) {
203
+ res.destroy();
204
+ throw e;
205
+ }
206
+ let release;
207
+ const lease = { url: v.url, username: v.username, password: v.password, closed, release: () => release ??= (async () => { released = true; res.destroy(); try {
208
+ const c = new AbortController();
209
+ const response = await send('DELETE', '/v1/login-proxy/' + encodeURIComponent(v.id), c.signal);
210
+ response.resume();
211
+ if (![204, 404].includes(response.statusCode))
212
+ bad('DEPENDENCY_REQUIRED');
213
+ }
214
+ finally {
215
+ lease.username = '';
216
+ lease.password = '';
217
+ v = null;
218
+ resolveClosed();
219
+ } })() };
220
+ return lease;
221
+ },
222
+ dispose() { disposed = true; if (timer)
223
+ clearInterval(timer); for (const c of pending)
224
+ c.abort(); listeners.clear(); },
225
+ };
226
+ }
@@ -0,0 +1,3 @@
1
+ import * as nodeFs from 'node:fs';
2
+ export declare const createWriteStream: typeof nodeFs.createWriteStream;
3
+ export declare const open: typeof nodeFs.promises.open, lstat: typeof nodeFs.promises.lstat, readFile: typeof nodeFs.promises.readFile, writeFile: typeof nodeFs.promises.writeFile, readdir: typeof nodeFs.promises.readdir, readlink: typeof nodeFs.promises.readlink, realpath: typeof nodeFs.promises.realpath, rename: typeof nodeFs.promises.rename, rm: typeof nodeFs.promises.rm, mkdir: typeof nodeFs.promises.mkdir, symlink: typeof nodeFs.promises.symlink;
@@ -0,0 +1,7 @@
1
+ import * as nodeFs from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ // 组件归档内的.asar是待校验的物理文件,不能消费Electron的虚拟目录视图。
4
+ // 仅缓存/解压边界使用;不设置全局process.noAsar,不影响宿主模块加载。
5
+ const physical = process.versions.electron ? createRequire(import.meta.url)('original-fs') : nodeFs;
6
+ export const { createWriteStream } = physical;
7
+ export const { open, lstat, readFile, writeFile, readdir, readlink, realpath, rename, rm, mkdir, symlink } = physical.promises;
package/lib/ports.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { Provider, Result, SshTarget, SessionTarget, HostContext, Project, ProjectList } from './api.ts';
2
+ export interface Enrollment {
3
+ authKeyFile?: string;
4
+ hostContext?: HostContext;
5
+ host: string;
6
+ username: string;
7
+ port: number;
8
+ projectRoot: string;
9
+ knownHostsFile: string;
10
+ session: SessionTarget;
11
+ }
12
+ export interface CommandResult {
13
+ exitCode: number | null;
14
+ stdout: string;
15
+ stderr: string;
16
+ }
17
+ export type NetworkEvent = {
18
+ kind: 'lost';
19
+ } | {
20
+ kind: 'restored';
21
+ };
22
+ export interface Ports {
23
+ enrollment?: {
24
+ read(signal: AbortSignal): Promise<Result<Enrollment>>;
25
+ };
26
+ network?: {
27
+ connect(enrollment: Enrollment, signal: AbortSignal): Promise<Result<{
28
+ proxyCommand: string;
29
+ }>>;
30
+ onState(listener: (event: NetworkEvent) => void): () => void;
31
+ };
32
+ ssh?: {
33
+ verify(target: SshTarget, signal: AbortSignal): Promise<Result<void>>;
34
+ status(target: SshTarget, provider: Provider, signal: AbortSignal): Promise<Result<CommandResult>>;
35
+ };
36
+ projects?: {
37
+ list(t: SshTarget, s: AbortSignal): Promise<Result<ProjectList>>;
38
+ create(t: SshTarget, name: string, s: AbortSignal): Promise<Result<Project>>;
39
+ };
40
+ login?: {
41
+ run(target: SshTarget, provider: Provider, signal: AbortSignal): Promise<Result<void>>;
42
+ };
43
+ }
package/lib/ports.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ export declare function privateDirectory(path: string): Promise<void>;
2
+ export declare function privateFile(path: string): Promise<void>;
3
+ export declare function readPrivate(path: string, maxBytes?: number): Promise<string>;
@@ -0,0 +1,41 @@
1
+ import { lstat, mkdir, open } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ export async function privateDirectory(path) {
4
+ await mkdir(path, { recursive: true, mode: 0o700 });
5
+ const s = await lstat(path);
6
+ if (!s.isDirectory() || s.isSymbolicLink() || s.uid !== process.getuid() || (s.mode & 0o077) !== 0)
7
+ throw new Error('PRIVATE_DIRECTORY_REQUIRED');
8
+ }
9
+ export async function privateFile(path) {
10
+ const s = await lstat(path);
11
+ if (!s.isFile() || s.isSymbolicLink() || s.uid !== process.getuid() || (s.mode & 0o177) !== 0)
12
+ throw new Error('PRIVATE_FILE_REQUIRED');
13
+ }
14
+ export async function readPrivate(path, maxBytes) {
15
+ const h = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
16
+ try {
17
+ const s = await h.stat();
18
+ if (!s.isFile() || s.uid !== process.getuid() || (s.mode & 0o177) !== 0)
19
+ throw new Error('PRIVATE_FILE_REQUIRED');
20
+ if (maxBytes === undefined)
21
+ return await h.readFile('utf8');
22
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 2147483647 || s.size > maxBytes)
23
+ throw new Error('PRIVATE_FILE_LIMIT');
24
+ const chunks = [];
25
+ let size = 0;
26
+ for (;;) {
27
+ const buffer = Buffer.alloc(Math.min(65536, maxBytes + 1 - size));
28
+ const { bytesRead } = await h.read(buffer, 0, buffer.length, null);
29
+ if (!bytesRead)
30
+ break;
31
+ size += bytesRead;
32
+ if (size > maxBytes)
33
+ throw new Error('PRIVATE_FILE_LIMIT');
34
+ chunks.push(buffer.subarray(0, bytesRead));
35
+ }
36
+ return Buffer.concat(chunks).toString('utf8');
37
+ }
38
+ finally {
39
+ await h.close();
40
+ }
41
+ }
@@ -0,0 +1,5 @@
1
+ import type { Provider } from './api.ts';
2
+ import type { Runner } from './ssh.ts';
3
+ import type { RemoteLogin } from './login.ts';
4
+ export declare function createRunner(maxBytes: number): Runner;
5
+ export declare function createRemoteLogin(command: string, args: string[], provider: Provider, maxBytes: number, signal: AbortSignal, protocol?: 'remote-control'): Promise<RemoteLogin>;
@@ -0,0 +1,147 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { StringDecoder } from 'node:string_decoder';
3
+ import { extractLoginUrl } from "./login.js";
4
+ import { fail, ok } from "./needs.js";
5
+ function limit(n) { if (!Number.isSafeInteger(n) || n < 1)
6
+ throw new Error('OUTPUT_SETTING_REQUIRED'); }
7
+ function start(command, args, signal) {
8
+ if (signal.aborted)
9
+ throw new Error('CANCELLED');
10
+ return spawn(command, args, { shell: false, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, ELECTRON_RUN_AS_NODE: undefined } });
11
+ }
12
+ function reap(child, closed) {
13
+ if (child.exitCode === null && child.signalCode === null)
14
+ child.kill('SIGTERM');
15
+ return closed.then(() => { });
16
+ }
17
+ export function createRunner(maxBytes) {
18
+ limit(maxBytes);
19
+ return { run: async (command, args, signal, input) => {
20
+ const child = start(command, args, signal);
21
+ let stdout = '', stderr = '', size = 0, failed = false;
22
+ child.stdin.on('error', () => { failed = true; child.kill('SIGTERM'); });
23
+ child.stdin.end(input);
24
+ const finished = new Promise((resolve, reject) => {
25
+ child.once('error', () => { failed = true; });
26
+ for (const [stream, channel] of [[child.stdout, 'out'], [child.stderr, 'err']]) {
27
+ const decoder = new StringDecoder('utf8');
28
+ stream.on('data', (chunk) => { size += chunk.length; if (size > maxBytes) {
29
+ failed = true;
30
+ child.kill('SIGTERM');
31
+ return;
32
+ } if (channel === 'out')
33
+ stdout += decoder.write(chunk);
34
+ else
35
+ stderr += decoder.write(chunk); });
36
+ }
37
+ child.once('close', code => failed || signal.aborted ? reject(new Error('COMMAND_FAILED')) : resolve({ exitCode: code, stdout, stderr }));
38
+ });
39
+ const abort = () => child.kill('SIGTERM');
40
+ signal.addEventListener('abort', abort, { once: true });
41
+ try {
42
+ if (signal.aborted)
43
+ abort();
44
+ return await finished;
45
+ }
46
+ finally {
47
+ signal.removeEventListener('abort', abort);
48
+ }
49
+ } };
50
+ }
51
+ export async function createRemoteLogin(command, args, provider, maxBytes, signal, protocol) {
52
+ limit(maxBytes);
53
+ const child = start(command, args, signal);
54
+ let size = 0, invalid = false, urlSent = false, owned = false;
55
+ let terminal;
56
+ let ownResolve = () => { }, ownReject = () => { };
57
+ const ownership = new Promise((yes, no) => { ownResolve = yes; ownReject = () => no(new Error('REMOTE_LOGIN_NOT_STARTED')); });
58
+ void ownership.catch(() => { });
59
+ let found = () => { };
60
+ const url = new Promise(r => found = r);
61
+ const pending = new Map();
62
+ for (const stream of [child.stdout, child.stderr]) {
63
+ const record = { decoder: new StringDecoder('utf8'), line: '' };
64
+ pending.set(stream, record);
65
+ stream.on('data', (chunk) => {
66
+ size += chunk.length;
67
+ if (size > maxBytes) {
68
+ invalid = true;
69
+ child.kill('SIGTERM');
70
+ return;
71
+ }
72
+ record.line += record.decoder.write(chunk);
73
+ const lines = record.line.split(/\r?\n/);
74
+ record.line = lines.pop();
75
+ for (const line of lines) {
76
+ if (protocol && line.startsWith('AW_LOGIN_PROCESS ')) {
77
+ try {
78
+ const v = JSON.parse(line.slice(17));
79
+ if (stream === child.stdout && !owned && Number.isSafeInteger(v.pid) && v.pid > 0 && v.group === v.pid && v.provider === provider) {
80
+ owned = true;
81
+ ownResolve(v);
82
+ }
83
+ else
84
+ throw Error();
85
+ }
86
+ catch {
87
+ invalid = true;
88
+ child.kill('SIGTERM');
89
+ }
90
+ }
91
+ if (protocol && line.startsWith('AW_LOGIN_RESULT ')) {
92
+ try {
93
+ const v = JSON.parse(line.slice(16));
94
+ if (stream !== child.stdout || terminal || !owned || v.protocolVersion !== 1 || v.provider !== provider || Object.keys(v).sort().join(',') !== 'exitCode,protocolVersion,provider,stopping' || !Number.isInteger(v.exitCode) || v.exitCode < 0 || v.exitCode > 255 || typeof v.stopping !== 'boolean')
95
+ throw Error();
96
+ terminal = v;
97
+ }
98
+ catch {
99
+ invalid = true;
100
+ child.kill('SIGTERM');
101
+ }
102
+ }
103
+ const u = extractLoginUrl(provider, line);
104
+ if (u && !urlSent) {
105
+ urlSent = true;
106
+ found(ok(u));
107
+ }
108
+ }
109
+ });
110
+ }
111
+ child.once('error', () => { invalid = true; });
112
+ const completion = new Promise(resolve => child.once('close', code => {
113
+ for (const record of pending.values()) {
114
+ if (protocol && /^AW_LOGIN_(RESULT|PROCESS)/.test(record.line))
115
+ invalid = true;
116
+ const u = extractLoginUrl(provider, record.line + record.decoder.end());
117
+ if (u && !urlSent) {
118
+ urlSent = true;
119
+ found(ok(u));
120
+ }
121
+ record.line = '';
122
+ }
123
+ ownReject();
124
+ if (!urlSent)
125
+ found(fail('LOGIN_FAILED'));
126
+ signal.removeEventListener('abort', abort);
127
+ resolve(!invalid && !signal.aborted && code === 0 && (!protocol || (owned && terminal?.exitCode === 0 && !terminal.stopping)) ? ok(undefined) : fail('LOGIN_FAILED'));
128
+ }));
129
+ const abort = () => child.kill('SIGTERM');
130
+ signal.addEventListener('abort', abort, { once: true });
131
+ if (signal.aborted)
132
+ abort();
133
+ child.stdin.on('error', () => { invalid = true; });
134
+ return { url, completion, ...(protocol ? { ownership } : {}),
135
+ writeCode: async (code) => {
136
+ if (signal.aborted || child.exitCode !== null || child.signalCode !== null || child.stdin.writableEnded || /[\x00-\x20\x7f]/.test(code) || !code)
137
+ throw new Error('LOGIN_CODE_REJECTED');
138
+ await new Promise((yes, no) => { const failed = () => no(new Error('LOGIN_STDIN_FAILED')); child.stdin.once('error', failed); const done = () => { child.stdin.removeListener('error', failed); yes(); }; if (protocol)
139
+ child.stdin.write(JSON.stringify({ type: 'code', value: code }) + '\n', done);
140
+ else
141
+ child.stdin.end(code + '\n', done); });
142
+ },
143
+ close: () => { if (protocol && child.exitCode === null && child.signalCode === null && !child.stdin.writableEnded) {
144
+ child.stdin.end(JSON.stringify({ type: 'close' }) + '\n');
145
+ return completion.then(() => { });
146
+ } return reap(child, completion); }, };
147
+ }
@@ -0,0 +1,32 @@
1
+ import type { Enrollment } from './ports.ts';
2
+ import type { Result } from './api.ts';
3
+ import type { NetworkProvider } from './provider.ts';
4
+ export interface AssemblySettings {
5
+ configFile?: string;
6
+ helperLogFile?: string;
7
+ stateDir?: string;
8
+ runtimeDir?: string;
9
+ nodeName?: string;
10
+ proxyListen?: string;
11
+ logUpload?: boolean;
12
+ statusRefreshIntervalSeconds?: number;
13
+ dialTimeoutSeconds?: number;
14
+ headerTimeoutSeconds?: number;
15
+ enrollmentObservationSeconds?: number;
16
+ httpMaxHeaderBytes?: number;
17
+ httpMaxResponseHeaderBytes?: number;
18
+ startupObservationSeconds?: number;
19
+ shutdownObservationSeconds?: number;
20
+ controlResponseMaxBytes?: number;
21
+ downloadMaxRedirects?: number;
22
+ }
23
+ interface Options {
24
+ profileDir: string;
25
+ metadataMaxBytes: number;
26
+ network?: NetworkProvider;
27
+ settings?: AssemblySettings;
28
+ }
29
+ export declare function createProfileActivation(options: Options): {
30
+ activate(e: Enrollment, signal: AbortSignal): Promise<Result<void>>;
31
+ };
32
+ export {};
@@ -0,0 +1,118 @@
1
+ import { open, rename, rm, realpath } from 'node:fs/promises';
2
+ import { join, isAbsolute } from 'node:path';
3
+ import { hostname, homedir } from 'node:os';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { ok, fail } from "./needs.js";
6
+ import { mapNetworkNeed } from "./provider.js";
7
+ import { privateDirectory, readPrivate } from "./private-files.js";
8
+ import { NETWORK_DEFAULTS } from "./defaults.js";
9
+ // Own handoff configuration only. Never reads N's identity files, journal or key contents.
10
+ export function createProfileActivation(options) {
11
+ const root = join(options.profileDir, 'ai-workbench-connect'), recordFile = join(root, 'network-activation.json');
12
+ let flight, flightTarget;
13
+ async function save(file, value) { const bytes = JSON.stringify(value) + '\n'; if (Buffer.byteLength(bytes) > options.metadataMaxBytes)
14
+ throw Error('ACTIVATION_METADATA_LIMIT'); const temp = file + '.' + randomUUID(); try {
15
+ const h = await open(temp, 'wx', 0o600);
16
+ try {
17
+ await h.writeFile(bytes);
18
+ await h.sync();
19
+ }
20
+ finally {
21
+ await h.close();
22
+ }
23
+ await rename(temp, file);
24
+ const d = await open(join(file, '..'), 'r');
25
+ try {
26
+ await d.sync();
27
+ }
28
+ finally {
29
+ await d.close();
30
+ }
31
+ }
32
+ finally {
33
+ await rm(temp, { force: true });
34
+ } }
35
+ const receiptValid = (s) => s?.phase === 'active' && s.receipt?.apiVersion === 1 && s.receipt.session === 'identity' && s.receipt.identity === 'resumable' && ((s.receipt.key === 'confirmed' && s.receipt.cleanup === 'complete') || (s.receipt.key === 'not-required' && s.receipt.cleanup === 'not-required'));
36
+ async function run(e, signal) {
37
+ const n = options.network;
38
+ if (!n?.activate || !n.getActivationState || !n.onActivationState)
39
+ return fail('DEPENDENCY_REQUIRED');
40
+ if (signal.aborted)
41
+ return fail('ACTIVATION_CANCELLED');
42
+ try {
43
+ if (!isAbsolute(options.profileDir) || await realpath(options.profileDir) !== options.profileDir)
44
+ return fail('ENROLLMENT_INVALID');
45
+ await privateDirectory(root);
46
+ const configDir = join(root, 'network-config'), logDir = join(root, 'network-logs');
47
+ await privateDirectory(configDir);
48
+ await privateDirectory(logDir);
49
+ for (const p of [root, configDir, logDir])
50
+ if (await realpath(p) !== p)
51
+ return fail('ENROLLMENT_INVALID');
52
+ const s = { ...NETWORK_DEFAULTS, ...options.settings };
53
+ const settings = { configFile: s.configFile ?? join(configDir, 'config.json'), helperLogFile: s.helperLogFile ?? join(logDir, 'network.log'), startupObservationSeconds: s.startupObservationSeconds, shutdownObservationSeconds: s.shutdownObservationSeconds, controlResponseMaxBytes: s.controlResponseMaxBytes, downloadMaxRedirects: s.downloadMaxRedirects };
54
+ const target = { host: e.host, username: e.username, port: e.port, knownHostsFile: e.knownHostsFile };
55
+ const expected = { apiVersion: 1, stateDir: s.stateDir ?? join(homedir(), 'Library', 'Application Support', 'ai-workbench', 'network'), runtimeDir: s.runtimeDir ?? '/tmp/aw-net-' + process.getuid(), nodeName: s.nodeName ?? `aw-${e.username}-${hostname()}-${process.getuid()}`, host: { name: e.host, sshPort: e.port, employee: e.username, knownHostsFile: e.knownHostsFile }, proxyListen: s.proxyListen, logUpload: s.logUpload, statusRefreshIntervalSeconds: s.statusRefreshIntervalSeconds, dialTimeoutSeconds: s.dialTimeoutSeconds, headerTimeoutSeconds: s.headerTimeoutSeconds, enrollmentObservationSeconds: s.enrollmentObservationSeconds, httpMaxHeaderBytes: s.httpMaxHeaderBytes, httpMaxResponseHeaderBytes: s.httpMaxResponseHeaderBytes };
56
+ const canonical = (v) => JSON.stringify(v, (_, x) => x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => a.localeCompare(b))) : x);
57
+ const registration = { schemaVersion: 1, settings, target };
58
+ let stored = false;
59
+ try {
60
+ const r = JSON.parse(await readPrivate(recordFile, options.metadataMaxBytes));
61
+ if (canonical(r) !== canonical(registration))
62
+ return fail('ENROLLMENT_MISMATCH');
63
+ stored = true;
64
+ }
65
+ catch (error) {
66
+ if (error.code !== 'ENOENT')
67
+ throw error;
68
+ }
69
+ try {
70
+ const config = JSON.parse(await readPrivate(settings.configFile, options.metadataMaxBytes));
71
+ const key = config.authKeyFile;
72
+ delete config.authKeyFile;
73
+ if (canonical(config) !== canonical(expected) || (key !== undefined && key !== e.authKeyFile))
74
+ return fail('ENROLLMENT_MISMATCH');
75
+ if (!stored)
76
+ return fail('LOCAL_STATE_INVALID');
77
+ }
78
+ catch (error) {
79
+ if (error.code !== 'ENOENT')
80
+ throw error;
81
+ if (stored)
82
+ return fail('LOCAL_STATE_INVALID');
83
+ if (!e.authKeyFile)
84
+ return fail('ENROLLMENT_REQUIRED');
85
+ await save(settings.configFile, { ...expected, authKeyFile: e.authKeyFile });
86
+ }
87
+ if (!stored)
88
+ await save(recordFile, registration);
89
+ // Settings and target references are durable before activation; thereafter N owns config cleanup.
90
+ const before = n.getActivationState();
91
+ let latest = before;
92
+ const off = n.onActivationState(next => { if (next.instanceId === before.instanceId && next.revision >= latest.revision)
93
+ latest = next; });
94
+ try {
95
+ const result = await n.activate(settings, signal);
96
+ if (!result.ok)
97
+ return { ok: false, error: mapNetworkNeed(result.error) };
98
+ const current = n.getActivationState();
99
+ if (result.value.instanceId !== before.instanceId || current.instanceId !== before.instanceId || result.value.revision < latest.revision || current.revision < result.value.revision)
100
+ return fail('CONTROL_PROTOCOL_INVALID');
101
+ if (!receiptValid(result.value) || !receiptValid(current))
102
+ return fail('ENROLLMENT_RECEIPT_REQUIRED');
103
+ return ok(undefined);
104
+ }
105
+ finally {
106
+ off();
107
+ }
108
+ }
109
+ catch {
110
+ return fail('ENROLLMENT_INVALID');
111
+ }
112
+ }
113
+ return { activate(e, signal) { const target = JSON.stringify([e.host, e.username, e.port, e.knownHostsFile, e.projectRoot]); if (flight)
114
+ return target === flightTarget ? flight : Promise.resolve(fail('ACTIVATION_CONFLICT')); flightTarget = target; const p = run(e, signal); flight = p; void p.finally(() => { if (flight === p) {
115
+ flight = undefined;
116
+ flightTarget = undefined;
117
+ } }).catch(() => { }); return p; } };
118
+ }
@@ -0,0 +1,21 @@
1
+ import type { EnrollmentState, ConnectNeed, Result } from './api.ts';
2
+ import type { Enrollment } from './ports.ts';
3
+ export interface ProfileEnrollmentOptions {
4
+ profileDir: string;
5
+ metadataMaxBytes: number;
6
+ pick: (signal: AbortSignal) => Promise<string | null>;
7
+ activate?: (enrollment: Enrollment, signal: AbortSignal) => Promise<Result<void>>;
8
+ active?: () => boolean;
9
+ blockedByNetwork?: () => Pick<ConnectNeed, 'code'> | undefined;
10
+ onState?: (state: EnrollmentState) => void;
11
+ }
12
+ export declare function createProfileEnrollment(options: ProfileEnrollmentOptions): {
13
+ getState: () => EnrollmentState;
14
+ onState(fn: (s: EnrollmentState) => void): () => void;
15
+ read(): Promise<Result<Enrollment>>;
16
+ importEnrollment(): Promise<Result<{
17
+ outcome: "imported" | "cancelled";
18
+ state: EnrollmentState;
19
+ }>>;
20
+ dispose(): Promise<void>;
21
+ };