@msvesper/vesper-link 0.1.4
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/LICENSE.md +44 -0
- package/README.md +26 -0
- package/dist/atomic-json.d.ts +3 -0
- package/dist/atomic-json.js +47 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +170 -0
- package/dist/collectors/claude.d.ts +5 -0
- package/dist/collectors/claude.js +42 -0
- package/dist/collectors/codex.d.ts +5 -0
- package/dist/collectors/codex.js +65 -0
- package/dist/collectors/copilot.d.ts +5 -0
- package/dist/collectors/copilot.js +64 -0
- package/dist/collectors/index.d.ts +4 -0
- package/dist/collectors/index.js +3 -0
- package/dist/collectors/types.d.ts +16 -0
- package/dist/collectors/types.js +1 -0
- package/dist/companion.d.ts +12 -0
- package/dist/companion.js +62 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +41 -0
- package/dist/contracts.d.ts +83 -0
- package/dist/contracts.js +144 -0
- package/dist/diagnostics.d.ts +37 -0
- package/dist/diagnostics.js +64 -0
- package/dist/fs-utils.d.ts +5 -0
- package/dist/fs-utils.js +69 -0
- package/dist/hook-report.d.ts +8 -0
- package/dist/hook-report.js +21 -0
- package/dist/http-client.d.ts +25 -0
- package/dist/http-client.js +228 -0
- package/dist/install-args.d.ts +7 -0
- package/dist/install-args.js +35 -0
- package/dist/installation.d.ts +59 -0
- package/dist/installation.js +524 -0
- package/dist/installer.d.ts +59 -0
- package/dist/installer.js +214 -0
- package/dist/lock.d.ts +5 -0
- package/dist/lock.js +57 -0
- package/dist/managed-runtime.d.ts +33 -0
- package/dist/managed-runtime.js +130 -0
- package/dist/memory-mcp.d.ts +27 -0
- package/dist/memory-mcp.js +155 -0
- package/dist/operational-log.d.ts +17 -0
- package/dist/operational-log.js +48 -0
- package/dist/paths.d.ts +23 -0
- package/dist/paths.js +36 -0
- package/dist/pending.d.ts +7 -0
- package/dist/pending.js +35 -0
- package/dist/process-runner.d.ts +11 -0
- package/dist/process-runner.js +21 -0
- package/dist/provider-integrations.d.ts +42 -0
- package/dist/provider-integrations.js +339 -0
- package/dist/queue.d.ts +19 -0
- package/dist/queue.js +129 -0
- package/dist/scheduler.d.ts +51 -0
- package/dist/scheduler.js +176 -0
- package/dist/session.d.ts +50 -0
- package/dist/session.js +12 -0
- package/dist/source.d.ts +11 -0
- package/dist/source.js +45 -0
- package/dist/state.d.ts +11 -0
- package/dist/state.js +37 -0
- package/dist/sync.d.ts +18 -0
- package/dist/sync.js +247 -0
- package/dist/transport.d.ts +27 -0
- package/dist/transport.js +400 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/package.json +39 -0
package/dist/sync.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { ClaudeCollector, CodexCollector, CopilotCollector } from './collectors/index.js';
|
|
4
|
+
import { readConfig } from './config.js';
|
|
5
|
+
import { getHealth, postManifest, putContent, SystemicUploadError, TaskUploadError } from './http-client.js';
|
|
6
|
+
import { readInstalledState } from './installation.js';
|
|
7
|
+
import { acquireSyncLock, SyncAlreadyRunningError } from './lock.js';
|
|
8
|
+
import { readPendingMarkers, removePendingMarker } from './pending.js';
|
|
9
|
+
import { resolveLinkPaths } from './paths.js';
|
|
10
|
+
import { isStableForUpload, selectQueueItems } from './queue.js';
|
|
11
|
+
import { fingerprintStableSource, LocalTaskError, assertSourceUnchanged } from './source.js';
|
|
12
|
+
import { clearSyncState, readSessionStates, readSyncState, writeSessionState, writeSyncState } from './state.js';
|
|
13
|
+
import { SCHEMA_VERSION } from './contracts.js';
|
|
14
|
+
import { stableSessionKey } from './session.js';
|
|
15
|
+
import { acquireRemoteTransport } from './transport.js';
|
|
16
|
+
const MISSING_SOURCE_RETRY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
17
|
+
function createState(item) {
|
|
18
|
+
const candidate = item.candidate;
|
|
19
|
+
return {
|
|
20
|
+
schemaVersion: 1,
|
|
21
|
+
provider: candidate.provider,
|
|
22
|
+
format: candidate.format,
|
|
23
|
+
formatVersion: candidate.formatVersion,
|
|
24
|
+
providerSessionId: candidate.providerSessionId,
|
|
25
|
+
transcriptPath: candidate.transcriptPath,
|
|
26
|
+
projectKey: candidate.projectKey,
|
|
27
|
+
cwdUri: candidate.cwdUri,
|
|
28
|
+
sourceCreatedAt: candidate.sourceCreatedAt,
|
|
29
|
+
sourceUpdatedAt: candidate.sourceUpdatedAt,
|
|
30
|
+
lastSeenSize: candidate.size,
|
|
31
|
+
lastSeenMtimeMs: candidate.mtimeMs,
|
|
32
|
+
requiresStability: candidate.discovery === 'scan' && candidate.archived !== true,
|
|
33
|
+
status: 'pending',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function errorDetails(error) {
|
|
37
|
+
if (error instanceof TaskUploadError || error instanceof SystemicUploadError || error instanceof LocalTaskError) {
|
|
38
|
+
return { code: error.code, message: error.message };
|
|
39
|
+
}
|
|
40
|
+
if (error.code === 'ENOENT') {
|
|
41
|
+
return { code: 'missing_source', message: String(error) };
|
|
42
|
+
}
|
|
43
|
+
return { code: 'unexpected_error', message: String(error) };
|
|
44
|
+
}
|
|
45
|
+
function isPermanentTaskFailure(code) {
|
|
46
|
+
return ['file_too_large', 'manifest_too_large', 'invalid_manifest', 'unsupported_provider'].includes(code);
|
|
47
|
+
}
|
|
48
|
+
export async function runSyncOnce(options = {}) {
|
|
49
|
+
const paths = options.paths ?? resolveLinkPaths();
|
|
50
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
51
|
+
const summary = { discovered: 0, queued: 0, uploaded: 0, unchanged: 0, failed: 0, collectorIssues: [] };
|
|
52
|
+
let release;
|
|
53
|
+
try {
|
|
54
|
+
release = await acquireSyncLock(paths.lockPath);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error instanceof SyncAlreadyRunningError) {
|
|
58
|
+
summary.alreadyRunning = true;
|
|
59
|
+
return summary;
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
if ((await readInstalledState(paths))?.paused) {
|
|
65
|
+
summary.stoppedBy = 'paused';
|
|
66
|
+
return summary;
|
|
67
|
+
}
|
|
68
|
+
const syncState = await readSyncState(paths.syncStatePath);
|
|
69
|
+
if (syncState.retryAfterUntil && Date.parse(syncState.retryAfterUntil) > nowMs) {
|
|
70
|
+
summary.stoppedBy = 'rate_limited';
|
|
71
|
+
return summary;
|
|
72
|
+
}
|
|
73
|
+
if (syncState.retryAfterUntil) {
|
|
74
|
+
await clearSyncState(paths.syncStatePath);
|
|
75
|
+
}
|
|
76
|
+
const config = await readConfig(paths.configPath);
|
|
77
|
+
const transport = options.acquireTransport
|
|
78
|
+
? await options.acquireTransport(paths, config.host)
|
|
79
|
+
: await acquireRemoteTransport({ paths, hostUrl: config.host });
|
|
80
|
+
const remoteHost = transport.hostUrl;
|
|
81
|
+
const remoteFetch = transport.fetch;
|
|
82
|
+
try {
|
|
83
|
+
const states = await readSessionStates(paths.sessionsDir);
|
|
84
|
+
let health;
|
|
85
|
+
try {
|
|
86
|
+
health = await getHealth(remoteHost, remoteFetch);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error instanceof SystemicUploadError && error.code === 'rate_limited' && error.retryAfterUntil) {
|
|
90
|
+
await writeSyncState(paths.syncStatePath, { schemaVersion: 1, retryAfterUntil: error.retryAfterUntil });
|
|
91
|
+
summary.stoppedBy = error.code;
|
|
92
|
+
return summary;
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
if (!health.archiveReady) {
|
|
97
|
+
summary.stoppedBy = 'archive_unavailable';
|
|
98
|
+
return summary;
|
|
99
|
+
}
|
|
100
|
+
const context = {
|
|
101
|
+
nowMs,
|
|
102
|
+
claudeProjectsDir: paths.claudeProjectsDir,
|
|
103
|
+
copilotSessionsDir: paths.copilotSessionsDir,
|
|
104
|
+
codexHome: paths.codexHome,
|
|
105
|
+
reportIssue: (issue) => summary.collectorIssues.push(issue),
|
|
106
|
+
};
|
|
107
|
+
const collectors = [new ClaudeCollector(), new CopilotCollector(), new CodexCollector()];
|
|
108
|
+
const scanned = (await Promise.all(collectors.map((collector) => collector.collect(context)))).flat();
|
|
109
|
+
const markers = await readPendingMarkers(paths.pendingDir);
|
|
110
|
+
summary.discovered = scanned.length + markers.length;
|
|
111
|
+
const queue = await selectQueueItems({ markers, scanned, states, nowMs });
|
|
112
|
+
summary.queued = queue.length;
|
|
113
|
+
let consecutiveSystemicFailures = 0;
|
|
114
|
+
for (const item of queue) {
|
|
115
|
+
const key = stableSessionKey(item.candidate.provider, item.candidate.providerSessionId);
|
|
116
|
+
const state = states.get(key) ?? createState(item);
|
|
117
|
+
state.transcriptPath = item.candidate.transcriptPath;
|
|
118
|
+
state.format = item.candidate.format;
|
|
119
|
+
state.formatVersion = item.candidate.formatVersion;
|
|
120
|
+
state.projectKey = item.candidate.projectKey ?? state.projectKey;
|
|
121
|
+
state.cwdUri = item.candidate.cwdUri;
|
|
122
|
+
state.sourceUpdatedAt = item.candidate.sourceUpdatedAt;
|
|
123
|
+
state.lastSeenSize = item.candidate.size;
|
|
124
|
+
state.lastSeenMtimeMs = item.candidate.mtimeMs;
|
|
125
|
+
if (item.markerPath) {
|
|
126
|
+
state.requiresStability = false;
|
|
127
|
+
}
|
|
128
|
+
else if (item.candidate.discovery === 'scan') {
|
|
129
|
+
state.requiresStability = item.candidate.archived !== true;
|
|
130
|
+
}
|
|
131
|
+
const markerIsNewerThanLastAttempt = item.markerQueuedAt !== undefined
|
|
132
|
+
&& Date.parse(item.markerQueuedAt) > Date.parse(state.lastError?.at ?? state.missingSince ?? '');
|
|
133
|
+
if (markerIsNewerThanLastAttempt) {
|
|
134
|
+
delete state.missingSince;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const fingerprint = await fingerprintStableSource(item.candidate.transcriptPath);
|
|
138
|
+
if (!isStableForUpload(item.candidate, fingerprint.mtimeMs, nowMs)) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const sourceUpdatedAt = new Date(fingerprint.mtimeMs).toISOString();
|
|
142
|
+
state.sourceUpdatedAt = sourceUpdatedAt;
|
|
143
|
+
const manifest = {
|
|
144
|
+
schemaVersion: SCHEMA_VERSION,
|
|
145
|
+
provider: item.candidate.provider,
|
|
146
|
+
format: item.candidate.format,
|
|
147
|
+
formatVersion: item.candidate.formatVersion,
|
|
148
|
+
providerSessionId: item.candidate.providerSessionId,
|
|
149
|
+
projectKey: state.projectKey,
|
|
150
|
+
cwdUri: item.candidate.cwdUri,
|
|
151
|
+
sourceTranscriptUri: pathToFileURL(path.resolve(item.candidate.transcriptPath)).href,
|
|
152
|
+
sourceCreatedAt: item.candidate.sourceCreatedAt,
|
|
153
|
+
sourceUpdatedAt,
|
|
154
|
+
byteLength: fingerprint.size,
|
|
155
|
+
sha256: fingerprint.sha256,
|
|
156
|
+
};
|
|
157
|
+
const posted = await postManifest(remoteHost, manifest, remoteFetch);
|
|
158
|
+
if (posted.uploadStatus !== 'needs_content' && posted.uploadStatus !== 'already_archived') {
|
|
159
|
+
throw new SystemicUploadError('malformed_response', `Unexpected manifest upload status: ${posted.uploadStatus}`);
|
|
160
|
+
}
|
|
161
|
+
if ((posted.uploadStatus === 'needs_content' && posted.currentSha256 === fingerprint.sha256)
|
|
162
|
+
|| (posted.uploadStatus === 'already_archived' && posted.currentSha256 !== fingerprint.sha256)) {
|
|
163
|
+
throw new SystemicUploadError('malformed_response', 'Manifest response SHA does not match the source');
|
|
164
|
+
}
|
|
165
|
+
let finalResponse = posted;
|
|
166
|
+
if (posted.uploadStatus !== 'already_archived') {
|
|
167
|
+
await assertSourceUnchanged(item.candidate.transcriptPath, fingerprint);
|
|
168
|
+
finalResponse = await putContent({
|
|
169
|
+
host: remoteHost,
|
|
170
|
+
transcriptId: posted.transcriptId,
|
|
171
|
+
filePath: item.candidate.transcriptPath,
|
|
172
|
+
byteLength: fingerprint.size,
|
|
173
|
+
fetchImpl: remoteFetch,
|
|
174
|
+
});
|
|
175
|
+
await assertSourceUnchanged(item.candidate.transcriptPath, fingerprint);
|
|
176
|
+
}
|
|
177
|
+
if (!['archived', 'updated', 'already_archived'].includes(finalResponse.uploadStatus)) {
|
|
178
|
+
throw new SystemicUploadError('unexpected_upload_status', finalResponse.uploadStatus);
|
|
179
|
+
}
|
|
180
|
+
if (finalResponse.currentSha256 !== fingerprint.sha256) {
|
|
181
|
+
throw new SystemicUploadError('malformed_response', 'Archive response SHA does not match the source');
|
|
182
|
+
}
|
|
183
|
+
state.status = 'uploaded';
|
|
184
|
+
state.lastUploadedSha256 = fingerprint.sha256;
|
|
185
|
+
state.lastUploadedAt = new Date(nowMs).toISOString();
|
|
186
|
+
state.lastSeenSize = fingerprint.size;
|
|
187
|
+
state.lastSeenMtimeMs = fingerprint.mtimeMs;
|
|
188
|
+
delete state.pendingReason;
|
|
189
|
+
delete state.missingSince;
|
|
190
|
+
delete state.lastError;
|
|
191
|
+
await writeSessionState(paths.sessionsDir, state);
|
|
192
|
+
if (item.markerPath) {
|
|
193
|
+
await removePendingMarker(item.markerPath);
|
|
194
|
+
}
|
|
195
|
+
if (finalResponse.uploadStatus === 'already_archived') {
|
|
196
|
+
summary.unchanged += 1;
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
summary.uploaded += 1;
|
|
200
|
+
}
|
|
201
|
+
consecutiveSystemicFailures = 0;
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
const details = errorDetails(error);
|
|
205
|
+
if (details.code === 'missing_source') {
|
|
206
|
+
state.missingSince ??= new Date(nowMs).toISOString();
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
delete state.missingSince;
|
|
210
|
+
}
|
|
211
|
+
const missingExpired = state.missingSince !== undefined
|
|
212
|
+
&& nowMs - Date.parse(state.missingSince) >= MISSING_SOURCE_RETRY_MS;
|
|
213
|
+
const permanentTaskFailure = (error instanceof TaskUploadError || error instanceof LocalTaskError)
|
|
214
|
+
&& isPermanentTaskFailure(details.code);
|
|
215
|
+
state.status = (permanentTaskFailure || missingExpired) ? 'failed' : 'pending';
|
|
216
|
+
state.pendingReason = details.code;
|
|
217
|
+
state.lastError = { ...details, at: new Date(nowMs).toISOString() };
|
|
218
|
+
await writeSessionState(paths.sessionsDir, state);
|
|
219
|
+
if (error instanceof SystemicUploadError && error.code === 'rate_limited' && error.retryAfterUntil) {
|
|
220
|
+
await writeSyncState(paths.syncStatePath, { schemaVersion: 1, retryAfterUntil: error.retryAfterUntil });
|
|
221
|
+
}
|
|
222
|
+
summary.failed += 1;
|
|
223
|
+
if (error instanceof TaskUploadError || error instanceof LocalTaskError
|
|
224
|
+
|| error.code === 'ENOENT') {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
consecutiveSystemicFailures += 1;
|
|
228
|
+
if (error instanceof SystemicUploadError && error.code !== 'internal_error') {
|
|
229
|
+
summary.stoppedBy = error.code;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
if (consecutiveSystemicFailures >= 3) {
|
|
233
|
+
summary.stoppedBy = details.code;
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return summary;
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
await transport.release();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
await release();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
2
|
+
import { type CompanionInfo } from './companion.js';
|
|
3
|
+
import type { LinkPaths } from './paths.js';
|
|
4
|
+
export interface RemoteTransport {
|
|
5
|
+
readonly hostUrl: string;
|
|
6
|
+
readonly identity: {
|
|
7
|
+
tailnetName: string;
|
|
8
|
+
nodeName: string;
|
|
9
|
+
ipv4: string;
|
|
10
|
+
};
|
|
11
|
+
readonly fetch: typeof fetch;
|
|
12
|
+
release(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export interface AcquireTransportOptions {
|
|
15
|
+
paths: LinkPaths;
|
|
16
|
+
hostUrl: string;
|
|
17
|
+
fetchImpl?: typeof fetch;
|
|
18
|
+
resolveCompanion?: () => Promise<CompanionInfo>;
|
|
19
|
+
requiredCompanion?: CompanionInfo;
|
|
20
|
+
probeCompanion?: (companion: CompanionInfo, environment: NodeJS.ProcessEnv) => Promise<void>;
|
|
21
|
+
spawnChild?: (executable: string, args: string[], env: NodeJS.ProcessEnv) => ChildProcessWithoutNullStreams;
|
|
22
|
+
onNeedsLogin?: (url: string) => void;
|
|
23
|
+
bootstrapAuthKey?: string;
|
|
24
|
+
startTimeoutMs?: number;
|
|
25
|
+
}
|
|
26
|
+
export declare function consumeBootstrapAuthKey(explicitAuthKey: string | undefined, environment?: NodeJS.ProcessEnv): string | undefined;
|
|
27
|
+
export declare function acquireRemoteTransport(options: AcquireTransportOptions): Promise<RemoteTransport>;
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { isIPv4 } from 'node:net';
|
|
4
|
+
import * as http from 'node:http';
|
|
5
|
+
import { Readable } from 'node:stream';
|
|
6
|
+
import { assertCompanionProtocol, VESPER_NET_PROTOCOL_VERSION, resolveCompanion } from './companion.js';
|
|
7
|
+
const START_TIMEOUT_MS = 10 * 60_000;
|
|
8
|
+
const ATTACH_TIMEOUT_MS = 2_000;
|
|
9
|
+
const POLL_INTERVAL_MS = 100;
|
|
10
|
+
const MAX_CONTROL_BYTES = 64 * 1024;
|
|
11
|
+
const LOCAL_SECRET_HEADER = 'x-vesper-transport-secret';
|
|
12
|
+
const KEEPALIVE_MS = 10_000;
|
|
13
|
+
const PROXY_ENVIRONMENT_KEYS = new Set([
|
|
14
|
+
'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy',
|
|
15
|
+
]);
|
|
16
|
+
const AUTH_KEY_ENVIRONMENT_KEYS = new Set([
|
|
17
|
+
'vesper_ts_authkey', 'ts_authkey', 'ts_auth_key',
|
|
18
|
+
]);
|
|
19
|
+
const AUTH_KEY_PATTERN = /^tskey-auth-[A-Za-z0-9_-]{16,512}$/;
|
|
20
|
+
function directLoopbackFetch(input, init = {}) {
|
|
21
|
+
const inputRequest = input instanceof Request ? input : undefined;
|
|
22
|
+
const url = new URL(inputRequest?.url ?? input.toString());
|
|
23
|
+
if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1') {
|
|
24
|
+
return Promise.reject(new Error(`Direct transport client rejected non-loopback URL: ${url.origin}`));
|
|
25
|
+
}
|
|
26
|
+
const headers = new Headers(inputRequest?.headers);
|
|
27
|
+
if (init.headers) {
|
|
28
|
+
new Headers(init.headers).forEach((value, key) => headers.set(key, value));
|
|
29
|
+
}
|
|
30
|
+
const method = init.method ?? inputRequest?.method ?? 'GET';
|
|
31
|
+
const body = init.body ?? inputRequest?.body;
|
|
32
|
+
const signal = init.signal ?? inputRequest?.signal;
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
let settled = false;
|
|
35
|
+
const finishReject = (error) => {
|
|
36
|
+
if (!settled) {
|
|
37
|
+
settled = true;
|
|
38
|
+
reject(error);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const request = http.request({
|
|
42
|
+
protocol: 'http:', hostname: '127.0.0.1', port: Number(url.port),
|
|
43
|
+
path: `${url.pathname}${url.search}`, method, headers: Object.fromEntries(headers),
|
|
44
|
+
agent: false,
|
|
45
|
+
}, (response) => {
|
|
46
|
+
const chunks = [];
|
|
47
|
+
response.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
48
|
+
response.once('error', finishReject);
|
|
49
|
+
response.once('end', () => {
|
|
50
|
+
if (settled) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
settled = true;
|
|
54
|
+
const responseHeaders = new Headers();
|
|
55
|
+
for (const [key, value] of Object.entries(response.headers)) {
|
|
56
|
+
for (const item of Array.isArray(value) ? value : value === undefined ? [] : [value]) {
|
|
57
|
+
responseHeaders.append(key, String(item));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
resolve(new Response(Buffer.concat(chunks), {
|
|
61
|
+
status: response.statusCode ?? 500, statusText: response.statusMessage, headers: responseHeaders,
|
|
62
|
+
}));
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
request.once('error', finishReject);
|
|
66
|
+
const abort = () => {
|
|
67
|
+
request.destroy(new DOMException('The operation was aborted', 'AbortError'));
|
|
68
|
+
};
|
|
69
|
+
if (signal?.aborted) {
|
|
70
|
+
abort();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
74
|
+
request.once('close', () => signal?.removeEventListener('abort', abort));
|
|
75
|
+
if (body === undefined || body === null) {
|
|
76
|
+
request.end();
|
|
77
|
+
}
|
|
78
|
+
else if (typeof body === 'string' || Buffer.isBuffer(body) || body instanceof Uint8Array) {
|
|
79
|
+
request.end(body);
|
|
80
|
+
}
|
|
81
|
+
else if (typeof body.pipe === 'function') {
|
|
82
|
+
body.pipe(request);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
Readable.fromWeb(body).pipe(request);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function isAlive(pid) {
|
|
90
|
+
try {
|
|
91
|
+
process.kill(pid, 0);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function validLoopbackEndpoint(value) {
|
|
99
|
+
if (typeof value !== 'string') {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const url = new URL(value);
|
|
104
|
+
return url.protocol === 'http:' && url.hostname === '127.0.0.1' && url.port !== ''
|
|
105
|
+
&& url.pathname === '/' && url.search === '' && url.hash === '';
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function parseRendezvous(value) {
|
|
112
|
+
if (!value || typeof value !== 'object') {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const candidate = value;
|
|
116
|
+
if (candidate.protocolVersion !== VESPER_NET_PROTOCOL_VERSION
|
|
117
|
+
|| !Number.isSafeInteger(candidate.pid) || candidate.pid <= 0
|
|
118
|
+
|| !validLoopbackEndpoint(candidate.endpoint)
|
|
119
|
+
|| typeof candidate.secret !== 'string' || candidate.secret.length < 32) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return candidate;
|
|
123
|
+
}
|
|
124
|
+
async function readRendezvous(filePath) {
|
|
125
|
+
try {
|
|
126
|
+
const stat = await fs.stat(filePath);
|
|
127
|
+
if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) {
|
|
128
|
+
throw new Error('Transport rendezvous permissions are not user-private');
|
|
129
|
+
}
|
|
130
|
+
const parsed = parseRendezvous(JSON.parse(await fs.readFile(filePath, 'utf8')));
|
|
131
|
+
if (!parsed) {
|
|
132
|
+
throw new Error('Transport rendezvous is malformed or has an unsupported protocol');
|
|
133
|
+
}
|
|
134
|
+
return parsed;
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (error.code === 'ENOENT') {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function attach(paths, hostUrl, fetchImpl, requiredExecutablePath) {
|
|
144
|
+
let rendezvous;
|
|
145
|
+
try {
|
|
146
|
+
rendezvous = await readRendezvous(paths.transportRendezvousPath);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (error instanceof Error && error.message.startsWith('Transport rendezvous is malformed')) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
if (!rendezvous || !isAlive(rendezvous.pid)) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
const requestLease = async (inspectOnly) => {
|
|
158
|
+
try {
|
|
159
|
+
const operation = inspectOnly ? 'identity' : 'lease';
|
|
160
|
+
return await fetchImpl(`${rendezvous.endpoint}/__vesper_transport/${operation}`, {
|
|
161
|
+
headers: { [LOCAL_SECRET_HEADER]: rendezvous.secret },
|
|
162
|
+
signal: AbortSignal.timeout(ATTACH_TIMEOUT_MS),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
const readLease = async (inspectOnly) => {
|
|
170
|
+
const response = await requestLease(inspectOnly);
|
|
171
|
+
if (!response?.ok) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
return await response.json();
|
|
175
|
+
};
|
|
176
|
+
let lease = await readLease(requiredExecutablePath !== undefined);
|
|
177
|
+
if (!lease) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
function validateLease(candidate) {
|
|
181
|
+
if (candidate.protocolVersion !== VESPER_NET_PROTOCOL_VERSION || candidate.hostUrl !== hostUrl
|
|
182
|
+
|| typeof candidate.tailnetName !== 'string' || !candidate.tailnetName
|
|
183
|
+
|| typeof candidate.nodeName !== 'string' || !candidate.nodeName
|
|
184
|
+
|| typeof candidate.ipv4 !== 'string' || !isIPv4(candidate.ipv4)
|
|
185
|
+
|| typeof candidate.executablePath !== 'string' || !candidate.executablePath) {
|
|
186
|
+
throw new Error('Running vesper-net transport targets a different host or protocol');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
validateLease(lease);
|
|
190
|
+
if (requiredExecutablePath) {
|
|
191
|
+
const reportedExecutablePath = lease.executablePath;
|
|
192
|
+
const actualExecutablePath = await fs.realpath(reportedExecutablePath).catch(() => reportedExecutablePath);
|
|
193
|
+
const sameExecutable = process.platform === 'win32'
|
|
194
|
+
? actualExecutablePath.toLowerCase() === requiredExecutablePath.toLowerCase()
|
|
195
|
+
: actualExecutablePath === requiredExecutablePath;
|
|
196
|
+
if (!sameExecutable) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
lease = await readLease(false);
|
|
200
|
+
if (!lease) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
validateLease(lease);
|
|
204
|
+
}
|
|
205
|
+
const transportFetch = async (input, init) => {
|
|
206
|
+
const remote = input instanceof Request ? new URL(input.url) : new URL(input.toString());
|
|
207
|
+
if (remote.origin !== new URL(hostUrl).origin) {
|
|
208
|
+
throw new Error(`Remote Memory request escaped configured host: ${remote.origin}`);
|
|
209
|
+
}
|
|
210
|
+
const local = new URL(`${remote.pathname}${remote.search}`, rendezvous.endpoint);
|
|
211
|
+
const headers = new Headers(input instanceof Request ? input.headers : undefined);
|
|
212
|
+
if (init?.headers) {
|
|
213
|
+
new Headers(init.headers).forEach((value, key) => headers.set(key, value));
|
|
214
|
+
}
|
|
215
|
+
headers.set(LOCAL_SECRET_HEADER, rendezvous.secret);
|
|
216
|
+
return fetchImpl(local, { ...init, headers });
|
|
217
|
+
};
|
|
218
|
+
const keepalive = setInterval(() => {
|
|
219
|
+
void fetchImpl(`${rendezvous.endpoint}/__vesper_transport/lease`, {
|
|
220
|
+
headers: { [LOCAL_SECRET_HEADER]: rendezvous.secret },
|
|
221
|
+
signal: AbortSignal.timeout(ATTACH_TIMEOUT_MS),
|
|
222
|
+
}).catch(() => undefined);
|
|
223
|
+
}, KEEPALIVE_MS);
|
|
224
|
+
keepalive.unref();
|
|
225
|
+
let released = false;
|
|
226
|
+
return {
|
|
227
|
+
ownerPid: rendezvous.pid,
|
|
228
|
+
transport: {
|
|
229
|
+
hostUrl,
|
|
230
|
+
identity: { tailnetName: lease.tailnetName, nodeName: lease.nodeName, ipv4: lease.ipv4 },
|
|
231
|
+
fetch: transportFetch,
|
|
232
|
+
async release() {
|
|
233
|
+
if (!released) {
|
|
234
|
+
released = true;
|
|
235
|
+
clearInterval(keepalive);
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function defaultSpawn(executable, args, env) {
|
|
242
|
+
return spawn(executable, args, {
|
|
243
|
+
env, detached: true, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
function companionEnvironment(environment, bootstrapAuthKey) {
|
|
247
|
+
const filtered = Object.fromEntries(Object.entries(environment).filter(([key]) => {
|
|
248
|
+
const normalized = key.toLowerCase();
|
|
249
|
+
return !PROXY_ENVIRONMENT_KEYS.has(normalized) && !AUTH_KEY_ENVIRONMENT_KEYS.has(normalized);
|
|
250
|
+
}));
|
|
251
|
+
if (bootstrapAuthKey !== undefined) {
|
|
252
|
+
filtered.VESPER_TS_AUTHKEY = bootstrapAuthKey;
|
|
253
|
+
}
|
|
254
|
+
return filtered;
|
|
255
|
+
}
|
|
256
|
+
function protocolProbeEnvironment(environment) {
|
|
257
|
+
const filtered = companionEnvironment(environment);
|
|
258
|
+
return filtered;
|
|
259
|
+
}
|
|
260
|
+
export function consumeBootstrapAuthKey(explicitAuthKey, environment = process.env) {
|
|
261
|
+
let inheritedAuthKey;
|
|
262
|
+
for (const alias of AUTH_KEY_ENVIRONMENT_KEYS) {
|
|
263
|
+
for (const [key, value] of Object.entries(environment)) {
|
|
264
|
+
if (key.toLowerCase() === alias) {
|
|
265
|
+
inheritedAuthKey ??= value || undefined;
|
|
266
|
+
delete environment[key];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return explicitAuthKey ?? inheritedAuthKey;
|
|
271
|
+
}
|
|
272
|
+
function validateHostUrl(hostUrl) {
|
|
273
|
+
const url = new URL(hostUrl);
|
|
274
|
+
if (url.protocol !== 'http:' || !isIPv4(url.hostname) || url.port !== '11723'
|
|
275
|
+
|| url.pathname !== '/' || url.search !== '' || url.hash !== '') {
|
|
276
|
+
throw new Error('Transport host must be the configured http://<Tailscale IPv4>:11723 origin');
|
|
277
|
+
}
|
|
278
|
+
const octets = url.hostname.split('.').map(Number);
|
|
279
|
+
if (octets[0] !== 100 || octets[1] < 64 || octets[1] > 127) {
|
|
280
|
+
throw new Error('Transport host must use Tailscale IPv4 range 100.64.0.0/10');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
export async function acquireRemoteTransport(options) {
|
|
284
|
+
const bootstrapAuthKey = consumeBootstrapAuthKey(options.bootstrapAuthKey);
|
|
285
|
+
validateHostUrl(options.hostUrl);
|
|
286
|
+
if (bootstrapAuthKey !== undefined && !AUTH_KEY_PATTERN.test(bootstrapAuthKey)) {
|
|
287
|
+
throw new Error('Tailscale bootstrap auth key is malformed');
|
|
288
|
+
}
|
|
289
|
+
const fetchImpl = options.fetchImpl ?? directLoopbackFetch;
|
|
290
|
+
let companion = options.requiredCompanion;
|
|
291
|
+
if (companion) {
|
|
292
|
+
await (options.probeCompanion ?? assertCompanionProtocol)(companion, protocolProbeEnvironment(process.env));
|
|
293
|
+
}
|
|
294
|
+
const requiredExecutablePath = companion
|
|
295
|
+
? await fs.realpath(companion.executable).catch(() => companion.executable)
|
|
296
|
+
: undefined;
|
|
297
|
+
const existing = await attach(options.paths, options.hostUrl, fetchImpl, requiredExecutablePath);
|
|
298
|
+
if (existing) {
|
|
299
|
+
return existing.transport;
|
|
300
|
+
}
|
|
301
|
+
// Package selection and lockstep version validation must fail before any
|
|
302
|
+
// transport state, identity directory, or rendezvous mutation.
|
|
303
|
+
companion ??= await (options.resolveCompanion ?? (() => resolveCompanion()))();
|
|
304
|
+
if (!options.requiredCompanion) {
|
|
305
|
+
await (options.probeCompanion ?? assertCompanionProtocol)(companion, protocolProbeEnvironment(process.env));
|
|
306
|
+
}
|
|
307
|
+
const deadline = Date.now() + (options.startTimeoutMs ?? START_TIMEOUT_MS);
|
|
308
|
+
const args = [
|
|
309
|
+
'--mode', 'transport', '--state-dir', options.paths.tsnetDir, '--hostname', 'vesper-link',
|
|
310
|
+
'--host-url', options.hostUrl, '--rendezvous', options.paths.transportRendezvousPath,
|
|
311
|
+
'--lock', options.paths.transportLockPath, '--idle-timeout', '30s',
|
|
312
|
+
];
|
|
313
|
+
const child = (options.spawnChild ?? defaultSpawn)(companion.executable, args, companionEnvironment(process.env, bootstrapAuthKey));
|
|
314
|
+
child.stdin.end();
|
|
315
|
+
let controlBuffer = '';
|
|
316
|
+
let fatalMessage;
|
|
317
|
+
child.once('error', (error) => { fatalMessage = `spawn failed: ${error.message}`; });
|
|
318
|
+
child.once('exit', (code, signal) => {
|
|
319
|
+
fatalMessage ??= `exited before ready (code=${code ?? 'none'}, signal=${signal ?? 'none'})`;
|
|
320
|
+
});
|
|
321
|
+
child.stdout.setEncoding('utf8');
|
|
322
|
+
child.stdout.on('data', (chunk) => {
|
|
323
|
+
controlBuffer += chunk;
|
|
324
|
+
if (Buffer.byteLength(controlBuffer, 'utf8') > MAX_CONTROL_BYTES) {
|
|
325
|
+
fatalMessage = 'vesper-net control stream exceeded its limit';
|
|
326
|
+
child.kill('SIGTERM');
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
let lineEnd;
|
|
330
|
+
while ((lineEnd = controlBuffer.indexOf('\n')) >= 0) {
|
|
331
|
+
const line = controlBuffer.slice(0, lineEnd).trim();
|
|
332
|
+
controlBuffer = controlBuffer.slice(lineEnd + 1);
|
|
333
|
+
if (!line) {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
const event = JSON.parse(line);
|
|
338
|
+
if (event.protocolVersion !== VESPER_NET_PROTOCOL_VERSION) {
|
|
339
|
+
fatalMessage = 'vesper-net protocol mismatch';
|
|
340
|
+
child.kill('SIGTERM');
|
|
341
|
+
}
|
|
342
|
+
else if (event.type === 'needs_login' && typeof event.loginUrl === 'string') {
|
|
343
|
+
if (bootstrapAuthKey !== undefined) {
|
|
344
|
+
fatalMessage = 'automatic enrollment key was not accepted; copy a fresh Memory Link command';
|
|
345
|
+
child.kill('SIGTERM');
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
options.onNeedsLogin?.(event.loginUrl);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
else if (event.type === 'ready' && typeof event.tailnetName === 'string'
|
|
352
|
+
&& typeof event.nodeName === 'string' && typeof event.ipv4 === 'string') {
|
|
353
|
+
// Rendezvous attachment below is the authoritative transport-ready signal.
|
|
354
|
+
}
|
|
355
|
+
else if (event.type === 'fatal' && typeof event.message === 'string') {
|
|
356
|
+
fatalMessage = event.message;
|
|
357
|
+
child.kill('SIGTERM');
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
fatalMessage = 'vesper-net emitted an unknown or malformed control event';
|
|
361
|
+
child.kill('SIGTERM');
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
fatalMessage = 'vesper-net emitted malformed control JSON';
|
|
366
|
+
child.kill('SIGTERM');
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
// Never persist helper stderr. Drain it to prevent a blocked pipe.
|
|
371
|
+
child.stderr.resume();
|
|
372
|
+
while (Date.now() < deadline) {
|
|
373
|
+
let attached;
|
|
374
|
+
try {
|
|
375
|
+
attached = await attach(options.paths, options.hostUrl, fetchImpl, requiredExecutablePath);
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
child.kill('SIGTERM');
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
if (attached) {
|
|
382
|
+
if (attached.ownerPid === child.pid) {
|
|
383
|
+
child.unref();
|
|
384
|
+
child.stdout.unref?.();
|
|
385
|
+
child.stderr.unref?.();
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
child.kill('SIGTERM');
|
|
389
|
+
}
|
|
390
|
+
return attached.transport;
|
|
391
|
+
}
|
|
392
|
+
if (fatalMessage) {
|
|
393
|
+
child.kill('SIGTERM');
|
|
394
|
+
throw new Error(`vesper-net transport failed: ${fatalMessage}`);
|
|
395
|
+
}
|
|
396
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
397
|
+
}
|
|
398
|
+
child.kill('SIGTERM');
|
|
399
|
+
throw new Error('Timed out waiting for vesper-net transport');
|
|
400
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const VESPER_LINK_VERSION = "0.1.4";
|
package/dist/version.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const VESPER_LINK_VERSION = '0.1.4';
|