@agentpulselabs/cli-plugin-pulse 0.2.0 → 1.1.2
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 +115 -32
- package/package.json +14 -19
- package/src/clients/hub-bridge.mjs +99 -0
- package/src/clients/hub.mjs +324 -0
- package/src/commands/pulse/cutover.js +39 -0
- package/src/commands/pulse/doctor.js +15 -0
- package/src/commands/pulse/hub-write.js +26 -0
- package/src/commands/pulse/import.js +62 -0
- package/src/commands/pulse/install.js +14 -0
- package/src/commands/pulse/log-write.js +26 -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/parity.js +20 -0
- package/src/commands/pulse/restart.js +14 -0
- package/src/commands/pulse/shadow.js +19 -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 +43 -0
- package/src/hub/core/auth.mjs +80 -0
- package/src/hub/core/changes.mjs +38 -0
- package/src/hub/core/conversations.mjs +121 -0
- package/src/hub/core/db.mjs +263 -0
- package/src/hub/core/edgeIds.mjs +9 -0
- package/src/hub/core/hub.mjs +20 -0
- package/src/hub/core/ids.mjs +39 -0
- package/src/hub/core/leases.mjs +55 -0
- package/src/hub/core/messaging.mjs +322 -0
- package/src/hub/core/registry.mjs +281 -0
- package/src/hub/core/ticks.mjs +58 -0
- package/src/hub/core/wake.mjs +125 -0
- package/src/hub/home.mjs +16 -0
- package/src/hub/hooks/hub-bridge.mjs +167 -0
- package/src/hub/keepawake/agent.mjs +96 -0
- package/src/hub/lib/brief.mjs +133 -0
- package/src/hub/lib/channels.mjs +268 -0
- package/src/hub/lib/chat.mjs +163 -0
- package/src/hub/lib/closure.mjs +92 -0
- package/src/hub/lib/collect-worker.mjs +20 -0
- package/src/hub/lib/collect.mjs +342 -0
- package/src/hub/lib/deliveries.mjs +222 -0
- package/src/hub/lib/git.mjs +93 -0
- package/src/hub/lib/me.mjs +123 -0
- package/src/hub/lib/personas.mjs +143 -0
- package/src/hub/lib/status.mjs +147 -0
- package/src/hub/lib/store.mjs +106 -0
- package/src/hub/lib/titles.mjs +29 -0
- package/src/hub/lib/transcript-feed.mjs +72 -0
- package/src/hub/lib/transcripts.mjs +364 -0
- package/src/hub/lib/writing.mjs +18 -0
- package/src/hub/projections/deliveries.mjs +31 -0
- package/src/hub/projections/graph.mjs +149 -0
- package/src/hub/runners/drivers.mjs +162 -0
- package/src/hub/workers/collector.mjs +128 -0
- package/src/hub/workers/manager.mjs +366 -0
- package/src/hub/workers/runner.mjs +145 -0
- package/src/lib/BaseCommand.js +23 -23
- package/src/lib/agentSessions.js +279 -0
- package/src/lib/application.js +704 -0
- package/src/lib/backoff.js +8 -0
- package/src/lib/budgets.js +85 -0
- package/src/lib/channelScanner.js +99 -0
- package/src/lib/cliDefaults.js +20 -0
- package/src/lib/clientInstaller.js +111 -0
- package/src/lib/cloudAuth.js +189 -0
- package/src/lib/cloudFirstCommands.js +293 -0
- package/src/lib/contract.sha256 +2 -0
- package/src/lib/cutover.js +376 -0
- package/src/lib/cutoverWiring.js +137 -0
- package/src/lib/daemonComposition.js +338 -0
- package/src/lib/daemonLock.js +57 -0
- package/src/lib/daemonLoop.js +72 -0
- package/src/lib/daemonProcess.js +380 -0
- package/src/lib/daemonRuntime.js +828 -0
- package/src/lib/doctorWarnings.js +53 -0
- package/src/lib/drivers.js +280 -0
- package/src/lib/errors.js +33 -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/hubDbReader.js +132 -0
- package/src/lib/hubMapping.js +108 -0
- package/src/lib/hubScan.js +611 -0
- package/src/lib/hubSnapshot.js +64 -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 +365 -0
- package/src/lib/mirrorSource.js +430 -0
- package/src/lib/mirrorSync.js +353 -0
- package/src/lib/nodeFloor.js +19 -0
- package/src/lib/oauthLogin.js +132 -0
- package/src/lib/outboxFlusher.js +75 -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/reconcile.js +316 -0
- package/src/lib/reconcileView.js +129 -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/runnerStore.js +418 -0
- package/src/lib/secretStore.js +169 -0
- package/src/lib/selfRestart.js +261 -0
- package/src/lib/serviceManager.js +419 -0
- package/src/lib/shadowService.js +435 -0
- package/src/lib/sqlite.js +28 -0
- package/src/lib/storage.js +131 -0
- package/src/lib/subprocess.js +45 -0
- package/src/lib/tokenStore.js +136 -0
- package/src/lib/transport.js +334 -0
- package/src/lib/transportContract.js +40 -0
- package/src/lib/uploadRedactor.js +102 -0
- package/src/runner/applier.js +297 -0
- package/src/runner/bridge.js +289 -0
- package/src/runner/busIdentity.js +499 -0
- package/src/runner/capture.js +103 -0
- package/src/runner/captureMapping.js +21 -0
- package/src/runner/channelDiscipline.js +425 -0
- package/src/runner/deliveryStates.js +220 -0
- package/src/runner/facade.js +876 -0
- package/src/runner/hubCapture.js +160 -0
- package/src/runner/lease.js +46 -0
- package/src/runner/main.js +35 -0
- package/src/runner/render.js +91 -0
- package/src/runner/runnerChild.js +57 -0
- package/src/runner/runnerHub.js +104 -0
- package/src/runner/runnerProcess.js +124 -0
- package/src/runner/runnerSchema.js +51 -0
- package/src/runner/runnerService.js +140 -0
- package/src/runner/spool.js +86 -0
- package/src/runner/spoolInto.js +22 -0
- package/src/runner/statusPage.js +30 -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,8 @@
|
|
|
1
|
+
export function fullJitterDelay(
|
|
2
|
+
attempt,
|
|
3
|
+
{ baseMs = 1_000, capMs = 60_000, random = Math.random } = {}
|
|
4
|
+
) {
|
|
5
|
+
const exponent = Math.max(0, Math.min(30, Number(attempt) || 0));
|
|
6
|
+
const ceiling = Math.min(capMs, baseMs * 2 ** exponent);
|
|
7
|
+
return Math.floor(Math.max(0, Math.min(1, random())) * ceiling);
|
|
8
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { HubError } from './errors.js';
|
|
3
|
+
|
|
4
|
+
function assertLimit(value, limit, code, label) {
|
|
5
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
6
|
+
throw new HubError(code, `${label} usage must be a finite non-negative number`);
|
|
7
|
+
}
|
|
8
|
+
if (value > limit) {
|
|
9
|
+
throw new HubError(code, `${label} budget exceeded (${value} > ${limit})`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class BudgetGuard {
|
|
14
|
+
#active = new Map();
|
|
15
|
+
|
|
16
|
+
constructor({
|
|
17
|
+
maxTokens,
|
|
18
|
+
maxCostUsd,
|
|
19
|
+
maxWallMs,
|
|
20
|
+
maxOutputBytes,
|
|
21
|
+
maxConcurrency,
|
|
22
|
+
now = Date.now,
|
|
23
|
+
}) {
|
|
24
|
+
for (const [name, value] of Object.entries({
|
|
25
|
+
maxTokens,
|
|
26
|
+
maxCostUsd,
|
|
27
|
+
maxWallMs,
|
|
28
|
+
maxOutputBytes,
|
|
29
|
+
maxConcurrency,
|
|
30
|
+
})) {
|
|
31
|
+
if (!Number.isFinite(value) || value <= 0 || (name === 'maxConcurrency' && !Number.isInteger(value))) {
|
|
32
|
+
throw new HubError('HUB_BUDGET_INVALID', `${name} must be an explicit positive finite limit`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
this.limits = { maxTokens, maxCostUsd, maxWallMs, maxOutputBytes, maxConcurrency };
|
|
36
|
+
this.now = now;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
acquire() {
|
|
40
|
+
assertLimit(
|
|
41
|
+
this.#active.size + 1,
|
|
42
|
+
this.limits.maxConcurrency,
|
|
43
|
+
'HUB_BUDGET_CONCURRENCY',
|
|
44
|
+
'Concurrency'
|
|
45
|
+
);
|
|
46
|
+
const startedAt = this.now();
|
|
47
|
+
if (!Number.isFinite(startedAt) || startedAt < 0) {
|
|
48
|
+
throw new HubError('HUB_BUDGET_TIME', 'Monotonic clock returned an invalid value');
|
|
49
|
+
}
|
|
50
|
+
const lease = { id: randomUUID(), startedAt };
|
|
51
|
+
this.#active.set(lease.id, { ...lease, tokens: 0, costUsd: 0, outputBytes: 0 });
|
|
52
|
+
return { ...lease };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
assertWithin({ id, tokens, costUsd, outputBytes }) {
|
|
56
|
+
const lease = this.#active.get(id);
|
|
57
|
+
if (!lease) throw new HubError('HUB_BUDGET_LEASE_INVALID', 'Budget lease is not active');
|
|
58
|
+
assertLimit(tokens, this.limits.maxTokens, 'HUB_BUDGET_TOKENS', 'Token');
|
|
59
|
+
assertLimit(costUsd, this.limits.maxCostUsd, 'HUB_BUDGET_COST', 'Cost');
|
|
60
|
+
assertLimit(outputBytes, this.limits.maxOutputBytes, 'HUB_BUDGET_OUTPUT', 'Output');
|
|
61
|
+
assertLimit(this.now() - lease.startedAt, this.limits.maxWallMs, 'HUB_BUDGET_TIME', 'Wall time');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
consume(lease, delta) {
|
|
65
|
+
const current = this.#active.get(lease.id);
|
|
66
|
+
if (!current) throw new HubError('HUB_BUDGET_LEASE_INVALID', 'Budget lease is not active');
|
|
67
|
+
const next = {
|
|
68
|
+
id: current.id,
|
|
69
|
+
tokens: current.tokens + delta.tokens,
|
|
70
|
+
costUsd: current.costUsd + delta.costUsd,
|
|
71
|
+
outputBytes: current.outputBytes + delta.outputBytes,
|
|
72
|
+
};
|
|
73
|
+
this.assertWithin(next);
|
|
74
|
+
Object.assign(current, next);
|
|
75
|
+
return { ...current };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
release(lease) {
|
|
79
|
+
this.#active.delete(lease.id);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get active() {
|
|
83
|
+
return this.#active.size;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Ported from agents-hub lib/channels.mjs at 37a0b2f: the post header, role aliases, the header type and the walk that
|
|
5
|
+
// dates headers (which carry a time but no date) backwards from the file's modified time.
|
|
6
|
+
export const HEADER = /^## \[([A-Z][A-Z0-9 -]*?)\] (\d{1,2}):(\d{2})(?::(\d{2}))?(?: (IST|UTC|GMT))? ([^\n]*)$/;
|
|
7
|
+
const ROLE_ALIASES = { 'PLATFORM-AUDITOR': 'PLATFORM AUDITOR', AUDITOR: 'PLATFORM AUDITOR' };
|
|
8
|
+
export const normRole = (role) => {
|
|
9
|
+
const upper = String(role || '').toUpperCase().trim();
|
|
10
|
+
return ROLE_ALIASES[upper] || upper;
|
|
11
|
+
};
|
|
12
|
+
const DAY_S = 86400;
|
|
13
|
+
const DAY_JUMP_S = 2 * 3600;
|
|
14
|
+
const NAME = /^[A-Za-z0-9._-]+$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Every task channel the local hub shows: <root>/<repo>/.agents/<task>/channel.md, roots in the hub's order. A
|
|
18
|
+
* `<repo>/<task>` present under two roots is the first root's (as the hub resolves it); the others are returned in
|
|
19
|
+
* `collisions`. Names outside the channel key charset are returned in `unnamed`.
|
|
20
|
+
*/
|
|
21
|
+
export async function channelFiles(roots) {
|
|
22
|
+
const files = [];
|
|
23
|
+
const collisions = [];
|
|
24
|
+
const unnamed = [];
|
|
25
|
+
const seen = new Map();
|
|
26
|
+
for (const root of roots) {
|
|
27
|
+
let repos = [];
|
|
28
|
+
try {
|
|
29
|
+
repos = (await readdir(root)).sort();
|
|
30
|
+
} catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
for (const repo of repos) {
|
|
34
|
+
const dir = path.join(root, repo, '.agents');
|
|
35
|
+
let tasks = [];
|
|
36
|
+
try {
|
|
37
|
+
tasks = (await readdir(dir)).sort();
|
|
38
|
+
} catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
for (const task of tasks) {
|
|
42
|
+
const file = path.join(dir, task, 'channel.md');
|
|
43
|
+
let info;
|
|
44
|
+
try {
|
|
45
|
+
info = await stat(file);
|
|
46
|
+
} catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!info.isFile()) continue;
|
|
50
|
+
const key = `${repo}/${task}`;
|
|
51
|
+
if (!NAME.test(repo) || !NAME.test(task)) {
|
|
52
|
+
unnamed.push({ key, file });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (seen.has(key)) {
|
|
56
|
+
collisions.push({ key, file, kept: seen.get(key) });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
seen.set(key, file);
|
|
60
|
+
files.push({ key, repo, task, file, mtimeMs: info.mtimeMs, size: info.size });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { files, collisions, unnamed };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const readChannel = (file) => readFile(file, 'utf8');
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The posts of a channel file as whole blocks: each header line and every line up to the next header, with no line or
|
|
71
|
+
* character cap (the hub's parsePosts caps a body; an import must not). Text before the first header is not a post.
|
|
72
|
+
* `at` is each header's time on the date the backwards walk gives it.
|
|
73
|
+
*/
|
|
74
|
+
export function splitPosts(text, mtimeMs, utcOffsetS = -new Date(mtimeMs).getTimezoneOffset() * 60) {
|
|
75
|
+
const lines = String(text).replace(/\r/g, '').split('\n');
|
|
76
|
+
const raw = [];
|
|
77
|
+
let current = null;
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
const match = line.match(HEADER);
|
|
80
|
+
if (match) {
|
|
81
|
+
const head = match[6].trim();
|
|
82
|
+
let sec = Number(match[2]) * 3600 + Number(match[3]) * 60 + Number(match[4] || 0) + (match[5] === 'UTC' || match[5] === 'GMT' ? utcOffsetS : 0);
|
|
83
|
+
sec -= Math.floor(sec / DAY_S) * DAY_S;
|
|
84
|
+
current = { role: normRole(match[1]), head, type: head.split(/ [—(-] | — /)[0].trim().slice(0, 40), sec, lines: [line] };
|
|
85
|
+
raw.push(current);
|
|
86
|
+
} else if (current) {
|
|
87
|
+
current.lines.push(line);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const mt = new Date(mtimeMs);
|
|
91
|
+
const day = new Date(mt.getFullYear(), mt.getMonth(), mt.getDate());
|
|
92
|
+
let later = mt.getHours() * 3600 + mt.getMinutes() * 60 + mt.getSeconds() + 600;
|
|
93
|
+
for (let k = raw.length - 1; k >= 0; k -= 1) {
|
|
94
|
+
if (raw[k].sec > later + DAY_JUMP_S) day.setDate(day.getDate() - 1);
|
|
95
|
+
later = raw[k].sec;
|
|
96
|
+
raw[k].at = day.getTime() + raw[k].sec * 1000;
|
|
97
|
+
}
|
|
98
|
+
return raw.map(({ role, head, type, at, lines: block }) => ({ role, head, type, at, text: block.join('\n').trimEnd() }));
|
|
99
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export async function readCliDefaults({ env = process.env, home = os.homedir() } = {}) {
|
|
6
|
+
const file = path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'agentpulse', 'config.json');
|
|
7
|
+
let config;
|
|
8
|
+
try {
|
|
9
|
+
config = JSON.parse(await readFile(file, 'utf8'));
|
|
10
|
+
} catch {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
const aliases = Object.keys(config?.profiles || {});
|
|
14
|
+
const alias = config?.profiles?.[config.defaultProfile] ? config.defaultProfile : aliases.length === 1 ? aliases[0] : null;
|
|
15
|
+
const profile = alias ? config.profiles[alias] : null;
|
|
16
|
+
return {
|
|
17
|
+
platformUrl: profile?.baseUrl || profile?.url || null,
|
|
18
|
+
devOrgSlug: profile?.devOrgSlug || null,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmod, copyFile, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { HubError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
const CLIENTS = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'clients');
|
|
8
|
+
const MARKER = '/*PULSE_CLIENT_CONFIG*/ null';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The agents-hub entry points every agent and hook already calls, with their git blob at the frozen 6b2fb0b. Cutover
|
|
12
|
+
* replaces each with the Pulse thin client, keeping the original beside it; rollback puts the original back.
|
|
13
|
+
*/
|
|
14
|
+
export const ENTRY_POINTS = Object.freeze([
|
|
15
|
+
{ name: 'hub.mjs', client: 'hub.mjs', relative: 'hub.mjs', blob: 'f83f19a2bb6ed6236c48ed219e33a54998358731' },
|
|
16
|
+
{ name: 'hook', client: 'hub-bridge.mjs', relative: path.join('hooks', 'hub-bridge.mjs'), blob: '3caacb1771e5efa0f354f9a0757c3c6f3561acae' },
|
|
17
|
+
]);
|
|
18
|
+
export const ORIGINAL_SUFFIX = '.pre-cutover';
|
|
19
|
+
|
|
20
|
+
export const gitBlobSha = (bytes) => createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
|
|
21
|
+
|
|
22
|
+
/** A thin client with its configuration written in: `{ port, tokenFile, spoolDir, cliPath, offline }`. */
|
|
23
|
+
export async function renderClient(client, config) {
|
|
24
|
+
const source = await readFile(path.join(CLIENTS, client), 'utf8');
|
|
25
|
+
if (source.split(MARKER).length !== 2) throw new HubError('HUB_CLIENT_TEMPLATE', `${client} has no configuration marker`);
|
|
26
|
+
return source.replace(MARKER, () => JSON.stringify(config));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function clientConfig({ hubHome, port = 8890, offline = false }) {
|
|
30
|
+
return {
|
|
31
|
+
port,
|
|
32
|
+
tokenFile: path.join(hubHome, 'state', 'hub.token'),
|
|
33
|
+
spoolDir: path.join(hubHome, 'state', 'spool'),
|
|
34
|
+
cliPath: path.join(hubHome, 'hub.mjs'),
|
|
35
|
+
offline,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function atomicWrite(file, text, mode) {
|
|
40
|
+
const temp = `${file}.pulse-${process.pid}.tmp`;
|
|
41
|
+
await writeFile(temp, text, { mode });
|
|
42
|
+
await chmod(temp, mode);
|
|
43
|
+
await rename(temp, file);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readOrNull(file) {
|
|
47
|
+
try {
|
|
48
|
+
return await readFile(file);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error.code === 'ENOENT') return null;
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Install the thin clients over the entry points, online or offline. The first install keeps each original as
|
|
57
|
+
* `<file>.pre-cutover` and refuses unless it is exactly the 6b2fb0b blob; a later call only switches the client.
|
|
58
|
+
*/
|
|
59
|
+
export async function installClients({ hubHome, port = 8890, offline = false }) {
|
|
60
|
+
const config = clientConfig({ hubHome, port, offline });
|
|
61
|
+
const installed = [];
|
|
62
|
+
for (const entry of ENTRY_POINTS) {
|
|
63
|
+
const file = path.join(hubHome, entry.relative);
|
|
64
|
+
const original = `${file}${ORIGINAL_SUFFIX}`;
|
|
65
|
+
if (!(await readOrNull(original))) {
|
|
66
|
+
const current = await readOrNull(file);
|
|
67
|
+
if (!current) throw new HubError('HUB_ENTRY_POINT_MISSING', `${file} is missing`);
|
|
68
|
+
if (gitBlobSha(current) !== entry.blob) {
|
|
69
|
+
throw new HubError('HUB_ENTRY_POINT_CHANGED', `${file} is not agents-hub 6b2fb0b; cutover will not replace it`, { details: { file, blob: gitBlobSha(current) } });
|
|
70
|
+
}
|
|
71
|
+
await copyFile(file, original);
|
|
72
|
+
await chmod(original, 0o444);
|
|
73
|
+
}
|
|
74
|
+
await atomicWrite(file, await renderClient(entry.client, config), 0o755);
|
|
75
|
+
installed.push({ name: entry.name, file, offline });
|
|
76
|
+
}
|
|
77
|
+
return installed;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Put the 6b2fb0b entry points back, byte for byte, and drop the kept copies. */
|
|
81
|
+
export async function restoreClients({ hubHome }) {
|
|
82
|
+
const restored = [];
|
|
83
|
+
for (const entry of ENTRY_POINTS) {
|
|
84
|
+
const file = path.join(hubHome, entry.relative);
|
|
85
|
+
const original = `${file}${ORIGINAL_SUFFIX}`;
|
|
86
|
+
const kept = await readOrNull(original);
|
|
87
|
+
if (!kept) {
|
|
88
|
+
const current = await readOrNull(file);
|
|
89
|
+
if (current && gitBlobSha(current) === entry.blob) { restored.push({ name: entry.name, file, already: true }); continue; }
|
|
90
|
+
throw new HubError('HUB_ENTRY_POINT_LOST', `${original} is missing, and ${file} is not the original`);
|
|
91
|
+
}
|
|
92
|
+
if (gitBlobSha(kept) !== entry.blob) throw new HubError('HUB_ENTRY_POINT_CHANGED', `${original} is not agents-hub 6b2fb0b`);
|
|
93
|
+
await chmod(original, 0o644);
|
|
94
|
+
await rename(original, file);
|
|
95
|
+
restored.push({ name: entry.name, file });
|
|
96
|
+
}
|
|
97
|
+
return restored;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Which entry points are the thin client now, and whether it is offline. */
|
|
101
|
+
export async function clientState({ hubHome }) {
|
|
102
|
+
const out = {};
|
|
103
|
+
for (const entry of ENTRY_POINTS) {
|
|
104
|
+
const current = await readOrNull(path.join(hubHome, entry.relative));
|
|
105
|
+
const text = current ? current.toString('utf8') : '';
|
|
106
|
+
out[entry.name] = !current ? 'missing'
|
|
107
|
+
: gitBlobSha(current) === entry.blob ? 'original'
|
|
108
|
+
: /"offline":true/.test(text) ? 'client-offline' : /"offline":false/.test(text) ? 'client-online' : 'changed';
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { HubError } from './errors.js';
|
|
2
|
+
import { cleanOrigin, decodeJwtClaims, fetchJson } from './http.js';
|
|
3
|
+
import { DEV_ORG_INGRESS_HEADER, OAUTH_CLIENT_ID } from './transportContract.js';
|
|
4
|
+
|
|
5
|
+
const SKEW_MS = 60_000;
|
|
6
|
+
const TOKEN_TIMEOUT_MS = 15_000;
|
|
7
|
+
|
|
8
|
+
export function loginRequired(reason) {
|
|
9
|
+
return new HubError(
|
|
10
|
+
'HUB_LOGIN_REQUIRED',
|
|
11
|
+
`Pulse connector sign-in is missing or expired (${reason}). Run \`agentpulse pulse login\`, then \`agentpulse pulse start\`.`,
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function refreshUnavailable(reason, cause) {
|
|
16
|
+
return new HubError(
|
|
17
|
+
'HUB_REFRESH_UNAVAILABLE',
|
|
18
|
+
`Could not refresh the Pulse sign-in (${reason}); the connector will retry and claims no work until it succeeds`,
|
|
19
|
+
cause ? { cause } : {},
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function expiresSoon(expiresAt, now) {
|
|
24
|
+
const expiry = Date.parse(expiresAt || '');
|
|
25
|
+
return !Number.isFinite(expiry) || expiry - now() <= SKEW_MS;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class CloudAuth {
|
|
29
|
+
constructor({ tokenStore, target, fetch = globalThis.fetch, now = Date.now }) {
|
|
30
|
+
this.tokenStore = tokenStore;
|
|
31
|
+
this.target = {
|
|
32
|
+
...target,
|
|
33
|
+
platformUrl: cleanOrigin(target.platformUrl, 'Platform URL'),
|
|
34
|
+
instanceUrl: target.instanceUrl ? cleanOrigin(target.instanceUrl, 'Instance URL') : null,
|
|
35
|
+
};
|
|
36
|
+
this.fetch = fetch;
|
|
37
|
+
this.now = now;
|
|
38
|
+
this.devOrgSession = null;
|
|
39
|
+
this.platformSession = null;
|
|
40
|
+
this.forceRefresh = false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get instanceUrl() {
|
|
44
|
+
return this.target.devOrg ? this.target.instanceUrl : this.target.platformUrl;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
invalidate() {
|
|
48
|
+
this.devOrgSession = null;
|
|
49
|
+
this.platformSession = null;
|
|
50
|
+
if (!this.target.devOrg) this.forceRefresh = true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async #refresh(session) {
|
|
54
|
+
if (!session.refreshToken) throw loginRequired('access token expired and no refresh token is stored');
|
|
55
|
+
let refreshed;
|
|
56
|
+
try {
|
|
57
|
+
refreshed = await fetchJson({
|
|
58
|
+
fetch: this.fetch,
|
|
59
|
+
url: `${this.target.platformUrl}/api/oauth/token`,
|
|
60
|
+
body: { grant_type: 'refresh_token', client_id: OAUTH_CLIENT_ID, refresh_token: session.refreshToken },
|
|
61
|
+
timeoutMs: TOKEN_TIMEOUT_MS,
|
|
62
|
+
});
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw refreshUnavailable('the platform did not answer', error);
|
|
65
|
+
}
|
|
66
|
+
if (refreshed.status >= 500 || refreshed.status === 429) {
|
|
67
|
+
throw refreshUnavailable(`the platform answered HTTP ${refreshed.status}`);
|
|
68
|
+
}
|
|
69
|
+
if (refreshed.body?.error === 'invalid_grant') {
|
|
70
|
+
throw loginRequired('the platform rejected or revoked the refresh token (invalid_grant)');
|
|
71
|
+
}
|
|
72
|
+
const claims = decodeJwtClaims(refreshed.body?.access_token);
|
|
73
|
+
if (
|
|
74
|
+
!refreshed.ok ||
|
|
75
|
+
claims?.type !== 'user_delegated' ||
|
|
76
|
+
claims.sub !== session.userId ||
|
|
77
|
+
claims.team_id !== session.teamId
|
|
78
|
+
) {
|
|
79
|
+
throw loginRequired('token refresh did not return this user\'s delegated token');
|
|
80
|
+
}
|
|
81
|
+
const expiresIn = Number(refreshed.body.expires_in);
|
|
82
|
+
const next = {
|
|
83
|
+
...session,
|
|
84
|
+
accessToken: refreshed.body.access_token,
|
|
85
|
+
refreshToken: typeof refreshed.body.refresh_token === 'string' ? refreshed.body.refresh_token : session.refreshToken,
|
|
86
|
+
expiresAt: new Date(this.now() + (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600) * 1000).toISOString(),
|
|
87
|
+
};
|
|
88
|
+
await this.tokenStore.save(next);
|
|
89
|
+
return next;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async #storedSession() {
|
|
93
|
+
const session = await this.tokenStore.load();
|
|
94
|
+
if (!session?.accessToken) throw loginRequired('no stored sign-in');
|
|
95
|
+
if (
|
|
96
|
+
session.platformUrl !== this.target.platformUrl ||
|
|
97
|
+
session.userId !== this.target.userId ||
|
|
98
|
+
session.teamId !== this.target.teamId
|
|
99
|
+
) {
|
|
100
|
+
throw loginRequired('the stored sign-in belongs to a different platform, team or user');
|
|
101
|
+
}
|
|
102
|
+
return session;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The stored session is in the OS secret store (a Keychain read is 0.5-2 s), so it is read once and kept until 60 s
|
|
106
|
+
// before expiry or until invalidate().
|
|
107
|
+
async #platformSession() {
|
|
108
|
+
if (!this.forceRefresh && this.platformSession && !expiresSoon(this.platformSession.expiresAt, this.now)) {
|
|
109
|
+
return this.platformSession;
|
|
110
|
+
}
|
|
111
|
+
this.platformSession = await this.#loadPlatformSession();
|
|
112
|
+
return this.platformSession;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async #loadPlatformSession() {
|
|
116
|
+
const session = await this.#storedSession();
|
|
117
|
+
if (!this.forceRefresh && !expiresSoon(session.expiresAt, this.now)) return session;
|
|
118
|
+
const rejectedAccessToken = this.forceRefresh ? session.accessToken : null;
|
|
119
|
+
const current = await this.tokenStore.withRefreshLock(async () => {
|
|
120
|
+
const latest = await this.#storedSession();
|
|
121
|
+
const refreshedElsewhere = rejectedAccessToken && latest.accessToken !== rejectedAccessToken;
|
|
122
|
+
if (refreshedElsewhere || (!rejectedAccessToken && !expiresSoon(latest.expiresAt, this.now))) return latest;
|
|
123
|
+
return this.#refresh(latest);
|
|
124
|
+
});
|
|
125
|
+
this.forceRefresh = false;
|
|
126
|
+
return current;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async #mintDevOrgSession(platformSession) {
|
|
130
|
+
let minted;
|
|
131
|
+
try {
|
|
132
|
+
minted = await fetchJson({
|
|
133
|
+
fetch: this.fetch,
|
|
134
|
+
url: `${this.target.platformUrl}/api/team/dev-orgs/${encodeURIComponent(this.target.devOrg.id)}/cli-token`,
|
|
135
|
+
headers: { authorization: `Bearer ${platformSession.accessToken}` },
|
|
136
|
+
body: {},
|
|
137
|
+
timeoutMs: TOKEN_TIMEOUT_MS,
|
|
138
|
+
});
|
|
139
|
+
} catch (error) {
|
|
140
|
+
throw new HubError('HUB_TARGET_UNAVAILABLE', 'Could not reach the platform to connect to the dev-org', { cause: error });
|
|
141
|
+
}
|
|
142
|
+
if ([401, 403].includes(minted.status) || minted.body?.code === 'NO_USER_IDENTITY') {
|
|
143
|
+
throw loginRequired(`the platform refused the dev-org connection (HTTP ${minted.status})`);
|
|
144
|
+
}
|
|
145
|
+
if (!minted.ok) {
|
|
146
|
+
throw new HubError(
|
|
147
|
+
'HUB_TARGET_UNAVAILABLE',
|
|
148
|
+
`Dev-org ${this.target.devOrg.slug} is not reachable (HTTP ${minted.status}${minted.body?.code ? ` ${minted.body.code}` : ''})`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const claims = decodeJwtClaims(minted.body.token);
|
|
152
|
+
if (claims?.userId !== this.target.userId || claims?.teamId !== this.target.teamId) {
|
|
153
|
+
throw loginRequired('the dev-org token was minted for a different user');
|
|
154
|
+
}
|
|
155
|
+
const instanceUrl = cleanOrigin(minted.body.instanceUrl, 'Dev-org instance URL');
|
|
156
|
+
if (this.target.instanceUrl && instanceUrl !== this.target.instanceUrl) {
|
|
157
|
+
throw new HubError(
|
|
158
|
+
'HUB_TARGET_CHANGED',
|
|
159
|
+
`Dev-org ${this.target.devOrg.slug} moved to a new address; pair this machine again`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
this.target.instanceUrl = instanceUrl;
|
|
163
|
+
return {
|
|
164
|
+
token: minted.body.token,
|
|
165
|
+
identityToken: typeof minted.body.identityToken === 'string' ? minted.body.identityToken : null,
|
|
166
|
+
expiresAt: minted.body.expiresAt,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The credentials held in memory now, so the mirror can redact them by identity; it never reads the secret store. */
|
|
171
|
+
heldSecrets() {
|
|
172
|
+
return [this.platformSession?.accessToken, this.platformSession?.refreshToken, this.devOrgSession?.token, this.devOrgSession?.identityToken]
|
|
173
|
+
.filter((value) => typeof value === 'string' && value.length >= 16);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async credentials() {
|
|
177
|
+
const platformSession = await this.#platformSession();
|
|
178
|
+
if (!this.target.devOrg) return { authorization: `Bearer ${platformSession.accessToken}` };
|
|
179
|
+
if (!this.devOrgSession || expiresSoon(this.devOrgSession.expiresAt, this.now)) {
|
|
180
|
+
this.devOrgSession = await this.#mintDevOrgSession(platformSession);
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
authorization: `Bearer ${this.devOrgSession.token}`,
|
|
184
|
+
...(this.devOrgSession.identityToken
|
|
185
|
+
? { [DEV_ORG_INGRESS_HEADER]: `Bearer ${this.devOrgSession.identityToken}` }
|
|
186
|
+
: {}),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|