@turns/dev 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -0
- package/dist/agent-anchor-client.js +319 -0
- package/dist/agent-anchor.js +1685 -0
- package/dist/auth.js +258 -0
- package/dist/daemon.js +134 -0
- package/dist/deepseek-trajectories.js +196 -0
- package/dist/directory-policy.js +41 -0
- package/dist/file-browsing.js +241 -0
- package/dist/git-association.js +136 -0
- package/dist/harness-registry.js +323 -0
- package/dist/index.js +300 -0
- package/dist/local-session-sources.js +1448 -0
- package/dist/native-record-compaction.js +84 -0
- package/dist/path-resolution.js +298 -0
- package/dist/question-mcp.js +126 -0
- package/dist/queue-worker.js +1053 -0
- package/dist/secret-redaction.js +181 -0
- package/dist/upload-sessions.js +2016 -0
- package/package.json +35 -0
package/dist/auth.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import WebSocket from 'ws';
|
|
7
|
+
export const DEFAULT_ORIGIN = 'https://turns.dev';
|
|
8
|
+
export const CREDENTIAL_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
export function applicationPaths(environment = process.env, platform = process.platform, home = os.homedir()) {
|
|
10
|
+
const configBase = environment.TURNS_CONFIG_DIR ||
|
|
11
|
+
(platform === 'win32'
|
|
12
|
+
? environment.APPDATA || path.join(home, 'AppData', 'Roaming')
|
|
13
|
+
: platform === 'darwin'
|
|
14
|
+
? path.join(home, 'Library', 'Application Support')
|
|
15
|
+
: environment.XDG_CONFIG_HOME || path.join(home, '.config'));
|
|
16
|
+
const stateBase = environment.TURNS_STATE_DIR ||
|
|
17
|
+
(platform === 'win32'
|
|
18
|
+
? environment.LOCALAPPDATA || path.join(home, 'AppData', 'Local')
|
|
19
|
+
: platform === 'darwin'
|
|
20
|
+
? path.join(home, 'Library', 'Application Support')
|
|
21
|
+
: environment.XDG_STATE_HOME || path.join(home, '.local', 'state'));
|
|
22
|
+
const configDirectory = path.join(configBase, 'turns');
|
|
23
|
+
const stateDirectory = path.join(stateBase, 'turns');
|
|
24
|
+
return {
|
|
25
|
+
configDirectory,
|
|
26
|
+
credentials: path.join(configDirectory, 'credentials.json'),
|
|
27
|
+
machineId: path.join(configDirectory, 'machine-id'),
|
|
28
|
+
stateDirectory,
|
|
29
|
+
uploaderState: path.join(stateDirectory, 'native-upload-state.json'),
|
|
30
|
+
daemonLog: path.join(stateDirectory, 'daemon.log'),
|
|
31
|
+
linuxService: path.join(home, '.config', 'systemd', 'user', 'turns.service'),
|
|
32
|
+
macLaunchAgent: path.join(home, 'Library', 'LaunchAgents', 'dev.turns.cli.plist'),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function normalizeOrigin(value = DEFAULT_ORIGIN) {
|
|
36
|
+
const url = new URL(value || DEFAULT_ORIGIN);
|
|
37
|
+
if (!['http:', 'https:'].includes(url.protocol))
|
|
38
|
+
throw new Error('Turns server must use http or https.');
|
|
39
|
+
url.pathname = '';
|
|
40
|
+
url.search = '';
|
|
41
|
+
url.hash = '';
|
|
42
|
+
return url.toString().replace(/\/$/u, '');
|
|
43
|
+
}
|
|
44
|
+
export function uploadSocketUrl(origin) {
|
|
45
|
+
const url = new URL('/api/socket', normalizeOrigin(origin));
|
|
46
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
47
|
+
return url.toString();
|
|
48
|
+
}
|
|
49
|
+
function socketUrl(origin, pathname) {
|
|
50
|
+
const url = new URL(pathname, normalizeOrigin(origin));
|
|
51
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
52
|
+
return url.toString();
|
|
53
|
+
}
|
|
54
|
+
export function readCredentials(paths = applicationPaths()) {
|
|
55
|
+
try {
|
|
56
|
+
const value = JSON.parse(fs.readFileSync(paths.credentials, 'utf8'));
|
|
57
|
+
return value?.accessToken && value?.refreshToken && value?.origin ? value : null;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function writeCredentials(credentials, paths = applicationPaths()) {
|
|
64
|
+
fs.mkdirSync(paths.configDirectory, { recursive: true, mode: 0o700 });
|
|
65
|
+
const temporary = `${paths.credentials}.${process.pid}.${Date.now()}.tmp`;
|
|
66
|
+
fs.writeFileSync(temporary, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
|
|
67
|
+
fs.chmodSync(temporary, 0o600);
|
|
68
|
+
fs.renameSync(temporary, paths.credentials);
|
|
69
|
+
}
|
|
70
|
+
export function removeCredentials(paths = applicationPaths()) {
|
|
71
|
+
try {
|
|
72
|
+
fs.unlinkSync(paths.credentials);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error?.code !== 'ENOENT')
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function credentialsNeedRefresh(credentials, now = Date.now()) {
|
|
80
|
+
const refreshedAt = Date.parse(String(credentials?.refreshedAt || credentials?.authenticatedAt || ''));
|
|
81
|
+
const expiresAt = Date.parse(String(credentials?.expiresAt || ''));
|
|
82
|
+
return (!Number.isFinite(refreshedAt) ||
|
|
83
|
+
now - refreshedAt >= CREDENTIAL_REFRESH_INTERVAL_MS ||
|
|
84
|
+
!Number.isFinite(expiresAt) ||
|
|
85
|
+
expiresAt - now <= CREDENTIAL_REFRESH_INTERVAL_MS);
|
|
86
|
+
}
|
|
87
|
+
export async function refreshCredentials(credentials, { fetchImpl = fetch, now = Date.now() } = {}) {
|
|
88
|
+
const response = await fetchImpl(new URL('/api/cli/auth/refresh', credentials.origin), {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'content-type': 'application/json' },
|
|
91
|
+
body: JSON.stringify({ refreshToken: credentials.refreshToken }),
|
|
92
|
+
});
|
|
93
|
+
const result = (await response.json().catch(() => ({})));
|
|
94
|
+
if (!response.ok || !result.accessToken || !result.refreshToken || !result.user?.login) {
|
|
95
|
+
const error = new Error(result.error || `Turns rejected token refresh (HTTP ${response.status}).`);
|
|
96
|
+
error.code = response.status === 401 ? 'AUTH_REJECTED' : 'AUTH_REFRESH_FAILED';
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
version: 1,
|
|
101
|
+
origin: normalizeOrigin(credentials.origin),
|
|
102
|
+
accessToken: result.accessToken,
|
|
103
|
+
refreshToken: result.refreshToken,
|
|
104
|
+
expiresAt: result.expiresAt,
|
|
105
|
+
user: result.user,
|
|
106
|
+
authenticatedAt: credentials.authenticatedAt || new Date(now).toISOString(),
|
|
107
|
+
refreshedAt: new Date(now).toISOString(),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
export async function exchangeLegacyToken(origin, legacyToken, { fetchImpl = fetch, now = Date.now() } = {}) {
|
|
111
|
+
const normalized = normalizeOrigin(origin || DEFAULT_ORIGIN);
|
|
112
|
+
const response = await fetchImpl(new URL('/api/cli/auth/exchange', normalized), {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
headers: { authorization: `Bearer ${legacyToken}` },
|
|
115
|
+
});
|
|
116
|
+
const result = (await response.json().catch(() => ({})));
|
|
117
|
+
if (!response.ok || !result.accessToken || !result.refreshToken || !result.user?.login) {
|
|
118
|
+
throw new Error(result.error || `Turns rejected legacy-token exchange (HTTP ${response.status}).`);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
version: 1,
|
|
122
|
+
origin: normalized,
|
|
123
|
+
accessToken: result.accessToken,
|
|
124
|
+
refreshToken: result.refreshToken,
|
|
125
|
+
expiresAt: result.expiresAt,
|
|
126
|
+
user: result.user,
|
|
127
|
+
authenticatedAt: new Date(now).toISOString(),
|
|
128
|
+
refreshedAt: new Date(now).toISOString(),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export function openBrowser(url, { environment = process.env, platform = process.platform, spawnImpl = spawn } = {}) {
|
|
132
|
+
if (environment.TURNS_NO_BROWSER === '1')
|
|
133
|
+
return false;
|
|
134
|
+
const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
135
|
+
const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
136
|
+
try {
|
|
137
|
+
const child = spawnImpl(command, args, { detached: true, stdio: 'ignore' });
|
|
138
|
+
child.on?.('error', () => { });
|
|
139
|
+
child.unref?.();
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
export function waitForAuthentication(socketAddress, { WebSocketImpl = WebSocket, timeoutMs = 10 * 60_000 } = {}) {
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
let settled = false;
|
|
149
|
+
const socket = new WebSocketImpl(socketAddress, { headers: { 'x-turns-client-kind': 'cli' } });
|
|
150
|
+
const timer = setTimeout(() => finish(new Error('Timed out waiting for Turns authentication.')), timeoutMs);
|
|
151
|
+
timer.unref?.();
|
|
152
|
+
const finish = (error, value) => {
|
|
153
|
+
if (settled)
|
|
154
|
+
return;
|
|
155
|
+
settled = true;
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
try {
|
|
158
|
+
socket.close();
|
|
159
|
+
}
|
|
160
|
+
catch { }
|
|
161
|
+
if (error)
|
|
162
|
+
reject(error);
|
|
163
|
+
else
|
|
164
|
+
resolve(value);
|
|
165
|
+
};
|
|
166
|
+
socket.on('message', (data) => {
|
|
167
|
+
let message;
|
|
168
|
+
try {
|
|
169
|
+
message = JSON.parse(data.toString());
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (message.type === 'waiting')
|
|
175
|
+
return;
|
|
176
|
+
if (message.type === 'authenticated' && message.accessToken && message.refreshToken && message.user?.login) {
|
|
177
|
+
finish(null, message);
|
|
178
|
+
}
|
|
179
|
+
else if (message.type === 'error') {
|
|
180
|
+
finish(new Error(message.error || 'Turns authentication failed.'));
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
socket.on('unexpected-response', (_request, response) => {
|
|
184
|
+
finish(new Error(`Turns rejected authentication (HTTP ${response.statusCode}).`));
|
|
185
|
+
});
|
|
186
|
+
socket.on('error', (error) => finish(error));
|
|
187
|
+
socket.on('close', () => finish(new Error('The authentication connection closed before it completed.')));
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
export async function browserAuthenticate(origin, { fetchImpl = fetch, waitForAuthenticationImpl = waitForAuthentication, openBrowserImpl = openBrowser, output = process.stdout, now = Date.now(), } = {}) {
|
|
191
|
+
const normalized = normalizeOrigin(origin);
|
|
192
|
+
const response = await fetchImpl(new URL('/api/cli/auth/start', normalized), { method: 'POST' });
|
|
193
|
+
const flow = (await response.json().catch(() => ({})));
|
|
194
|
+
if (!response.ok || (!flow.authUrl && !flow.authPath) || (!flow.socketUrl && !flow.socketPath)) {
|
|
195
|
+
throw new Error(flow.error || `Could not start Turns authentication (HTTP ${response.status}).`);
|
|
196
|
+
}
|
|
197
|
+
const authUrl = flow.authPath ? new URL(flow.authPath, normalized).toString() : flow.authUrl;
|
|
198
|
+
const authSocket = flow.socketPath ? socketUrl(normalized, flow.socketPath) : flow.socketUrl;
|
|
199
|
+
const authentication = waitForAuthenticationImpl(authSocket);
|
|
200
|
+
const opened = openBrowserImpl(authUrl);
|
|
201
|
+
output.write(`${opened ? 'Opening' : 'Open'} this URL to sign in with GitHub:\n\n ${authUrl}\n\n`);
|
|
202
|
+
output.write('Waiting for authentication…\n');
|
|
203
|
+
const result = await authentication;
|
|
204
|
+
return {
|
|
205
|
+
version: 1,
|
|
206
|
+
origin: normalized,
|
|
207
|
+
accessToken: result.accessToken,
|
|
208
|
+
refreshToken: result.refreshToken,
|
|
209
|
+
expiresAt: result.expiresAt,
|
|
210
|
+
user: result.user,
|
|
211
|
+
authenticatedAt: new Date(now).toISOString(),
|
|
212
|
+
refreshedAt: new Date(now).toISOString(),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/** Resolve credentials without contacting the server unless daily refresh is due. */
|
|
216
|
+
export async function authenticate({ origin, forceBrowser = false, interactive = true, paths = applicationPaths(), fetchImpl = fetch, browserAuthenticateImpl = browserAuthenticate, now = Date.now(), } = {}) {
|
|
217
|
+
const saved = readCredentials(paths);
|
|
218
|
+
const selectedOrigin = normalizeOrigin(origin || saved?.origin || DEFAULT_ORIGIN);
|
|
219
|
+
if (!forceBrowser && saved?.origin === selectedOrigin) {
|
|
220
|
+
if (!credentialsNeedRefresh(saved, now))
|
|
221
|
+
return saved;
|
|
222
|
+
try {
|
|
223
|
+
const refreshed = await refreshCredentials(saved, { fetchImpl, now });
|
|
224
|
+
writeCredentials(refreshed, paths);
|
|
225
|
+
return refreshed;
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
if (!interactive)
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (!interactive)
|
|
233
|
+
throw new Error('Turns authentication is missing or invalid. Run `turns login` in a terminal.');
|
|
234
|
+
const credentials = await browserAuthenticateImpl(selectedOrigin, { fetchImpl, now });
|
|
235
|
+
writeCredentials(credentials, paths);
|
|
236
|
+
return credentials;
|
|
237
|
+
}
|
|
238
|
+
/** Retry an upload-socket rejection with the permanent refresh token first. */
|
|
239
|
+
export async function recoverRejectedCredentials(credentials, { paths = applicationPaths(), fetchImpl = fetch, browserAuthenticateImpl = browserAuthenticate, interactive = true, now = Date.now(), } = {}) {
|
|
240
|
+
try {
|
|
241
|
+
const refreshed = await refreshCredentials(credentials, { fetchImpl, now });
|
|
242
|
+
writeCredentials(refreshed, paths);
|
|
243
|
+
return refreshed;
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
if (!interactive)
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
return authenticate({
|
|
250
|
+
origin: credentials.origin,
|
|
251
|
+
forceBrowser: true,
|
|
252
|
+
interactive,
|
|
253
|
+
paths,
|
|
254
|
+
fetchImpl,
|
|
255
|
+
browserAuthenticateImpl,
|
|
256
|
+
now,
|
|
257
|
+
});
|
|
258
|
+
}
|
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { applicationPaths } from "./auth.js";
|
|
7
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
/** Resolve linked-worktree installs back to the primary checkout. A service
|
|
9
|
+
* must not remain pinned to an expendable feature worktree after that branch
|
|
10
|
+
* has fallen behind or the worktree has been removed. */
|
|
11
|
+
export function canonicalDaemonCliEntry(cliDirectory = HERE) {
|
|
12
|
+
const built = path.basename(cliDirectory) === 'dist';
|
|
13
|
+
const cliRoot = built ? path.dirname(cliDirectory) : cliDirectory;
|
|
14
|
+
const extension = built ? '.js' : path.extname(fileURLToPath(import.meta.url));
|
|
15
|
+
const localEntry = path.join(cliDirectory, `index${extension}`);
|
|
16
|
+
const checkout = path.resolve(cliRoot, '..');
|
|
17
|
+
const dotGit = path.join(checkout, '.git');
|
|
18
|
+
try {
|
|
19
|
+
if (fs.statSync(dotGit).isDirectory())
|
|
20
|
+
return localEntry;
|
|
21
|
+
}
|
|
22
|
+
catch { }
|
|
23
|
+
try {
|
|
24
|
+
const match = /^gitdir:\s*(.+)\s*$/imu.exec(fs.readFileSync(dotGit, 'utf8'));
|
|
25
|
+
if (!match)
|
|
26
|
+
return localEntry;
|
|
27
|
+
const worktreeGitDirectory = path.resolve(checkout, match[1]);
|
|
28
|
+
const commonRelative = fs.readFileSync(path.join(worktreeGitDirectory, 'commondir'), 'utf8').trim();
|
|
29
|
+
const commonGitDirectory = path.resolve(worktreeGitDirectory, commonRelative);
|
|
30
|
+
const primaryEntry = path.join(path.dirname(commonGitDirectory), 'cli', ...(built ? ['dist'] : []), `index${extension}`);
|
|
31
|
+
if (fs.statSync(primaryEntry).isFile())
|
|
32
|
+
return primaryEntry;
|
|
33
|
+
}
|
|
34
|
+
catch { }
|
|
35
|
+
return localEntry;
|
|
36
|
+
}
|
|
37
|
+
export const SYSTEMD_SERVICE_NAME = 'turns.service';
|
|
38
|
+
export const LEGACY_SYSTEMD_SERVICE_NAME = 'turns-native-uploader.service';
|
|
39
|
+
function systemdQuote(value) {
|
|
40
|
+
return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')}"`;
|
|
41
|
+
}
|
|
42
|
+
export function linuxServiceUnit({ cliEntry = canonicalDaemonCliEntry(), execPath = process.execPath } = {}) {
|
|
43
|
+
return `[Unit]
|
|
44
|
+
Description=Continuously upload local AI sessions to turns.dev
|
|
45
|
+
Wants=network-online.target
|
|
46
|
+
After=network-online.target
|
|
47
|
+
|
|
48
|
+
[Service]
|
|
49
|
+
Type=simple
|
|
50
|
+
# Agent anchors use detached process groups and survive daemon restarts.
|
|
51
|
+
KillMode=process
|
|
52
|
+
WorkingDirectory=%h
|
|
53
|
+
ExecStart=${systemdQuote(execPath)} --max-old-space-size=256 --max-semi-space-size=8 --experimental-strip-types ${systemdQuote(cliEntry)} daemon run
|
|
54
|
+
Restart=on-failure
|
|
55
|
+
RestartSec=5
|
|
56
|
+
Environment=NODE_NO_WARNINGS=1
|
|
57
|
+
|
|
58
|
+
[Install]
|
|
59
|
+
WantedBy=default.target
|
|
60
|
+
`;
|
|
61
|
+
}
|
|
62
|
+
function systemctl(arguments_, { spawnSyncImpl = spawnSync, allowFailure = false } = {}) {
|
|
63
|
+
const result = spawnSyncImpl('systemctl', ['--user', ...arguments_], { encoding: 'utf8', stdio: 'pipe' });
|
|
64
|
+
if (!allowFailure && result.status !== 0) {
|
|
65
|
+
throw new Error(String(result.stderr || result.stdout || `systemctl ${arguments_.join(' ')} failed`).trim());
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
export function installLinuxDaemon({ paths = applicationPaths(), spawnSyncImpl = spawnSync, } = {}) {
|
|
70
|
+
fs.mkdirSync(path.dirname(paths.linuxService), { recursive: true, mode: 0o700 });
|
|
71
|
+
const unit = linuxServiceUnit();
|
|
72
|
+
const temporary = `${paths.linuxService}.${process.pid}.tmp`;
|
|
73
|
+
fs.writeFileSync(temporary, unit, { mode: 0o644 });
|
|
74
|
+
fs.renameSync(temporary, paths.linuxService);
|
|
75
|
+
systemctl(['disable', '--now', LEGACY_SYSTEMD_SERVICE_NAME], { spawnSyncImpl, allowFailure: true });
|
|
76
|
+
const unitDirectory = path.dirname(paths.linuxService);
|
|
77
|
+
for (const legacyLink of [
|
|
78
|
+
path.join(unitDirectory, LEGACY_SYSTEMD_SERVICE_NAME),
|
|
79
|
+
path.join(unitDirectory, 'default.target.wants', LEGACY_SYSTEMD_SERVICE_NAME),
|
|
80
|
+
]) {
|
|
81
|
+
try {
|
|
82
|
+
if (fs.lstatSync(legacyLink).isSymbolicLink())
|
|
83
|
+
fs.unlinkSync(legacyLink);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error?.code !== 'ENOENT')
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
systemctl(['daemon-reload'], { spawnSyncImpl });
|
|
91
|
+
// `enable --now` starts an inactive unit, but deliberately leaves an
|
|
92
|
+
// already-running unit alone. An install from a different checkout would
|
|
93
|
+
// therefore rewrite ExecStart while the old daemon kept running until its
|
|
94
|
+
// next failure or reboot. Enable first, then restart unconditionally so the
|
|
95
|
+
// process executing after a successful install always matches this unit.
|
|
96
|
+
systemctl(['enable', SYSTEMD_SERVICE_NAME], { spawnSyncImpl });
|
|
97
|
+
systemctl(['restart', SYSTEMD_SERVICE_NAME], { spawnSyncImpl });
|
|
98
|
+
return paths.linuxService;
|
|
99
|
+
}
|
|
100
|
+
export function uninstallLinuxDaemon({ paths = applicationPaths(), spawnSyncImpl = spawnSync, } = {}) {
|
|
101
|
+
systemctl(['disable', '--now', SYSTEMD_SERVICE_NAME], { spawnSyncImpl, allowFailure: true });
|
|
102
|
+
try {
|
|
103
|
+
fs.unlinkSync(paths.linuxService);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error?.code !== 'ENOENT')
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
systemctl(['daemon-reload'], { spawnSyncImpl, allowFailure: true });
|
|
110
|
+
}
|
|
111
|
+
export function linuxDaemonStatus({ spawnSyncImpl = spawnSync } = {}) {
|
|
112
|
+
const result = systemctl(['is-active', SYSTEMD_SERVICE_NAME], { spawnSyncImpl, allowFailure: true });
|
|
113
|
+
return {
|
|
114
|
+
active: result.status === 0 && String(result.stdout).trim() === 'active',
|
|
115
|
+
status: String(result.stdout || result.stderr || 'unknown').trim(),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
export function installDaemon(options = {}) {
|
|
119
|
+
if (process.platform !== 'linux') {
|
|
120
|
+
throw new Error('Automatic daemon installation is currently supported on Linux with systemd.');
|
|
121
|
+
}
|
|
122
|
+
return installLinuxDaemon(options);
|
|
123
|
+
}
|
|
124
|
+
export function uninstallDaemon(options = {}) {
|
|
125
|
+
if (process.platform !== 'linux') {
|
|
126
|
+
throw new Error('Automatic daemon removal is currently supported on Linux with systemd.');
|
|
127
|
+
}
|
|
128
|
+
return uninstallLinuxDaemon(options);
|
|
129
|
+
}
|
|
130
|
+
export function daemonStatus(options = {}) {
|
|
131
|
+
if (process.platform !== 'linux')
|
|
132
|
+
return { active: false, status: 'unsupported' };
|
|
133
|
+
return linuxDaemonStatus(options);
|
|
134
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { zstdDecompressSync } from 'node:zlib';
|
|
3
|
+
const ZSTD_MAGIC = 0xfd2fb528;
|
|
4
|
+
const HEADER_READ_BYTES = 8 * 1024;
|
|
5
|
+
/**
|
|
6
|
+
* Locate complete frames in DeepSeek Harness's concatenated-zstd container.
|
|
7
|
+
* An incomplete final frame is deliberately omitted: DSH exposes only the
|
|
8
|
+
* committed prefix while a durable append is still being written.
|
|
9
|
+
*/
|
|
10
|
+
export function scanDeepseekZstdFrames(buffer, maxFrames = Number.POSITIVE_INFINITY) {
|
|
11
|
+
const frames = [];
|
|
12
|
+
let offset = 0;
|
|
13
|
+
const torn = (start) => ({ frames, tornStart: start });
|
|
14
|
+
while (offset < buffer.length && frames.length < maxFrames) {
|
|
15
|
+
const start = offset;
|
|
16
|
+
if (buffer.length - offset < 4)
|
|
17
|
+
return torn(start);
|
|
18
|
+
if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) {
|
|
19
|
+
throw new Error(`corrupt DeepSeek trajectory: invalid zstd frame magic at byte ${offset}`);
|
|
20
|
+
}
|
|
21
|
+
offset += 4;
|
|
22
|
+
if (offset === buffer.length)
|
|
23
|
+
return torn(start);
|
|
24
|
+
const descriptor = buffer.readUInt8(offset);
|
|
25
|
+
offset += 1;
|
|
26
|
+
if ((descriptor & 24) !== 0) {
|
|
27
|
+
throw new Error(`corrupt DeepSeek trajectory: reserved zstd header bit at byte ${offset - 1}`);
|
|
28
|
+
}
|
|
29
|
+
const contentSizeFlag = descriptor >>> 6;
|
|
30
|
+
const singleSegment = (descriptor & 32) !== 0;
|
|
31
|
+
const checksum = (descriptor & 4) !== 0;
|
|
32
|
+
const dictionaryFlag = descriptor & 3;
|
|
33
|
+
const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
|
|
34
|
+
const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag;
|
|
35
|
+
const headerBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
|
|
36
|
+
if (buffer.length - offset < headerBytes)
|
|
37
|
+
return torn(start);
|
|
38
|
+
offset += headerBytes;
|
|
39
|
+
for (;;) {
|
|
40
|
+
if (buffer.length - offset < 3)
|
|
41
|
+
return torn(start);
|
|
42
|
+
const blockHeader = buffer.readUIntLE(offset, 3);
|
|
43
|
+
offset += 3;
|
|
44
|
+
const lastBlock = (blockHeader & 1) !== 0;
|
|
45
|
+
const blockType = (blockHeader >>> 1) & 3;
|
|
46
|
+
const blockSize = blockHeader >>> 3;
|
|
47
|
+
if (blockType === 3) {
|
|
48
|
+
throw new Error(`corrupt DeepSeek trajectory: reserved zstd block type at byte ${offset - 3}`);
|
|
49
|
+
}
|
|
50
|
+
const payloadBytes = blockType === 1 ? 1 : blockSize;
|
|
51
|
+
if (buffer.length - offset < payloadBytes)
|
|
52
|
+
return torn(start);
|
|
53
|
+
offset += payloadBytes;
|
|
54
|
+
if (lastBlock)
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
if (checksum) {
|
|
58
|
+
if (buffer.length - offset < 4)
|
|
59
|
+
return torn(start);
|
|
60
|
+
offset += 4;
|
|
61
|
+
}
|
|
62
|
+
frames.push({ start, end: offset });
|
|
63
|
+
}
|
|
64
|
+
return { frames };
|
|
65
|
+
}
|
|
66
|
+
function parseHeaderLine(line, file) {
|
|
67
|
+
let header;
|
|
68
|
+
try {
|
|
69
|
+
header = JSON.parse(line);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
throw new Error(`invalid DeepSeek trajectory header in ${file}`, { cause: error });
|
|
73
|
+
}
|
|
74
|
+
if (!header || header.type !== 'session' || !String(header.id || '').trim()) {
|
|
75
|
+
throw new Error(`invalid DeepSeek trajectory session header in ${file}`);
|
|
76
|
+
}
|
|
77
|
+
return header;
|
|
78
|
+
}
|
|
79
|
+
/** Read only the independently compressed first frame, never the trajectory. */
|
|
80
|
+
export function readDeepseekTrajectoryHeader(file) {
|
|
81
|
+
if (!file.endsWith('.zstd')) {
|
|
82
|
+
const descriptor = fs.openSync(file, 'r');
|
|
83
|
+
try {
|
|
84
|
+
const buffer = Buffer.alloc(1024 * 1024);
|
|
85
|
+
const bytes = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
86
|
+
const source = buffer.subarray(0, bytes).toString('utf8');
|
|
87
|
+
const newline = source.indexOf('\n');
|
|
88
|
+
if (newline < 0)
|
|
89
|
+
throw new Error(`unfinished DeepSeek trajectory header in ${file}`);
|
|
90
|
+
return parseHeaderLine(source.slice(0, newline).replace(/\r$/u, ''), file);
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
fs.closeSync(descriptor);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const descriptor = fs.openSync(file, 'r');
|
|
97
|
+
try {
|
|
98
|
+
let content = Buffer.alloc(HEADER_READ_BYTES);
|
|
99
|
+
let length = 0;
|
|
100
|
+
for (;;) {
|
|
101
|
+
if (length === content.length) {
|
|
102
|
+
const expanded = Buffer.alloc(content.length * 2);
|
|
103
|
+
content.copy(expanded);
|
|
104
|
+
content = expanded;
|
|
105
|
+
}
|
|
106
|
+
const bytes = fs.readSync(descriptor, content, length, content.length - length, length);
|
|
107
|
+
length += bytes;
|
|
108
|
+
const first = scanDeepseekZstdFrames(content.subarray(0, length), 1).frames[0];
|
|
109
|
+
if (first) {
|
|
110
|
+
const plaintext = zstdDecompressSync(content.subarray(first.start, first.end)).toString('utf8');
|
|
111
|
+
const newline = plaintext.indexOf('\n');
|
|
112
|
+
if (newline < 0)
|
|
113
|
+
throw new Error(`unfinished DeepSeek trajectory header frame in ${file}`);
|
|
114
|
+
return parseHeaderLine(plaintext.slice(0, newline).replace(/\r$/u, ''), file);
|
|
115
|
+
}
|
|
116
|
+
if (bytes === 0)
|
|
117
|
+
throw new Error(`unfinished DeepSeek trajectory header frame in ${file}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
fs.closeSync(descriptor);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Return physical JSONL records from the committed trajectory prefix. Packed
|
|
126
|
+
* `*-chunks` records intentionally stay packed; the raw reducer consumes the
|
|
127
|
+
* authoritative surface records and does not need an expanded pre-pass.
|
|
128
|
+
*/
|
|
129
|
+
export function readDeepseekTrajectoryLines(file) {
|
|
130
|
+
let source;
|
|
131
|
+
if (file.endsWith('.zstd')) {
|
|
132
|
+
const compressed = fs.readFileSync(file);
|
|
133
|
+
const { frames } = scanDeepseekZstdFrames(compressed);
|
|
134
|
+
source = Buffer.concat(frames.map((frame) => zstdDecompressSync(compressed.subarray(frame.start, frame.end)))).toString('utf8');
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
source = fs.readFileSync(file, 'utf8');
|
|
138
|
+
}
|
|
139
|
+
// A plaintext write can be observed midway through its final line. Zstd
|
|
140
|
+
// frames are committed atomically, but applying the same newline rule keeps
|
|
141
|
+
// both storage modes equivalent and prevents a transient malformed row.
|
|
142
|
+
const complete = source.slice(0, source.lastIndexOf('\n') + 1);
|
|
143
|
+
return complete.split(/\r?\n/u).filter(Boolean);
|
|
144
|
+
}
|
|
145
|
+
/** Bounded-memory variant for the long-lived uploader. Concatenated zstd
|
|
146
|
+
* frames are decompressed one at a time and plaintext is exposed only through
|
|
147
|
+
* complete newline-terminated records. */
|
|
148
|
+
export async function* streamDeepseekTrajectoryLines(file) {
|
|
149
|
+
if (!file.endsWith('.zstd')) {
|
|
150
|
+
let carry = Buffer.alloc(0);
|
|
151
|
+
for await (const chunk of fs.createReadStream(file)) {
|
|
152
|
+
const buffer = carry.length ? Buffer.concat([carry, chunk]) : chunk;
|
|
153
|
+
let start = 0;
|
|
154
|
+
for (;;) {
|
|
155
|
+
const newline = buffer.indexOf(0x0a, start);
|
|
156
|
+
if (newline < 0)
|
|
157
|
+
break;
|
|
158
|
+
let line = buffer.subarray(start, newline);
|
|
159
|
+
if (line.at(-1) === 0x0d)
|
|
160
|
+
line = line.subarray(0, -1);
|
|
161
|
+
if (line.length)
|
|
162
|
+
yield line.toString('utf8');
|
|
163
|
+
start = newline + 1;
|
|
164
|
+
}
|
|
165
|
+
carry = buffer.subarray(start);
|
|
166
|
+
}
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
let compressed = Buffer.alloc(0);
|
|
170
|
+
let plaintextCarry = '';
|
|
171
|
+
const emitPlaintext = function* (plaintext) {
|
|
172
|
+
const source = plaintextCarry + plaintext;
|
|
173
|
+
let start = 0;
|
|
174
|
+
for (;;) {
|
|
175
|
+
const newline = source.indexOf('\n', start);
|
|
176
|
+
if (newline < 0)
|
|
177
|
+
break;
|
|
178
|
+
const line = source.slice(start, newline).replace(/\r$/u, '');
|
|
179
|
+
if (line)
|
|
180
|
+
yield line;
|
|
181
|
+
start = newline + 1;
|
|
182
|
+
}
|
|
183
|
+
plaintextCarry = source.slice(start);
|
|
184
|
+
};
|
|
185
|
+
for await (const chunk of fs.createReadStream(file)) {
|
|
186
|
+
compressed = compressed.length ? Buffer.concat([compressed, chunk]) : chunk;
|
|
187
|
+
const { frames } = scanDeepseekZstdFrames(compressed);
|
|
188
|
+
if (!frames.length)
|
|
189
|
+
continue;
|
|
190
|
+
for (const frame of frames) {
|
|
191
|
+
const plaintext = zstdDecompressSync(compressed.subarray(frame.start, frame.end)).toString('utf8');
|
|
192
|
+
yield* emitPlaintext(plaintext);
|
|
193
|
+
}
|
|
194
|
+
compressed = compressed.subarray(frames.at(-1).end);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
function canonicalDirectory(value, label) {
|
|
4
|
+
const resolved = path.resolve(value);
|
|
5
|
+
let canonical;
|
|
6
|
+
try {
|
|
7
|
+
canonical = fs.realpathSync(resolved);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
throw new Error(`${label} is unavailable: ${resolved}`);
|
|
11
|
+
}
|
|
12
|
+
if (!fs.statSync(canonical).isDirectory())
|
|
13
|
+
throw new Error(`${label} is not a directory: ${resolved}`);
|
|
14
|
+
return canonical;
|
|
15
|
+
}
|
|
16
|
+
export function normalizeAllowedDirectories(values = []) {
|
|
17
|
+
return [...new Set(values.map((value) => canonicalDirectory(value, 'Allowed directory prefix')))];
|
|
18
|
+
}
|
|
19
|
+
export function workingDirectoryIsAllowed(workingDirectory, allowedDirectories = []) {
|
|
20
|
+
if (!allowedDirectories.length)
|
|
21
|
+
return true;
|
|
22
|
+
if (!workingDirectory || !path.isAbsolute(workingDirectory))
|
|
23
|
+
return false;
|
|
24
|
+
let canonical;
|
|
25
|
+
try {
|
|
26
|
+
canonical = canonicalDirectory(workingDirectory, 'Working directory');
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return allowedDirectories.some((allowed) => {
|
|
32
|
+
const relative = path.relative(allowed, canonical);
|
|
33
|
+
return (relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
export function assertAllowedWorkingDirectory(workingDirectory, allowedDirectories = []) {
|
|
37
|
+
if (!workingDirectoryIsAllowed(workingDirectory, allowedDirectories)) {
|
|
38
|
+
throw new Error(`Working directory is outside the daemon allowlist: ${workingDirectory || '(missing)'}`);
|
|
39
|
+
}
|
|
40
|
+
return fs.realpathSync(path.resolve(workingDirectory));
|
|
41
|
+
}
|