@agentpulselabs/cli-plugin-pulse 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -32
- package/package.json +10 -18
- package/src/commands/pulse/doctor.js +14 -0
- package/src/commands/pulse/hub-write.js +26 -0
- package/src/commands/pulse/install.js +14 -0
- package/src/commands/pulse/login.js +20 -0
- package/src/commands/pulse/mirror.js +27 -0
- package/src/commands/pulse/pair.js +38 -0
- package/src/commands/pulse/restart.js +14 -0
- package/src/commands/pulse/start.js +6 -18
- package/src/commands/pulse/status.js +6 -6
- package/src/commands/pulse/stop.js +24 -0
- package/src/daemon/main.js +36 -0
- package/src/lib/BaseCommand.js +23 -23
- package/src/lib/agentSessions.js +279 -0
- package/src/lib/application.js +656 -0
- package/src/lib/backoff.js +8 -0
- package/src/lib/budgets.js +85 -0
- package/src/lib/cliDefaults.js +20 -0
- package/src/lib/cloudAuth.js +189 -0
- package/src/lib/contract.sha256 +2 -0
- package/src/lib/daemonComposition.js +338 -0
- package/src/lib/daemonLock.js +55 -0
- package/src/lib/daemonLoop.js +69 -0
- package/src/lib/daemonProcess.js +366 -0
- package/src/lib/daemonRuntime.js +820 -0
- package/src/lib/drivers.js +280 -0
- package/src/lib/errors.js +19 -0
- package/src/lib/graphWindows.js +149 -0
- package/src/lib/http.js +84 -0
- package/src/lib/hubActions.js +74 -0
- package/src/lib/identity.js +102 -0
- package/src/lib/intents.js +590 -0
- package/src/lib/ledger.js +535 -0
- package/src/lib/lifecycle.js +123 -0
- package/src/lib/localHub.js +217 -0
- package/src/lib/localHubWriter.js +69 -0
- package/src/lib/mirror-contract.json +4299 -0
- package/src/lib/mirrorContract.js +513 -0
- package/src/lib/mirrorDaemon.js +127 -0
- package/src/lib/mirrorService.js +358 -0
- package/src/lib/mirrorSource.js +455 -0
- package/src/lib/mirrorSync.js +353 -0
- package/src/lib/oauthLogin.js +132 -0
- package/src/lib/ownerLock.js +99 -0
- package/src/lib/pairedConfig.js +40 -0
- package/src/lib/pairingLifecycle.js +179 -0
- package/src/lib/pathRedaction.js +89 -0
- package/src/lib/planContract.js +141 -0
- package/src/lib/priorityLane.js +80 -0
- package/src/lib/processOwnership.js +252 -0
- package/src/lib/processSupervisor.js +376 -0
- package/src/lib/redaction.js +128 -0
- package/src/lib/rootPolicy.js +372 -0
- package/src/lib/rulePacks.js +162 -0
- package/src/lib/secretStore.js +169 -0
- package/src/lib/selfRestart.js +261 -0
- package/src/lib/serviceManager.js +358 -0
- package/src/lib/storage.js +131 -0
- package/src/lib/subprocess.js +45 -0
- package/src/lib/tokenStore.js +97 -0
- package/src/lib/transport.js +286 -0
- package/src/lib/transportContract.js +37 -0
- package/LICENSE +0 -21
- package/src/commands/pulse/gate.js +0 -25
- package/src/commands/pulse/next.js +0 -14
- package/src/commands/pulse/submit.js +0 -21
- package/src/commands/pulse/verify.js +0 -21
- package/src/runPulse.js +0 -58
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { access, readFile } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import { HubError } from './errors.js';
|
|
6
|
+
import {
|
|
7
|
+
ensureOwnerOnlyDirectory,
|
|
8
|
+
readOwnerOnlyJson,
|
|
9
|
+
writeOwnerOnlyText,
|
|
10
|
+
} from './storage.js';
|
|
11
|
+
import { execFileBounded as execFile } from './subprocess.js';
|
|
12
|
+
|
|
13
|
+
const LABEL = 'com.agentpulse.hub';
|
|
14
|
+
const SYSTEMD_UNIT = 'agentpulse-hub.service';
|
|
15
|
+
const START_ATTEMPTS = 6;
|
|
16
|
+
const START_RETRY_MS = 250;
|
|
17
|
+
const ABSENT_SERVICE = /not found|could not find|no such process|not loaded|inactive|not running/i;
|
|
18
|
+
|
|
19
|
+
function xml(value) {
|
|
20
|
+
return String(value)
|
|
21
|
+
.replaceAll('&', '&')
|
|
22
|
+
.replaceAll('<', '<')
|
|
23
|
+
.replaceAll('>', '>')
|
|
24
|
+
.replaceAll('"', '"')
|
|
25
|
+
.replaceAll("'", ''');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function launchdPlist({ nodePath, daemonPath, configDir, logDir }) {
|
|
29
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
30
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
31
|
+
<plist version="1.0">
|
|
32
|
+
<dict>
|
|
33
|
+
<key>Label</key><string>${LABEL}</string>
|
|
34
|
+
<key>ProgramArguments</key>
|
|
35
|
+
<array>
|
|
36
|
+
<string>${xml(nodePath)}</string>
|
|
37
|
+
<string>${xml(daemonPath)}</string>
|
|
38
|
+
<string>--config-dir</string>
|
|
39
|
+
<string>${xml(configDir)}</string>
|
|
40
|
+
</array>
|
|
41
|
+
<key>RunAtLoad</key><true/>
|
|
42
|
+
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
43
|
+
<key>ProcessType</key><string>Standard</string>
|
|
44
|
+
<key>StandardOutPath</key><string>${xml(path.join(logDir, 'daemon.log'))}</string>
|
|
45
|
+
<key>StandardErrorPath</key><string>${xml(path.join(logDir, 'daemon-error.log'))}</string>
|
|
46
|
+
</dict>
|
|
47
|
+
</plist>
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function unxml(value) {
|
|
52
|
+
return value
|
|
53
|
+
.replaceAll(''', "'")
|
|
54
|
+
.replaceAll('"', '"')
|
|
55
|
+
.replaceAll('>', '>')
|
|
56
|
+
.replaceAll('<', '<')
|
|
57
|
+
.replaceAll('&', '&');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function launchdArgv(contents) {
|
|
61
|
+
const programArguments = contents.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/)?.[1] || '';
|
|
62
|
+
return [...programArguments.matchAll(/<string>([^<]*)<\/string>/g)].map((match) => unxml(match[1]));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function systemdArgv(contents) {
|
|
66
|
+
const execStart = contents.match(/^ExecStart=(.*)$/m)?.[1] || '';
|
|
67
|
+
return [...execStart.matchAll(/"((?:\\.|[^"\\])*)"|(\S+)/g)].map(([, quoted, bare]) =>
|
|
68
|
+
quoted === undefined ? bare : quoted.replace(/\\(["\\])/g, '$1')
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function exists(file) {
|
|
73
|
+
try {
|
|
74
|
+
await access(file);
|
|
75
|
+
return true;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function systemdArgument(value) {
|
|
82
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) return value;
|
|
83
|
+
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function systemdUnit({ nodePath, daemonPath, configDir }) {
|
|
87
|
+
for (const value of [nodePath, daemonPath, configDir]) {
|
|
88
|
+
if (/[\n\r]/.test(value)) {
|
|
89
|
+
throw new HubError('HUB_SERVICE_PATH_DENIED', 'Service paths cannot contain newlines');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return `[Unit]
|
|
93
|
+
Description=AgentPulse Pulse local connector
|
|
94
|
+
After=network-online.target
|
|
95
|
+
Wants=network-online.target
|
|
96
|
+
|
|
97
|
+
[Service]
|
|
98
|
+
Type=simple
|
|
99
|
+
ExecStart=${systemdArgument(nodePath)} ${systemdArgument(daemonPath)} --config-dir ${systemdArgument(configDir)}
|
|
100
|
+
Restart=on-failure
|
|
101
|
+
RestartSec=5
|
|
102
|
+
NoNewPrivileges=true
|
|
103
|
+
PrivateTmp=true
|
|
104
|
+
|
|
105
|
+
[Install]
|
|
106
|
+
WantedBy=default.target
|
|
107
|
+
`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class ServiceManager {
|
|
111
|
+
constructor({
|
|
112
|
+
platform = process.platform,
|
|
113
|
+
home = os.homedir(),
|
|
114
|
+
uid = typeof process.getuid === 'function' ? process.getuid() : null,
|
|
115
|
+
nodePath = process.execPath,
|
|
116
|
+
daemonPath,
|
|
117
|
+
configDir = path.join(
|
|
118
|
+
process.env.XDG_CONFIG_HOME || path.join(home, '.config'),
|
|
119
|
+
'agentpulse',
|
|
120
|
+
'hub'
|
|
121
|
+
),
|
|
122
|
+
logDir = path.join(
|
|
123
|
+
process.env.XDG_STATE_HOME || path.join(home, '.local', 'state'),
|
|
124
|
+
'agentpulse',
|
|
125
|
+
'hub',
|
|
126
|
+
'logs'
|
|
127
|
+
),
|
|
128
|
+
execute = execFile,
|
|
129
|
+
wait = delay,
|
|
130
|
+
}) {
|
|
131
|
+
this.platform = platform;
|
|
132
|
+
this.home = home;
|
|
133
|
+
this.uid = uid;
|
|
134
|
+
this.nodePath = nodePath;
|
|
135
|
+
this.daemonPath = daemonPath;
|
|
136
|
+
this.configDir = configDir;
|
|
137
|
+
this.logDir = logDir;
|
|
138
|
+
this.execute = execute;
|
|
139
|
+
this.wait = wait;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
serviceFile() {
|
|
143
|
+
switch (this.platform) {
|
|
144
|
+
case 'darwin':
|
|
145
|
+
return path.join(this.home, 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
146
|
+
case 'linux':
|
|
147
|
+
return path.join(this.home, '.config', 'systemd', 'user', SYSTEMD_UNIT);
|
|
148
|
+
default:
|
|
149
|
+
throw new HubError('HUB_UNSUPPORTED_PLATFORM', `Unsupported service platform: ${this.platform}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async install() {
|
|
154
|
+
if (!this.daemonPath) {
|
|
155
|
+
throw new HubError('HUB_DAEMON_PATH_MISSING', 'Daemon entry point is unavailable');
|
|
156
|
+
}
|
|
157
|
+
const serviceFile = this.serviceFile();
|
|
158
|
+
switch (this.platform) {
|
|
159
|
+
case 'darwin':
|
|
160
|
+
await ensureOwnerOnlyDirectory(this.logDir);
|
|
161
|
+
await writeOwnerOnlyText(
|
|
162
|
+
serviceFile,
|
|
163
|
+
launchdPlist({
|
|
164
|
+
nodePath: this.nodePath,
|
|
165
|
+
daemonPath: this.daemonPath,
|
|
166
|
+
configDir: this.configDir,
|
|
167
|
+
logDir: this.logDir,
|
|
168
|
+
})
|
|
169
|
+
);
|
|
170
|
+
break;
|
|
171
|
+
case 'linux':
|
|
172
|
+
await writeOwnerOnlyText(
|
|
173
|
+
serviceFile,
|
|
174
|
+
systemdUnit({
|
|
175
|
+
nodePath: this.nodePath,
|
|
176
|
+
daemonPath: this.daemonPath,
|
|
177
|
+
configDir: this.configDir,
|
|
178
|
+
})
|
|
179
|
+
);
|
|
180
|
+
await this.execute('systemctl', ['--user', 'daemon-reload']);
|
|
181
|
+
break;
|
|
182
|
+
default:
|
|
183
|
+
throw new HubError('HUB_UNSUPPORTED_PLATFORM', `Unsupported service platform: ${this.platform}`);
|
|
184
|
+
}
|
|
185
|
+
return { serviceFile, platform: this.platform, label: LABEL };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async daemonPathCheck() {
|
|
189
|
+
let contents;
|
|
190
|
+
try {
|
|
191
|
+
contents = await readFile(this.serviceFile(), 'utf8');
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error.code === 'ENOENT') return { ok: true, installed: null, expected: this.daemonPath };
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
let argv;
|
|
197
|
+
switch (this.platform) {
|
|
198
|
+
case 'darwin':
|
|
199
|
+
argv = launchdArgv(contents);
|
|
200
|
+
break;
|
|
201
|
+
case 'linux':
|
|
202
|
+
argv = systemdArgv(contents);
|
|
203
|
+
break;
|
|
204
|
+
default:
|
|
205
|
+
throw new HubError('HUB_UNSUPPORTED_PLATFORM', `Unsupported service platform: ${this.platform}`);
|
|
206
|
+
}
|
|
207
|
+
const [nodePath = null, installed = null] = argv;
|
|
208
|
+
let reason = null;
|
|
209
|
+
if (installed !== this.daemonPath) reason = 'daemon_path_changed';
|
|
210
|
+
else if (!nodePath || !(await exists(nodePath))) reason = 'node_missing';
|
|
211
|
+
if (!reason) return { ok: true, installed, expected: this.daemonPath, nodePath };
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
code: 'HUB_SERVICE_PATH_STALE',
|
|
215
|
+
reason,
|
|
216
|
+
installed,
|
|
217
|
+
expected: this.daemonPath,
|
|
218
|
+
nodePath,
|
|
219
|
+
hint: 'Run `agentpulse pulse install` once, then `agentpulse pulse start`.',
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async start() {
|
|
224
|
+
switch (this.platform) {
|
|
225
|
+
case 'darwin':
|
|
226
|
+
await this.#startLaunchd();
|
|
227
|
+
break;
|
|
228
|
+
case 'linux':
|
|
229
|
+
await this.execute('systemctl', ['--user', 'start', SYSTEMD_UNIT]);
|
|
230
|
+
break;
|
|
231
|
+
default:
|
|
232
|
+
throw new HubError('HUB_UNSUPPORTED_PLATFORM', `Unsupported service platform: ${this.platform}`);
|
|
233
|
+
}
|
|
234
|
+
return this.status();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async #launchdLoaded() {
|
|
238
|
+
try {
|
|
239
|
+
await this.execute('/bin/launchctl', ['print', `gui/${this.uid}/${LABEL}`]);
|
|
240
|
+
return true;
|
|
241
|
+
} catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// launchd tears a booted-out service down asynchronously, so bootstrap and
|
|
247
|
+
// kickstart can fail briefly (codes 5 and 37) right after `pulse stop`.
|
|
248
|
+
async #startLaunchd() {
|
|
249
|
+
const target = `gui/${this.uid}/${LABEL}`;
|
|
250
|
+
let lastError = null;
|
|
251
|
+
for (let attempt = 0; attempt < START_ATTEMPTS; attempt += 1) {
|
|
252
|
+
if (attempt > 0) await this.wait(START_RETRY_MS * attempt);
|
|
253
|
+
try {
|
|
254
|
+
if (await this.#launchdLoaded()) {
|
|
255
|
+
await this.execute('/bin/launchctl', ['kickstart', '-k', target]);
|
|
256
|
+
} else {
|
|
257
|
+
await this.execute('/bin/launchctl', ['bootstrap', `gui/${this.uid}`, this.serviceFile()]);
|
|
258
|
+
await this.execute('/bin/launchctl', ['kickstart', target]);
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
} catch (error) {
|
|
262
|
+
lastError = error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
throw new HubError('HUB_SERVICE_START_FAILED', 'launchd could not start Pulse; run `agentpulse pulse install`, then `agentpulse pulse start`', {
|
|
266
|
+
cause: lastError,
|
|
267
|
+
details: { stderr: String(lastError?.stderr || lastError?.message || '').trim().slice(0, 300) },
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async status() {
|
|
272
|
+
switch (this.platform) {
|
|
273
|
+
case 'darwin': {
|
|
274
|
+
try {
|
|
275
|
+
const result = await this.execute('/bin/launchctl', [
|
|
276
|
+
'print',
|
|
277
|
+
`gui/${this.uid}/${LABEL}`,
|
|
278
|
+
]);
|
|
279
|
+
const pid = Number(result.stdout.match(/\bpid\s*=\s*(\d+)/)?.[1]) || null;
|
|
280
|
+
const state = result.stdout.match(/\bstate\s*=\s*(\w+)/)?.[1] || (pid ? 'running' : 'loaded');
|
|
281
|
+
return { state, pid, serviceFile: this.serviceFile() };
|
|
282
|
+
} catch {
|
|
283
|
+
return { state: 'stopped', pid: null, serviceFile: this.serviceFile() };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
case 'linux': {
|
|
287
|
+
try {
|
|
288
|
+
const result = await this.execute('systemctl', [
|
|
289
|
+
'--user',
|
|
290
|
+
'show',
|
|
291
|
+
SYSTEMD_UNIT,
|
|
292
|
+
'--property',
|
|
293
|
+
'ActiveState',
|
|
294
|
+
'--property',
|
|
295
|
+
'MainPID',
|
|
296
|
+
]);
|
|
297
|
+
const active = result.stdout.match(/^ActiveState=(.+)$/m)?.[1] || 'unknown';
|
|
298
|
+
const pid = Number(result.stdout.match(/^MainPID=(\d+)$/m)?.[1]) || null;
|
|
299
|
+
return {
|
|
300
|
+
state: active === 'active' ? 'running' : active,
|
|
301
|
+
pid,
|
|
302
|
+
serviceFile: this.serviceFile(),
|
|
303
|
+
};
|
|
304
|
+
} catch {
|
|
305
|
+
return { state: 'stopped', pid: null, serviceFile: this.serviceFile() };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
default:
|
|
309
|
+
throw new HubError('HUB_UNSUPPORTED_PLATFORM', `Unsupported service platform: ${this.platform}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async stop() {
|
|
314
|
+
switch (this.platform) {
|
|
315
|
+
case 'darwin':
|
|
316
|
+
try {
|
|
317
|
+
await this.execute('/bin/launchctl', ['bootout', `gui/${this.uid}/${LABEL}`]);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
const detail = `${error.message || ''}\n${error.stderr || ''}`;
|
|
320
|
+
if (!ABSENT_SERVICE.test(detail)) {
|
|
321
|
+
throw new HubError('HUB_SERVICE_STOP_FAILED', 'launchd could not stop Pulse', {
|
|
322
|
+
cause: error,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
break;
|
|
327
|
+
case 'linux':
|
|
328
|
+
try {
|
|
329
|
+
await this.execute('systemctl', ['--user', 'stop', SYSTEMD_UNIT]);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
const detail = `${error.message || ''}\n${error.stderr || ''}`;
|
|
332
|
+
if (!ABSENT_SERVICE.test(detail)) {
|
|
333
|
+
throw new HubError('HUB_SERVICE_STOP_FAILED', 'systemd could not stop Pulse', {
|
|
334
|
+
cause: error,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
break;
|
|
339
|
+
default:
|
|
340
|
+
throw new HubError('HUB_UNSUPPORTED_PLATFORM', `Unsupported service platform: ${this.platform}`);
|
|
341
|
+
}
|
|
342
|
+
return { state: 'stopped', pid: null, serviceFile: this.serviceFile() };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async health({ freshMs = 30_000, after = 0 } = {}) {
|
|
346
|
+
const health = await readOwnerOnlyJson(path.join(this.configDir, 'daemon-health.json'));
|
|
347
|
+
const heartbeat = Date.parse(health?.heartbeatAt);
|
|
348
|
+
return {
|
|
349
|
+
ok: Boolean(
|
|
350
|
+
health &&
|
|
351
|
+
Number.isFinite(heartbeat) &&
|
|
352
|
+
heartbeat >= after &&
|
|
353
|
+
Date.now() - heartbeat <= freshMs
|
|
354
|
+
),
|
|
355
|
+
health,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmod,
|
|
3
|
+
lstat,
|
|
4
|
+
mkdir,
|
|
5
|
+
open,
|
|
6
|
+
readFile,
|
|
7
|
+
rename,
|
|
8
|
+
rm,
|
|
9
|
+
} from 'node:fs/promises';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { randomBytes } from 'node:crypto';
|
|
12
|
+
import { HubError } from './errors.js';
|
|
13
|
+
|
|
14
|
+
export async function ensureOwnerOnlyDirectory(directory) {
|
|
15
|
+
const absolute = path.resolve(directory);
|
|
16
|
+
const root = path.parse(absolute).root;
|
|
17
|
+
const components = absolute.slice(root.length).split(path.sep).filter(Boolean);
|
|
18
|
+
let current = root;
|
|
19
|
+
let ownerAnchorReached = false;
|
|
20
|
+
for (const component of components) {
|
|
21
|
+
current = path.join(current, component);
|
|
22
|
+
try {
|
|
23
|
+
const componentInfo = await lstat(current);
|
|
24
|
+
if (componentInfo.isSymbolicLink()) {
|
|
25
|
+
if (ownerAnchorReached || current === absolute) {
|
|
26
|
+
throw new HubError('HUB_UNSAFE_STORAGE', `Storage ancestor is a symlink: ${current}`);
|
|
27
|
+
}
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (!componentInfo.isDirectory()) {
|
|
31
|
+
throw new HubError('HUB_UNSAFE_STORAGE', `Storage ancestor is not a directory: ${current}`);
|
|
32
|
+
}
|
|
33
|
+
if (typeof process.getuid === 'function' && componentInfo.uid === process.getuid()) {
|
|
34
|
+
ownerAnchorReached = true;
|
|
35
|
+
}
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code !== 'ENOENT') throw error;
|
|
38
|
+
await mkdir(current, { mode: 0o700 });
|
|
39
|
+
ownerAnchorReached = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const info = await lstat(directory);
|
|
43
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
44
|
+
throw new HubError('HUB_UNSAFE_STORAGE', `Storage path is not a real directory: ${directory}`);
|
|
45
|
+
}
|
|
46
|
+
if (typeof process.getuid === 'function' && info.uid !== process.getuid()) {
|
|
47
|
+
throw new HubError('HUB_STORAGE_NOT_OWNED', `Storage path is not owned by the current user: ${directory}`);
|
|
48
|
+
}
|
|
49
|
+
await chmod(directory, 0o700);
|
|
50
|
+
return directory;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function assertOwnerOnlyFile(file) {
|
|
54
|
+
const info = await lstat(file);
|
|
55
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
56
|
+
throw new HubError('HUB_UNSAFE_STORAGE', `Storage path is not a regular file: ${file}`);
|
|
57
|
+
}
|
|
58
|
+
if (typeof process.getuid === 'function' && info.uid !== process.getuid()) {
|
|
59
|
+
throw new HubError('HUB_STORAGE_NOT_OWNED', `Storage file is not owned by the current user: ${file}`);
|
|
60
|
+
}
|
|
61
|
+
if ((info.mode & 0o077) !== 0) {
|
|
62
|
+
throw new HubError('HUB_STORAGE_PERMISSIONS', `Storage file must be owner-only: ${file}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function writeOwnerOnlyJson(file, value) {
|
|
67
|
+
return writeOwnerOnlyText(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function writeOwnerOnlyText(file, value) {
|
|
71
|
+
const directory = path.dirname(file);
|
|
72
|
+
await ensureOwnerOnlyDirectory(directory);
|
|
73
|
+
try {
|
|
74
|
+
const existing = await lstat(file);
|
|
75
|
+
if (existing.isSymbolicLink()) {
|
|
76
|
+
throw new HubError('HUB_UNSAFE_STORAGE', `Refusing to replace symlink: ${file}`);
|
|
77
|
+
}
|
|
78
|
+
if (!existing.isFile()) {
|
|
79
|
+
throw new HubError('HUB_UNSAFE_STORAGE', `Refusing to replace non-file storage: ${file}`);
|
|
80
|
+
}
|
|
81
|
+
if (typeof process.getuid === 'function' && existing.uid !== process.getuid()) {
|
|
82
|
+
throw new HubError('HUB_STORAGE_NOT_OWNED', `Storage file is not owned by the current user: ${file}`);
|
|
83
|
+
}
|
|
84
|
+
if ((existing.mode & 0o077) !== 0) {
|
|
85
|
+
throw new HubError('HUB_STORAGE_PERMISSIONS', `Storage file must already be owner-only: ${file}`);
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error.code !== 'ENOENT') throw error;
|
|
89
|
+
}
|
|
90
|
+
const temporary = path.join(
|
|
91
|
+
directory,
|
|
92
|
+
`.${path.basename(file)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
|
|
93
|
+
);
|
|
94
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
95
|
+
try {
|
|
96
|
+
await handle.writeFile(String(value), 'utf8');
|
|
97
|
+
await handle.sync();
|
|
98
|
+
} finally {
|
|
99
|
+
await handle.close();
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
await rename(temporary, file);
|
|
103
|
+
await chmod(file, 0o600);
|
|
104
|
+
try {
|
|
105
|
+
const directoryHandle = await open(directory, 'r');
|
|
106
|
+
try {
|
|
107
|
+
await directoryHandle.sync();
|
|
108
|
+
} finally {
|
|
109
|
+
await directoryHandle.close();
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Some filesystems do not support directory fsync; the atomic rename still applies.
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
await rm(temporary, { force: true });
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function readOwnerOnlyJson(file, fallback = null) {
|
|
121
|
+
try {
|
|
122
|
+
await assertOwnerOnlyFile(file);
|
|
123
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error.code === 'ENOENT') return fallback;
|
|
126
|
+
if (error instanceof SyntaxError) {
|
|
127
|
+
throw new HubError('HUB_INVALID_CONFIG', `Invalid JSON in ${file}`, { cause: error });
|
|
128
|
+
}
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { execFile as execFileCallback, spawn } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
export const SUBPROCESS_TIMEOUT_MS = 30_000;
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFileCallback);
|
|
7
|
+
const bounded = (ms, fallback) => (Number.isFinite(ms) && ms > 0 ? ms : fallback);
|
|
8
|
+
|
|
9
|
+
/** execFile that always ends: past its timeout (never unbounded, not even with timeout: 0) the child is SIGKILLed. */
|
|
10
|
+
export function execFileBounded(bin, args, { defaultTimeoutMs = SUBPROCESS_TIMEOUT_MS, ...options } = {}) {
|
|
11
|
+
return execFileAsync(bin, args, { ...options, timeout: bounded(options.timeout, defaultTimeoutMs), killSignal: 'SIGKILL' });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Runs bin with input on stdin and resolves { code, signal, stdout, stderr } when it exits. Past timeoutMs the child
|
|
16
|
+
* is SIGKILLed and the promise rejects with onTimeout(result), so a hung tool never holds its caller.
|
|
17
|
+
*/
|
|
18
|
+
export function spawnBounded(bin, args, {
|
|
19
|
+
input = '', env = process.env, timeoutMs = SUBPROCESS_TIMEOUT_MS, onTimeout = () => Object.assign(new Error(`${bin} timed out`), { code: 'ETIMEDOUT' }),
|
|
20
|
+
} = {}) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const child = spawn(bin, args, { env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
23
|
+
const stdout = [];
|
|
24
|
+
const stderr = [];
|
|
25
|
+
let timedOut = false;
|
|
26
|
+
const timer = setTimeout(() => {
|
|
27
|
+
timedOut = true;
|
|
28
|
+
child.kill('SIGKILL');
|
|
29
|
+
}, bounded(timeoutMs, SUBPROCESS_TIMEOUT_MS));
|
|
30
|
+
child.stdout.on('data', (chunk) => stdout.push(chunk));
|
|
31
|
+
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
|
32
|
+
child.on('error', (error) => {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
reject(error);
|
|
35
|
+
});
|
|
36
|
+
child.on('close', (code, signal) => {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
const result = { code, signal, stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8') };
|
|
39
|
+
if (timedOut) reject(Object.assign(onTimeout(result), { result }));
|
|
40
|
+
else resolve(result);
|
|
41
|
+
});
|
|
42
|
+
child.stdin.on('error', () => {});
|
|
43
|
+
child.stdin.end(input);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { rm } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { HubError } from './errors.js';
|
|
4
|
+
import { acquireOwnerLock } from './ownerLock.js';
|
|
5
|
+
import { ProcessIdentityProvider } from './processOwnership.js';
|
|
6
|
+
import { readOwnerOnlyJson, writeOwnerOnlyJson } from './storage.js';
|
|
7
|
+
|
|
8
|
+
const SECRET_REFERENCE = 'oauth-session';
|
|
9
|
+
const METADATA_FILE = 'oauth-session.json';
|
|
10
|
+
const LOCK_FILE = 'oauth-refresh.lock';
|
|
11
|
+
const inProcessLocks = new Map();
|
|
12
|
+
|
|
13
|
+
export class TokenStore {
|
|
14
|
+
constructor({ secretStore, configDir, lockTimeoutMs = 10_000, identityProvider = new ProcessIdentityProvider() }) {
|
|
15
|
+
this.secretStore = secretStore;
|
|
16
|
+
this.metadataFile = path.join(configDir, METADATA_FILE);
|
|
17
|
+
this.lockFile = path.join(configDir, LOCK_FILE);
|
|
18
|
+
this.lockTimeoutMs = lockTimeoutMs;
|
|
19
|
+
this.identityProvider = identityProvider;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async load() {
|
|
23
|
+
const metadata = await readOwnerOnlyJson(this.metadataFile);
|
|
24
|
+
if (!metadata) return null;
|
|
25
|
+
const secret = await this.secretStore.get(SECRET_REFERENCE);
|
|
26
|
+
if (!secret) return null;
|
|
27
|
+
let tokens;
|
|
28
|
+
try {
|
|
29
|
+
tokens = JSON.parse(secret);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new HubError('HUB_TOKEN_STORE_CORRUPT', 'Stored Pulse sign-in is unreadable; run `agentpulse pulse login`', {
|
|
32
|
+
cause: error,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
platformUrl: metadata.platformUrl,
|
|
37
|
+
userId: metadata.userId,
|
|
38
|
+
teamId: metadata.teamId,
|
|
39
|
+
accessToken: tokens.accessToken,
|
|
40
|
+
refreshToken: tokens.refreshToken ?? null,
|
|
41
|
+
expiresAt: metadata.expiresAt,
|
|
42
|
+
scopes: metadata.scopes || [],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async scopes() {
|
|
47
|
+
const metadata = await readOwnerOnlyJson(this.metadataFile);
|
|
48
|
+
return Array.isArray(metadata?.scopes) ? metadata.scopes : [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async save(session) {
|
|
52
|
+
if (typeof session?.accessToken !== 'string' || !session.userId || !session.teamId || !session.platformUrl) {
|
|
53
|
+
throw new HubError('HUB_TOKEN_STORE_INVALID', 'Refusing to store an incomplete Pulse sign-in');
|
|
54
|
+
}
|
|
55
|
+
await this.secretStore.set(SECRET_REFERENCE, JSON.stringify({
|
|
56
|
+
accessToken: session.accessToken,
|
|
57
|
+
refreshToken: session.refreshToken ?? null,
|
|
58
|
+
}));
|
|
59
|
+
await writeOwnerOnlyJson(this.metadataFile, {
|
|
60
|
+
platformUrl: session.platformUrl,
|
|
61
|
+
userId: session.userId,
|
|
62
|
+
teamId: session.teamId,
|
|
63
|
+
expiresAt: session.expiresAt,
|
|
64
|
+
scopes: session.scopes || [],
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async clear() {
|
|
69
|
+
await this.secretStore.delete(SECRET_REFERENCE);
|
|
70
|
+
await rm(this.metadataFile, { force: true });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async withRefreshLock(action) {
|
|
74
|
+
const previous = inProcessLocks.get(this.lockFile) || Promise.resolve();
|
|
75
|
+
let release;
|
|
76
|
+
const current = previous.then(() => new Promise((resolve) => { release = resolve; }));
|
|
77
|
+
inProcessLocks.set(this.lockFile, current);
|
|
78
|
+
await previous;
|
|
79
|
+
try {
|
|
80
|
+
const releaseFile = await acquireOwnerLock({
|
|
81
|
+
file: this.lockFile,
|
|
82
|
+
identity: await this.identityProvider.capture(process.pid),
|
|
83
|
+
identityProvider: this.identityProvider,
|
|
84
|
+
timeoutMs: this.lockTimeoutMs,
|
|
85
|
+
heldError: () => new HubError('HUB_REFRESH_LOCKED', 'Another Pulse process is refreshing the sign-in; retry shortly'),
|
|
86
|
+
});
|
|
87
|
+
try {
|
|
88
|
+
return await action();
|
|
89
|
+
} finally {
|
|
90
|
+
await releaseFile();
|
|
91
|
+
}
|
|
92
|
+
} finally {
|
|
93
|
+
release();
|
|
94
|
+
if (inProcessLocks.get(this.lockFile) === current) inProcessLocks.delete(this.lockFile);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|