@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,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,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,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
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { AgentSessionStore } from './agentSessions.js';
|
|
3
|
+
import { CloudAuth } from './cloudAuth.js';
|
|
4
|
+
import { defaultSecretStore } from './secretStore.js';
|
|
5
|
+
import { HubError } from './errors.js';
|
|
6
|
+
import { createHubActionHandlers } from './hubActions.js';
|
|
7
|
+
import { discoverLocalHub } from './localHub.js';
|
|
8
|
+
import { LocalHubWriter } from './localHubWriter.js';
|
|
9
|
+
import { assertPairedConfig } from './pairedConfig.js';
|
|
10
|
+
import { canonicalJson, validateDeliveryPlan } from './planContract.js';
|
|
11
|
+
import { TokenStore } from './tokenStore.js';
|
|
12
|
+
import { ConnectorTransport } from './transport.js';
|
|
13
|
+
import { validateRootGrant, WorktreeRegistry, createOwnedWorktree } from './rootPolicy.js';
|
|
14
|
+
import { execFileBounded as execFile } from './subprocess.js';
|
|
15
|
+
|
|
16
|
+
export async function createProductionDependencies({
|
|
17
|
+
config,
|
|
18
|
+
configDir,
|
|
19
|
+
secretStore = defaultSecretStore(),
|
|
20
|
+
fetch = globalThis.fetch,
|
|
21
|
+
log = () => {},
|
|
22
|
+
onTransportAttempt = () => {},
|
|
23
|
+
}) {
|
|
24
|
+
assertPairedConfig(config);
|
|
25
|
+
const tokenStore = new TokenStore({ secretStore, configDir });
|
|
26
|
+
const sessionId = randomUUID();
|
|
27
|
+
const worktrees = await WorktreeRegistry.open(configDir);
|
|
28
|
+
const sessions = await AgentSessionStore.open(configDir);
|
|
29
|
+
const roots = new Map((config.localRoots || []).map((root) => [root.rootId, root]));
|
|
30
|
+
let transport;
|
|
31
|
+
let transportKey;
|
|
32
|
+
|
|
33
|
+
const transportFactory = async (currentConfig) => {
|
|
34
|
+
const paired = assertPairedConfig(currentConfig);
|
|
35
|
+
const key = [paired.machineId, paired.policyVersion, paired.baseUrl, paired.userId].join('\0');
|
|
36
|
+
if (transport && transportKey === key) return transport;
|
|
37
|
+
const auth = new CloudAuth({
|
|
38
|
+
tokenStore,
|
|
39
|
+
fetch,
|
|
40
|
+
target: {
|
|
41
|
+
platformUrl: paired.target.platformUrl,
|
|
42
|
+
devOrg: paired.target.devOrg,
|
|
43
|
+
instanceUrl: paired.baseUrl,
|
|
44
|
+
userId: paired.userId,
|
|
45
|
+
teamId: paired.teamId,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
transport = new ConnectorTransport({
|
|
49
|
+
baseUrl: paired.baseUrl,
|
|
50
|
+
machine: { machineId: paired.machineId, sessionId, policyVersion: paired.policyVersion },
|
|
51
|
+
auth,
|
|
52
|
+
fetch,
|
|
53
|
+
onAttempt: onTransportAttempt,
|
|
54
|
+
});
|
|
55
|
+
transportKey = key;
|
|
56
|
+
return transport;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const hubActions = createHubActionHandlers({
|
|
60
|
+
writerFor: async () => {
|
|
61
|
+
let hub;
|
|
62
|
+
try {
|
|
63
|
+
hub = await discoverLocalHub({ config });
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw new HubError('hub_unreachable', error.message);
|
|
66
|
+
}
|
|
67
|
+
return new LocalHubWriter({ ...hub, fetch, log });
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const handlersFactory = ({ drivers, policy }) => {
|
|
72
|
+
const plannerChoices = Object.entries(policy.providers).flatMap(([provider, models]) =>
|
|
73
|
+
models.map((model) => ({ provider, model }))
|
|
74
|
+
);
|
|
75
|
+
const defaultProvider = plannerChoices.length === 1 ? plannerChoices[0].provider : null;
|
|
76
|
+
const defaultModel = plannerChoices.length === 1 ? plannerChoices[0].model : null;
|
|
77
|
+
const budget = (requested = {}) => ({
|
|
78
|
+
maxTokens: requested.maxTokens || policy.limits.maxTokensPerRun,
|
|
79
|
+
maxCostUsd: requested.maxCostUsd ?? policy.limits.maxCostUsd,
|
|
80
|
+
maxWallMs: requested.maxWallMs || policy.limits.maxWallMs,
|
|
81
|
+
maxOutputBytes: requested.maxOutputBytes || policy.limits.maxOutputBytes,
|
|
82
|
+
});
|
|
83
|
+
const workspaceFor = (rootId, mode, cloudJob = {}, expectedWorktreeId = null) => {
|
|
84
|
+
const root = roots.get(rootId);
|
|
85
|
+
if (!root) throw new HubError('HUB_CAPABILITY_DENIED', 'Root is not locally approved');
|
|
86
|
+
if (mode === 'read') return root.canonicalRoot;
|
|
87
|
+
const candidates = worktrees
|
|
88
|
+
.list(rootId)
|
|
89
|
+
.filter(
|
|
90
|
+
(record) =>
|
|
91
|
+
record.deliveryId === cloudJob.delivery_id &&
|
|
92
|
+
record.streamId === cloudJob.stream_id
|
|
93
|
+
);
|
|
94
|
+
if (candidates.length !== 1) {
|
|
95
|
+
throw new HubError(
|
|
96
|
+
'HUB_WRITE_REQUIRES_WORKTREE',
|
|
97
|
+
'Write-mode agent requires exactly one connector-owned worktree'
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (expectedWorktreeId && candidates[0].worktreeId !== expectedWorktreeId) {
|
|
101
|
+
throw new HubError(
|
|
102
|
+
'HUB_AGENT_SESSION_BINDING_MISMATCH',
|
|
103
|
+
'Continuation worktree does not match its durable local agent binding'
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return candidates[0].canonicalPath;
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
'delivery.plan': async (job, context) => {
|
|
110
|
+
if (!defaultProvider || !defaultModel) {
|
|
111
|
+
throw new HubError(
|
|
112
|
+
'HUB_PROVIDER_DENIED',
|
|
113
|
+
'Planner requires exactly one signed provider/model assignment'
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const rootId = job.arguments.rootIds[0];
|
|
117
|
+
const result = await drivers.driver(defaultProvider, defaultModel).start({
|
|
118
|
+
workspace: workspaceFor(rootId, 'read', context.cloudJob),
|
|
119
|
+
provider: defaultProvider,
|
|
120
|
+
model: defaultModel,
|
|
121
|
+
role: 'architect',
|
|
122
|
+
mode: 'read',
|
|
123
|
+
prompt: `Produce only a bounded JSON delivery plan for this approved outcome:\n${job.arguments.description}`,
|
|
124
|
+
budget: budget(job.arguments.budget),
|
|
125
|
+
attempt: context.attempt,
|
|
126
|
+
onStart: context.onProcessStart,
|
|
127
|
+
signal: context.signal,
|
|
128
|
+
});
|
|
129
|
+
let plan;
|
|
130
|
+
try {
|
|
131
|
+
plan = JSON.parse(result.text);
|
|
132
|
+
} catch {
|
|
133
|
+
throw new HubError('HUB_PROVIDER_OUTPUT_INVALID', 'Planner did not return JSON');
|
|
134
|
+
}
|
|
135
|
+
const validatedPlan = validateDeliveryPlan(plan, grant);
|
|
136
|
+
return {
|
|
137
|
+
plan: validatedPlan,
|
|
138
|
+
planDigest: createHash('sha256').update(canonicalJson(validatedPlan)).digest('hex'),
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
'workspace.inspect': async (job) => {
|
|
142
|
+
const root = roots.get(job.arguments.rootId);
|
|
143
|
+
if (!root) throw new HubError('HUB_CAPABILITY_DENIED', 'Root is not locally approved');
|
|
144
|
+
await validateRootGrant(root);
|
|
145
|
+
return {
|
|
146
|
+
summary: 'Approved workspace root is available.',
|
|
147
|
+
repo: { rootId: root.rootId, repoWrite: root.repoWrite },
|
|
148
|
+
fileManifestHash: createHash('sha256').update(root.repositoryIdentity).digest('hex'),
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
'worktree.create': async (job, context) => {
|
|
152
|
+
const root = roots.get(job.arguments.rootId);
|
|
153
|
+
if (!root) throw new HubError('HUB_CAPABILITY_DENIED', 'Root is not locally approved');
|
|
154
|
+
const record = await createOwnedWorktree({
|
|
155
|
+
grant: root,
|
|
156
|
+
deliveryId: context.cloudJob.delivery_id,
|
|
157
|
+
streamId: context.cloudJob.stream_id,
|
|
158
|
+
baseRef: root.baseRef,
|
|
159
|
+
registry: worktrees,
|
|
160
|
+
});
|
|
161
|
+
return {
|
|
162
|
+
summary: 'Connector-owned worktree created.',
|
|
163
|
+
branch: record.branch,
|
|
164
|
+
worktreeRef: record.worktreeId,
|
|
165
|
+
repoHash: root.repositoryIdentity,
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
'git.inspect': async (job) => {
|
|
169
|
+
const root = roots.get(job.arguments.rootId);
|
|
170
|
+
if (!root) throw new HubError('HUB_CAPABILITY_DENIED', 'Root is not locally approved');
|
|
171
|
+
await validateRootGrant(root);
|
|
172
|
+
const env = {
|
|
173
|
+
PATH: process.env.PATH || '/usr/bin:/bin',
|
|
174
|
+
LC_ALL: 'C',
|
|
175
|
+
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
176
|
+
GIT_CONFIG_SYSTEM: '/dev/null',
|
|
177
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
178
|
+
};
|
|
179
|
+
const status = await execFile(
|
|
180
|
+
'git',
|
|
181
|
+
['status', '--porcelain=v1', '--untracked-files=no'],
|
|
182
|
+
{ cwd: root.canonicalRoot, env, timeout: 30_000, maxBuffer: 512 * 1024 }
|
|
183
|
+
);
|
|
184
|
+
let refSha = null;
|
|
185
|
+
let diffSummary = '';
|
|
186
|
+
if (job.arguments.ref) {
|
|
187
|
+
const resolved = await execFile(
|
|
188
|
+
'git',
|
|
189
|
+
['rev-parse', '--verify', '--end-of-options', `${job.arguments.ref}^{commit}`],
|
|
190
|
+
{ cwd: root.canonicalRoot, env, timeout: 30_000, maxBuffer: 64 * 1024 }
|
|
191
|
+
);
|
|
192
|
+
refSha = resolved.stdout.trim();
|
|
193
|
+
if (job.arguments.includeDiffSummary) {
|
|
194
|
+
const diff = await execFile(
|
|
195
|
+
'git',
|
|
196
|
+
['diff', '--stat', '--no-ext-diff', `${refSha}..HEAD`],
|
|
197
|
+
{ cwd: root.canonicalRoot, env, timeout: 30_000, maxBuffer: 512 * 1024 }
|
|
198
|
+
);
|
|
199
|
+
diffSummary = diff.stdout;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
summary: 'Git metadata inspected under the approved root.',
|
|
204
|
+
status: {
|
|
205
|
+
clean: status.stdout.length === 0,
|
|
206
|
+
changedEntries: status.stdout.trim() ? status.stdout.trim().split('\n').length : 0,
|
|
207
|
+
refSha,
|
|
208
|
+
},
|
|
209
|
+
diffSummaryHash: createHash('sha256').update(diffSummary).digest('hex'),
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
'agent.start': async (job, context) => {
|
|
213
|
+
const args = job.arguments;
|
|
214
|
+
const mode = args.role === 'coder' ? 'write' : 'read';
|
|
215
|
+
const result = await drivers.driver(args.provider, args.model).start({
|
|
216
|
+
workspace: workspaceFor(args.rootId, mode, context.cloudJob),
|
|
217
|
+
model: args.model,
|
|
218
|
+
role: args.role,
|
|
219
|
+
mode,
|
|
220
|
+
prompt: `Execute this approved structured intent only:\n${JSON.stringify(args.intent)}`,
|
|
221
|
+
budget: budget(args.budget),
|
|
222
|
+
attempt: context.attempt,
|
|
223
|
+
onStart: context.onProcessStart,
|
|
224
|
+
signal: context.signal,
|
|
225
|
+
});
|
|
226
|
+
await sessions.recordPendingStart(context.cloudJob.id, {
|
|
227
|
+
agentId: args.agentId,
|
|
228
|
+
provider: args.provider,
|
|
229
|
+
model: args.model,
|
|
230
|
+
role: args.role,
|
|
231
|
+
mode,
|
|
232
|
+
rootId: args.rootId,
|
|
233
|
+
deliveryId: context.cloudJob.delivery_id,
|
|
234
|
+
streamId: context.cloudJob.stream_id,
|
|
235
|
+
worktreeId:
|
|
236
|
+
mode === 'write'
|
|
237
|
+
? worktrees
|
|
238
|
+
.list(args.rootId)
|
|
239
|
+
.find(
|
|
240
|
+
(record) =>
|
|
241
|
+
record.deliveryId === context.cloudJob.delivery_id &&
|
|
242
|
+
record.streamId === context.cloudJob.stream_id
|
|
243
|
+
)?.worktreeId
|
|
244
|
+
: null,
|
|
245
|
+
providerSessionId: result.sessionId,
|
|
246
|
+
processIdentity: result.processIdentity,
|
|
247
|
+
state: 'active',
|
|
248
|
+
});
|
|
249
|
+
return {
|
|
250
|
+
summary: drivers.driver(args.provider, args.model).collectSummary(result).text,
|
|
251
|
+
agentSessionRefHash: createHash('sha256')
|
|
252
|
+
.update(String(result.sessionId))
|
|
253
|
+
.digest('hex'),
|
|
254
|
+
};
|
|
255
|
+
},
|
|
256
|
+
'agent.turn': async (job, context) => {
|
|
257
|
+
const args = job.arguments;
|
|
258
|
+
const session = context.sessionBinding?.session;
|
|
259
|
+
if (!session) throw new HubError('HUB_AGENT_SESSION_MISSING', 'Agent session is not local');
|
|
260
|
+
await sessions.put(args.agentSessionId, session);
|
|
261
|
+
const result = await drivers.driver(session.provider, session.model).send({
|
|
262
|
+
workspace: workspaceFor(
|
|
263
|
+
args.rootId,
|
|
264
|
+
session.mode,
|
|
265
|
+
context.cloudJob,
|
|
266
|
+
session.worktreeId
|
|
267
|
+
),
|
|
268
|
+
model: session.model,
|
|
269
|
+
role: session.role,
|
|
270
|
+
mode: session.mode,
|
|
271
|
+
sessionId: session.providerSessionId,
|
|
272
|
+
prompt: `Execute this approved structured intent only:\n${JSON.stringify(args.intent)}`,
|
|
273
|
+
budget: budget(args.budget),
|
|
274
|
+
attempt: context.attempt,
|
|
275
|
+
onStart: context.onProcessStart,
|
|
276
|
+
signal: context.signal,
|
|
277
|
+
});
|
|
278
|
+
await sessions.put(args.agentSessionId, {
|
|
279
|
+
...session,
|
|
280
|
+
providerSessionId: result.sessionId,
|
|
281
|
+
processIdentity: result.processIdentity,
|
|
282
|
+
});
|
|
283
|
+
const summary = drivers.driver(session.provider, session.model).collectSummary(result);
|
|
284
|
+
return {
|
|
285
|
+
summary: summary.text,
|
|
286
|
+
usage: result.usage,
|
|
287
|
+
evidenceIds: [],
|
|
288
|
+
needsUser: false,
|
|
289
|
+
};
|
|
290
|
+
},
|
|
291
|
+
'agent.stop': async (job, context) => {
|
|
292
|
+
const args = job.arguments;
|
|
293
|
+
const session = context.sessionBinding?.session;
|
|
294
|
+
if (!session) throw new HubError('HUB_AGENT_SESSION_MISSING', 'Agent session is not local');
|
|
295
|
+
await sessions.put(args.agentSessionId, session);
|
|
296
|
+
if (session.processIdentity) {
|
|
297
|
+
const outcome = await drivers
|
|
298
|
+
.driver(session.provider, session.model)
|
|
299
|
+
.cancel(session.processIdentity);
|
|
300
|
+
if (!outcome.verifiedStopped) {
|
|
301
|
+
throw new HubError('HUB_CANCELLATION_UNPROVEN', 'Agent process did not stop');
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
await sessions.put(args.agentSessionId, { ...session, state: 'stopped' });
|
|
305
|
+
return { summary: args.reason, stopped: true };
|
|
306
|
+
},
|
|
307
|
+
'evidence.collect': async (job) => ({
|
|
308
|
+
summary: 'Evidence remains local until explicitly approved for upload.',
|
|
309
|
+
artifacts: job.arguments.kinds.map((kind) => ({
|
|
310
|
+
kind,
|
|
311
|
+
label: `Local ${kind.replaceAll('_', ' ')}`,
|
|
312
|
+
mimeType: 'application/octet-stream',
|
|
313
|
+
byteSize: 0,
|
|
314
|
+
sha256: createHash('sha256').update('').digest('hex'),
|
|
315
|
+
storageMode: 'local_only',
|
|
316
|
+
redactionVersion: 1,
|
|
317
|
+
userVisible: true,
|
|
318
|
+
})),
|
|
319
|
+
}),
|
|
320
|
+
...hubActions,
|
|
321
|
+
};
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const sessionResolver = (job) => sessions.resolveContinuation(job);
|
|
325
|
+
const settlementObserver = (attempt, response, error = null) => {
|
|
326
|
+
if (attempt.jobType !== 'agent.start') return null;
|
|
327
|
+
if (error) return sessions.rejectStartSettlement(attempt.jobId);
|
|
328
|
+
return sessions.applyStartAcknowledgement(attempt.jobId, response);
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
tokenStore,
|
|
333
|
+
transportFactory,
|
|
334
|
+
handlersFactory,
|
|
335
|
+
sessionResolver,
|
|
336
|
+
settlementObserver,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { HubError } from './errors.js';
|
|
3
|
+
import { acquireOwnerLock } from './ownerLock.js';
|
|
4
|
+
import { ProcessIdentityProvider } from './processOwnership.js';
|
|
5
|
+
import { ensureOwnerOnlyDirectory, writeOwnerOnlyJson } from './storage.js';
|
|
6
|
+
|
|
7
|
+
export class DaemonLock {
|
|
8
|
+
constructor({
|
|
9
|
+
directory,
|
|
10
|
+
pid = process.pid,
|
|
11
|
+
identityProvider = new ProcessIdentityProvider(),
|
|
12
|
+
}) {
|
|
13
|
+
this.directory = directory;
|
|
14
|
+
this.pid = pid;
|
|
15
|
+
this.identityProvider = identityProvider;
|
|
16
|
+
this.pidFile = path.join(directory, 'daemon.pid');
|
|
17
|
+
this.healthFile = path.join(directory, 'daemon-health.json');
|
|
18
|
+
this.acquired = false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async acquire() {
|
|
22
|
+
await ensureOwnerOnlyDirectory(this.directory);
|
|
23
|
+
this.releaseLock = await acquireOwnerLock({
|
|
24
|
+
file: this.pidFile,
|
|
25
|
+
identity: await this.identityProvider.capture(this.pid),
|
|
26
|
+
identityProvider: this.identityProvider,
|
|
27
|
+
heldError: (owner) => new HubError(
|
|
28
|
+
'HUB_DAEMON_ALREADY_RUNNING',
|
|
29
|
+
`Pulse daemon is already running${owner?.pid ? ` with pid ${owner.pid}` : ''}`
|
|
30
|
+
),
|
|
31
|
+
});
|
|
32
|
+
this.acquired = true;
|
|
33
|
+
return { pid: this.pid };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
heartbeat(status) {
|
|
37
|
+
if (!this.acquired) {
|
|
38
|
+
throw new HubError('HUB_DAEMON_NOT_OWNER', 'Daemon lock is not held by this process');
|
|
39
|
+
}
|
|
40
|
+
return writeOwnerOnlyJson(this.healthFile, {
|
|
41
|
+
pid: this.pid,
|
|
42
|
+
heartbeatAt: new Date().toISOString(),
|
|
43
|
+
...status,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async release() {
|
|
48
|
+
if (!this.acquired) return;
|
|
49
|
+
try {
|
|
50
|
+
await this.releaseLock();
|
|
51
|
+
} finally {
|
|
52
|
+
this.acquired = false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|