@hivemagnus/magnus-agent 1.1.7 → 1.1.8

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.
@@ -12,6 +12,9 @@ import { HOST_FQDN, NODE_MANAGER_SECRET, RESULT_SIGNING_SECRET } from '../env.js
12
12
  import { readdir, readFile } from 'node:fs/promises';
13
13
  import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
14
14
  import { stableCodePath } from '../upgrade/stable-path.js';
15
+ import { logger } from '../utils/logger.js';
16
+ /** Routes answer with a fixed code; the cause goes to the log. */
17
+ const warnCause = (what, error) => logger.warn(`${what} failed: ${error instanceof Error ? error.message : String(error)}`);
15
18
  export const cronRoutes = new Hono();
16
19
  const scheduler = new ManagedScheduler();
17
20
  const execFileAsync = promisify(execFile);
@@ -108,7 +111,8 @@ cronRoutes.get('/os-scheduler-writer-proof', async (c) => {
108
111
  c.header('Cache-Control', 'no-store');
109
112
  return c.json({ success: true, data: { ...receipt, signature } });
110
113
  }
111
- catch {
114
+ catch (error) {
115
+ warnCause('Writer proof', error);
112
116
  return c.json({ success: false, error: { code: 'WRITER_PROOF_UNAVAILABLE' } }, 409);
113
117
  }
114
118
  });
@@ -120,6 +124,7 @@ cronRoutes.get('/', async (c) => {
120
124
  return c.json({ success: true, data: jobs });
121
125
  }
122
126
  catch {
127
+ // no-reason-needed: `crontab -l` fails the same way for a user with no crontab, which is the common answer
123
128
  return c.json({ success: true, data: [] });
124
129
  }
125
130
  });
@@ -147,6 +152,7 @@ cronRoutes.get('/all-users', async (c) => {
147
152
  return c.json({ success: true, data: allJobs });
148
153
  }
149
154
  catch {
155
+ // no-reason-needed: `crontab -l` fails the same way for a user with no crontab, which is the common answer
150
156
  return c.json({ success: true, data: [] });
151
157
  }
152
158
  });
@@ -183,7 +189,8 @@ cronRoutes.post('/enrollment-stage', async (c) => {
183
189
  return c.json({ success: true, data: { ...receipt, receiptSignature: createHmac('sha256', RESULT_SIGNING_SECRET)
184
190
  .update(cronEnrollmentStageReceiptPayload(receipt)).digest('hex') } });
185
191
  }
186
- catch {
192
+ catch (error) {
193
+ warnCause('Enrollment stage', error);
187
194
  return c.json({ success: false, error: { code: 'CRON_ENROLLMENT_STAGE_CONFLICT' } }, 409);
188
195
  }
189
196
  });
@@ -195,7 +202,8 @@ cronRoutes.get('/source-reconciliation/:decisionId', async (c) => {
195
202
  c.header('Cache-Control', 'no-store');
196
203
  return c.json({ success: true, data: receipt });
197
204
  }
198
- catch {
205
+ catch (error) {
206
+ warnCause('Scheduler reconciliation lookup', error);
199
207
  return c.json({ success: false, error: { code: 'SCHEDULER_RECONCILIATION_NOT_FOUND' } }, 404);
200
208
  }
201
209
  });
@@ -230,7 +238,8 @@ cronRoutes.post('/source-reconciliation', async (c) => {
230
238
  c.header('Cache-Control', 'no-store');
231
239
  return c.json({ success: true, data: settled.receipt });
232
240
  }
233
- catch {
241
+ catch (error) {
242
+ warnCause('Scheduler reconciliation', error);
234
243
  return c.json({ success: false, error: { code: 'SCHEDULER_RECONCILIATION_CONFLICT' } }, 409);
235
244
  }
236
245
  });
@@ -257,7 +266,8 @@ cronRoutes.post('/sync', async (c) => {
257
266
  });
258
267
  return c.json({ success: true, data: { name: job.name, schedule: job.schedule, command: job.command, synced: true, managed: false } });
259
268
  }
260
- catch {
269
+ catch (error) {
270
+ warnCause('Cron sync', error);
261
271
  return c.json({ success: false, error: { code: 'CRON_SYNC_ERROR' } }, 500);
262
272
  }
263
273
  });
@@ -285,7 +295,8 @@ cronRoutes.post('/managed-sync', async (c) => {
285
295
  return c.json({ success: true, data: { nodeFqdn: HOST_FQDN, name: job.spec.name, jobId: job.jobId,
286
296
  revision: job.spec.revision, specSha256: job.specSha256, staged: true, enabled: false } });
287
297
  }
288
- catch {
298
+ catch (error) {
299
+ warnCause('Cron sync', error);
289
300
  return c.json({ success: false, error: { code: 'CRON_SYNC_ERROR', message: 'Managed cron installation incomplete; inspect durable definition before retry' } }, 500);
290
301
  }
291
302
  });
@@ -310,7 +321,8 @@ cronRoutes.post('/managed-activate', async (c) => {
310
321
  });
311
322
  return c.json({ success: true, data: { ...parsed.data, activated: true, enabled: true } });
312
323
  }
313
- catch {
324
+ catch (error) {
325
+ warnCause('Managed cron activation', error);
314
326
  return c.json({ success: false, error: { code: 'CRON_ACTIVATION_ERROR', message: 'Managed cron activation was not acknowledged' } }, 500);
315
327
  }
316
328
  });
@@ -333,7 +345,8 @@ cronRoutes.post('/managed-revoke', async (c) => {
333
345
  });
334
346
  return c.json({ success: true, data: { ...parsed.data, revoked: true, enabled: false } });
335
347
  }
336
- catch {
348
+ catch (error) {
349
+ warnCause('Managed cron revocation', error);
337
350
  return c.json({ success: false, error: { code: 'CRON_REVOCATION_ERROR', message: 'Managed cron revocation was not acknowledged' } }, 500);
338
351
  }
339
352
  });
@@ -349,7 +362,8 @@ cronRoutes.delete('/:name', async (c) => {
349
362
  });
350
363
  return c.json({ success: true, data: { deleted: true } });
351
364
  }
352
- catch {
365
+ catch (error) {
366
+ warnCause('Cron delete', error);
353
367
  return c.json({ success: false, error: { code: 'CRON_DELETE_ERROR' } }, 500);
354
368
  }
355
369
  });
@@ -56,7 +56,8 @@ enrollmentDrainRoutes.get('/host-local-writer-proof', async (c) => {
56
56
  c.header('Cache-Control', 'no-store');
57
57
  return c.json({ success: true, data: { ...receipt, signature } });
58
58
  }
59
- catch {
59
+ catch (error) {
60
+ logger.warn('Host-local writer proof failed', { error: error instanceof Error ? error.message : String(error) });
60
61
  return c.json({ success: false, error: { code: 'WRITER_PROOF_UNAVAILABLE' } }, 409);
61
62
  }
62
63
  });
@@ -66,7 +67,8 @@ enrollmentDrainRoutes.get('/mutation-inventory', async (c) => {
66
67
  c.header('Cache-Control', 'no-store');
67
68
  return c.json({ success: true, data: inventory });
68
69
  }
69
- catch {
70
+ catch (error) {
71
+ logger.warn('Mutation inventory failed', { error: error instanceof Error ? error.message : String(error) });
70
72
  return c.json({ success: false, error: { code: 'MUTATION_INVENTORY_UNAVAILABLE',
71
73
  message: 'Retained mutation inventory cannot be validated' } }, 409);
72
74
  }
@@ -4,11 +4,13 @@ import { z } from 'zod';
4
4
  import { CONTROL_API_URL, HOST_FQDN, NODE_MANAGER_SECRET, RESULT_SIGNING_SECRET, MAGNUS_EXECUTION_SUPERVISOR_ENABLED } from '../env.js';
5
5
  import { controlApiFetch } from '../tls.js';
6
6
  import { commandLedger, executeBodySchema } from './command-executor.js';
7
- import { commandFenceSchema, commandRequestDigest, isArdentScopedCommand } from './command-ledger.js';
7
+ import { refusalError, refusalStatus } from './command-refusal.js';
8
+ import { admissionCause, CommandAdmissionError, commandFenceSchema, commandRequestDigest, isArdentScopedCommand } from './command-ledger.js';
8
9
  import { managedExecution } from '../security/managed-context.js';
9
10
  import { legacyAdmissionResponseSchema, managedClaimSchema, MANAGED_CLAIM_HEADER, httpIdentity, httpReceiptBody } from '../security/managed-http.js';
10
11
  import { nativeCompletionDescriptor, nativeCompletionResourceKey } from '../security/native-completion-registry.js';
11
12
  import { signCommandResult } from '../security/command-result-signature.js';
13
+ import { logger } from '../utils/logger.js';
12
14
  import { mutationBarrier } from './mutation-barrier.js';
13
15
  import { recoverSkillPublication } from './skill-publication.js';
14
16
  export const managedHttpRequestSchema = z.object({ claim: managedClaimSchema, commandId: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/),
@@ -66,18 +68,39 @@ export const managedMutationGuard = async (c, next) => {
66
68
  return next();
67
69
  const encoded = c.req.header(MANAGED_CLAIM_HEADER);
68
70
  if (path === '/commands/execute' && encoded) {
71
+ let input;
72
+ let leaseId;
73
+ try {
74
+ leaseId = managedClaimSchema.parse(JSON.parse(encoded)).leaseId;
75
+ }
76
+ catch (error) {
77
+ logger.warn(`Managed command claim does not parse: ${error instanceof Error ? error.message : String(error)}`);
78
+ }
79
+ const refused = async (code, message) => {
80
+ // Only a well-formed request can be bound to a signed refusal.
81
+ const parsed = executeBodySchema.safeParse(input ?? await c.req.json().catch(() => null));
82
+ const error = parsed.success
83
+ ? await refusalError({ commandId: parsed.data.commandId, requestSha256: commandRequestDigest(parsed.data), leaseId }, code, message, { notStarted: true })
84
+ : { code, message };
85
+ return c.json({ success: false, error }, 409);
86
+ };
69
87
  if (activeLegacy || enrolling)
70
- return c.json({ success: false, error: { code: 'RESOURCE_BUSY' } }, 409);
88
+ return refused('RESOURCE_BUSY', 'Another request owns the node execution slot; nothing ran');
71
89
  enrolling = true;
90
+ let confirmed = false;
72
91
  try {
73
92
  const claim = managedClaimSchema.parse(JSON.parse(encoded));
74
- const input = await c.req.json();
93
+ input = await c.req.json();
75
94
  await confirm(claim, input);
95
+ confirmed = true;
76
96
  return await managedExecution.run({ claim, commandId: input.commandId, requestSha256: commandRequestDigest(input),
77
97
  ...(input.coordination ? { coordination: input.coordination } : {}), signal: c.req.raw.signal }, () => next());
78
98
  }
79
- catch {
80
- return c.json({ success: false, error: { code: 'RESOURCE_CLAIM_INVALID', message: 'A current authoritative resource claim is required' } }, 409);
99
+ catch (error) {
100
+ logger.warn(`Managed command claim failed: ${error instanceof Error ? error.message : String(error)}`);
101
+ const message = 'A current authoritative resource claim is required';
102
+ // After confirmation the handler may have started: stay uncertain.
103
+ return confirmed ? c.json({ success: false, error: { code: 'RESOURCE_CLAIM_INVALID', message } }, 409) : refused('RESOURCE_CLAIM_INVALID', message);
81
104
  }
82
105
  finally {
83
106
  enrolling = false;
@@ -117,7 +140,8 @@ export const managedMutationGuard = async (c, next) => {
117
140
  && MAGNUS_EXECUTION_SUPERVISOR_ENABLED)
118
141
  completed = true;
119
142
  }
120
- catch {
143
+ catch (error) {
144
+ logger.warn(`Mutation ${c.req.method} ${path} ${dispatched ? 'outcome is uncertain' : 'was not admitted'}: ${error instanceof Error ? error.message : String(error)}`);
121
145
  return c.json({ success: false,
122
146
  error: dispatched
123
147
  ? { code: 'MUTATION_OUTCOME_UNKNOWN', message: 'Mutation outcome is uncertain; reconcile before retrying' }
@@ -142,20 +166,19 @@ export function registerManagedHttp(app) {
142
166
  if (value.recoveryOnly && (value.method !== 'POST' || value.path !== '/skills/install'))
143
167
  return c.json({ success: false, error: { code: 'INVALID_RECOVERY_ONLY',
144
168
  message: 'Recovery-only execution is limited to exact skill publication evidence' } }, 409);
169
+ const identity = { ...httpIdentity(value.commandId, value.method, value.path, value.body, value.timeoutSeconds), coordination: value.coordination };
170
+ const digest = commandRequestDigest(identity);
171
+ const refuse = async (code, message, details) => c.json({ success: false, error: await refusalError({ commandId: value.commandId, requestSha256: digest, leaseId: value.claim.leaseId }, code, message, details) }, refusalStatus(code));
145
172
  const descriptor = nativeCompletionDescriptor(value.method, value.path);
146
173
  if (!descriptor)
147
- return c.json({ success: false,
148
- error: { code: 'UNREVIEWED_MUTATION', message: 'This mutation needs a separately reviewed completion boundary' } }, 409);
174
+ return refuse('UNREVIEWED_MUTATION', 'This mutation needs a separately reviewed completion boundary', { notStarted: true });
149
175
  if (value.timeoutSeconds > descriptor.timeoutCeilingSeconds
150
176
  || value.coordination.resourceKey !== nativeCompletionResourceKey(descriptor, value.claim.nodeId))
151
- return c.json({ success: false, error: { code: 'UNREVIEWED_MUTATION',
152
- message: 'Native mutation exceeds its reviewed completion or resource boundary' } }, 409);
177
+ return refuse('UNREVIEWED_MUTATION', 'Native mutation exceeds its reviewed completion or resource boundary', { notStarted: true });
153
178
  if (activeLegacy || enrolling)
154
- return c.json({ success: false, error: { code: 'RESOURCE_BUSY' } }, 409);
179
+ return refuse('RESOURCE_BUSY', 'Another request owns the node execution slot; nothing ran', { notStarted: true });
155
180
  enrolling = true;
156
181
  try {
157
- const identity = { ...httpIdentity(value.commandId, value.method, value.path, value.body, value.timeoutSeconds), coordination: value.coordination };
158
- const digest = commandRequestDigest(identity);
159
182
  const prior = await commandLedger.receipt(value.commandId);
160
183
  if (value.recoveryOnly) {
161
184
  if (prior && (prior.requestSha256 !== digest || !['unknown', 'completed'].includes(prior.state)))
@@ -175,8 +198,20 @@ export function registerManagedHttp(app) {
175
198
  const adopted = await commandLedger.settleRecoveredResult(identity, signed, value.claim);
176
199
  return c.json({ success: true, data: { response: recovered, receipt: adopted.result } });
177
200
  }
178
- await confirm(value.claim, identity);
201
+ try {
202
+ await confirm(value.claim, identity);
203
+ }
204
+ catch (error) {
205
+ // Before the ledger: no handler can have started for this request.
206
+ logger.warn(`Managed request ${value.commandId} was not confirmed: ${error instanceof Error ? error.message : String(error)}`);
207
+ throw new CommandAdmissionError('RESOURCE_CLAIM_INVALID', 'The server did not confirm the exact resource owner; nothing ran', { notStarted: !prior });
208
+ }
179
209
  let response;
210
+ const afterSettled = [];
211
+ const settled = async () => {
212
+ for (const task of afterSettled.splice(0))
213
+ await Promise.resolve().then(task).catch((error) => logger.warn(`After ${value.commandId} settled: ${error instanceof Error ? error.message : String(error)}`));
214
+ };
180
215
  const receipt = await commandLedger.execute(identity, async (digest) => {
181
216
  const start = Date.now();
182
217
  // Durable fence/enrollment/receipt writes may outlast the authority
@@ -196,7 +231,7 @@ export function registerManagedHttp(app) {
196
231
  }
197
232
  else
198
233
  result = await managedExecution.run({ claim: value.claim, commandId: value.commandId, requestSha256: digest,
199
- coordination: value.coordination, signal: c.req.raw.signal }, () => app.request(`http://localhost/api/v1${value.path}`, {
234
+ coordination: value.coordination, signal: c.req.raw.signal, afterSettled }, () => app.request(`http://localhost/api/v1${value.path}`, {
200
235
  method: value.method, headers: { 'content-type': 'application/json', 'x-node-token': NODE_MANAGER_SECRET },
201
236
  signal: c.req.raw.signal,
202
237
  ...(value.body ? { body: value.body } : {}),
@@ -233,12 +268,18 @@ export function registerManagedHttp(app) {
233
268
  durationMs: Date.now() - start, truncated: false,
234
269
  coordination: { ...value.coordination, requestSha256: digest } };
235
270
  return { ...signed, signature: signCommandResult(signed, RESULT_SIGNING_SECRET) };
236
- }, value.claim);
271
+ }, value.claim).finally(settled);
237
272
  if (!response)
238
273
  return c.json({ success: false, error: { code: 'RESULT_ALREADY_RECORDED', message: 'Read the existing signed receipt without replay' } }, 409);
239
274
  return c.json({ success: true, data: { response, receipt } });
240
275
  }
241
- catch {
276
+ catch (error) {
277
+ // A refusal the ledger can name keeps its name; only what is really uncertain is reported as uncertain.
278
+ if (error instanceof CommandAdmissionError && error.code !== 'OUTCOME_UNKNOWN') {
279
+ logger.warn(`Managed request ${value.commandId} ${value.method} ${value.path} refused: ${error.code}: ${error.message}${admissionCause(error)}${error.highWater ? ` (${error.highWater.resourceKey} is at fence ${error.highWater.fence}, the request has ${value.coordination.fence})` : ''}`);
280
+ return refuse(error.code, error.message, { notStarted: error.notStarted, ...(error.highWater ? { highWater: error.highWater } : {}) });
281
+ }
282
+ logger.warn(`Managed request ${value.commandId} ${value.method} ${value.path} is uncertain: ${error instanceof Error ? error.message : String(error)}${error instanceof CommandAdmissionError ? admissionCause(error) : ''}`);
242
283
  return c.json({ success: false, error: { code: 'MANAGED_MUTATION_UNCERTAIN', message: 'Inspect the exact resource receipt before further work; no automatic replay' } }, 409);
243
284
  }
244
285
  finally {
@@ -43,4 +43,46 @@ export interface CommandReconciliationAssertion {
43
43
  /** Separate key and domain: a result signer cannot authorize releasing an unknown claim. */
44
44
  export declare function commandReconciliationSigningPayload(assertion: Omit<CommandReconciliationAssertion, 'signature'>): string;
45
45
  export declare function verifyCommandReconciliation(assertion: CommandReconciliationAssertion, secret: string): boolean;
46
+ /**
47
+ * A certain refusal: this exact request did not start and has no receipt. Bound
48
+ * to the request digest, the lease and the node, so it settles nothing else.
49
+ */
50
+ export interface CommandRefusal {
51
+ commandId: string;
52
+ requestSha256: string;
53
+ code: string;
54
+ highWater: string | null;
55
+ nodeFqdn: string;
56
+ leaseId: string;
57
+ signature: string;
58
+ }
59
+ export declare function commandRefusalSigningPayload(refusal: Omit<CommandRefusal, 'signature'>): string;
60
+ /**
61
+ * What this ledger holds about a command right now, bound to the node and a fresh
62
+ * nonce. Absence means something only next to positive evidence that a ledger is
63
+ * there at all: its root, the fence high-water and the enrollment marker.
64
+ */
65
+ export interface CommandAbsence {
66
+ commandId: string;
67
+ nodeFqdn: string;
68
+ nonce: string;
69
+ ledgerExists: boolean;
70
+ receipt: boolean;
71
+ activeLock: boolean;
72
+ resourceKey: string;
73
+ fenceHighWater: string | null;
74
+ markerState: 'absent' | 'present' | 'unknown';
75
+ markerNodeId: string | null;
76
+ /** The fence of this command's durable record of a failed admission, if it has one. */
77
+ admissionFailedFence: string | null;
78
+ signature: string;
79
+ }
80
+ export declare function commandAbsenceSigningPayload(absence: Omit<CommandAbsence, 'signature'>): string;
81
+ /** The fence high-water a preflight reports, bound to the node and a fresh nonce. Only this can move a server generation. */
82
+ export declare function fenceProofSigningPayload(proof: {
83
+ nonce: string;
84
+ nodeFqdn: string;
85
+ fenceHighWater: Readonly<Record<string, string>>;
86
+ }): string;
87
+ export declare function signCommandEvidence(payload: string, secret: string): string;
46
88
  //# sourceMappingURL=command-result-signature.d.ts.map
@@ -47,4 +47,23 @@ export function verifyCommandReconciliation(assertion, secret) {
47
47
  const expected = createHmac('sha256', secret).update(commandReconciliationSigningPayload(assertion)).digest();
48
48
  return timingSafeEqual(expected, Buffer.from(assertion.signature, 'hex'));
49
49
  }
50
+ export function commandRefusalSigningPayload(refusal) {
51
+ return JSON.stringify({ domain: 'magnus.command-refusal.v1', commandId: refusal.commandId, requestSha256: refusal.requestSha256,
52
+ code: refusal.code, highWater: refusal.highWater, nodeFqdn: refusal.nodeFqdn, leaseId: refusal.leaseId });
53
+ }
54
+ export function commandAbsenceSigningPayload(absence) {
55
+ return JSON.stringify({ domain: 'magnus.command-absence.v1', commandId: absence.commandId, nodeFqdn: absence.nodeFqdn, nonce: absence.nonce,
56
+ ledgerExists: absence.ledgerExists, receipt: absence.receipt, activeLock: absence.activeLock, resourceKey: absence.resourceKey,
57
+ fenceHighWater: absence.fenceHighWater, markerState: absence.markerState, markerNodeId: absence.markerNodeId, admissionFailedFence: absence.admissionFailedFence });
58
+ }
59
+ /** The fence high-water a preflight reports, bound to the node and a fresh nonce. Only this can move a server generation. */
60
+ export function fenceProofSigningPayload(proof) {
61
+ return JSON.stringify({ domain: 'magnus.fence-high-water.v1', nonce: proof.nonce, nodeFqdn: proof.nodeFqdn,
62
+ fenceHighWater: Object.fromEntries(Object.entries(proof.fenceHighWater).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) });
63
+ }
64
+ export function signCommandEvidence(payload, secret) {
65
+ if (secret.length < 32)
66
+ throw new Error('RESULT_SIGNING_SECRET must contain at least 32 characters');
67
+ return createHmac('sha256', secret).update(payload).digest('hex');
68
+ }
50
69
  //# sourceMappingURL=command-result-signature.js.map
@@ -7,6 +7,8 @@ export interface ManagedExecutionContext {
7
7
  requestSha256: string;
8
8
  coordination?: CommandFence;
9
9
  signal?: AbortSignal;
10
+ /** Run once the ledger's terminal receipt for this request is durable, or the attempt ended without one. */
11
+ afterSettled?: Array<() => void | Promise<void>>;
10
12
  }
11
13
  /** Process-only trusted context. HTTP callers cannot set this with a header. */
12
14
  export declare const managedExecution: AsyncLocalStorage<ManagedExecutionContext>;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * What the server needs to know before it touches a node. Read only: nothing
3
+ * here takes a lock or writes a file, so asking is safe at any time and needs
4
+ * no lease. The same data, minus the checks against a target, goes out with
5
+ * every heartbeat, so an operator sees drift long before an upgrade.
6
+ */
7
+ import type { AgentDiskHealth, AgentLedgerHealth, AgentNpmHealth, AgentUpgradePreflight } from '@hivemagnus/magnus-contracts';
8
+ import type { CommandLedger } from '../executors/command-ledger.js';
9
+ export declare const PREFLIGHT_PROTOCOL = 1;
10
+ type LedgerReader = Pick<CommandLedger, 'health' | 'enrollmentMarker'>;
11
+ export declare function upgradePreflight(options: {
12
+ version: string;
13
+ target?: string | undefined;
14
+ engines?: string | undefined;
15
+ nonce?: string | undefined;
16
+ ledger: LedgerReader;
17
+ env?: NodeJS.ProcessEnv;
18
+ nodeVersion?: string;
19
+ }): Promise<AgentUpgradePreflight>;
20
+ /** The optional drift fields of a heartbeat's self-upgrade report. An older server ignores them. */
21
+ export declare function heartbeatDrift(ledger: LedgerReader, env?: NodeJS.ProcessEnv): {
22
+ fenceHighWater?: Record<string, string>;
23
+ ledger?: AgentLedgerHealth;
24
+ disk?: AgentDiskHealth;
25
+ npm: AgentNpmHealth;
26
+ };
27
+ export {};
28
+ //# sourceMappingURL=preflight.d.ts.map
@@ -0,0 +1,62 @@
1
+ import { HOST_FQDN, RESULT_SIGNING_SECRET } from '../env.js';
2
+ import { fenceProofSigningPayload, signCommandEvidence } from '../security/command-result-signature.js';
3
+ import { logger } from '../utils/logger.js';
4
+ import { diskHealth, engineProblem, installDir, npmHealth, runsVersion, selfUpgradeStatus, unwritablePaths, versionComplete } from './self-upgrade.js';
5
+ export const PREFLIGHT_PROTOCOL = 1;
6
+ const LEDGER_CACHE_MS = 60_000;
7
+ const ledgerReport = (health) => ({ readable: health.readable, activeLock: health.activeLock,
8
+ nonTerminalReceipts: health.nonTerminalReceipts, unresolvedReceipts: health.unresolvedReceipts, truncated: health.truncated, error: health.error });
9
+ export async function upgradePreflight(options) {
10
+ const env = options.env ?? process.env;
11
+ const dir = installDir(env);
12
+ const status = selfUpgradeStatus(options.version, env);
13
+ const health = await options.ledger.health();
14
+ const marker = await options.ledger.enrollmentMarker();
15
+ if (marker.error)
16
+ logger.warn(`Local enrollment marker could not be read: ${marker.error}`);
17
+ const nodeVersion = options.nodeVersion ?? process.versions.node;
18
+ const range = options.engines?.slice(0, 200) || null;
19
+ const problem = range ? engineProblem(range, nodeVersion) : null;
20
+ const blocked = unwritablePaths(dir);
21
+ const disk = diskHealth(dir);
22
+ return {
23
+ preflightProtocol: PREFLIGHT_PROTOCOL,
24
+ version: options.version,
25
+ target: options.target?.slice(0, 64) ?? null,
26
+ layout: status.layout,
27
+ launcherProtocol: status.launcherProtocol,
28
+ enabled: status.enabled,
29
+ reason: status.reason ?? null,
30
+ reasonCode: status.reasonCode ?? null,
31
+ writable: { ok: blocked.length === 0, blocked },
32
+ ...(disk ? { disk } : {}),
33
+ npm: npmHealth(env),
34
+ fromVersionComplete: versionComplete(dir, options.version) && runsVersion(dir, options.version),
35
+ ledger: ledgerReport(health),
36
+ fenceHighWater: health.fenceHighWater,
37
+ ...(options.nonce && /^[a-zA-Z0-9-]{16,64}$/.test(options.nonce) && RESULT_SIGNING_SECRET.length >= 32 ? { fenceProof: { nonce: options.nonce, nodeFqdn: HOST_FQDN,
38
+ signature: signCommandEvidence(fenceProofSigningPayload({ nonce: options.nonce, nodeFqdn: HOST_FQDN, fenceHighWater: health.fenceHighWater }), RESULT_SIGNING_SECRET) } } : {}),
39
+ managedEnrollment: marker.marker ? { nodeId: marker.marker.nodeId, nodeFqdn: marker.marker.nodeFqdn } : null,
40
+ managedEnrollmentState: marker.state,
41
+ engines: { node: nodeVersion, range, compatible: range ? problem === null : null, problem },
42
+ };
43
+ }
44
+ let cached = null;
45
+ let refreshing = false;
46
+ /** Ledger health for heartbeats: the last scan at once, with a new scan in the background once a minute. */
47
+ function cachedLedgerHealth(ledger, now = Date.now()) {
48
+ if (!refreshing && (!cached || now - cached.at >= LEDGER_CACHE_MS)) {
49
+ refreshing = true;
50
+ void ledger.health().then(health => { cached = { at: Date.now(), health }; }, (error) => {
51
+ logger.warn(`Ledger health could not be read: ${error instanceof Error ? error.message : String(error)}`);
52
+ }).finally(() => { refreshing = false; });
53
+ }
54
+ return cached?.health ?? null;
55
+ }
56
+ /** The optional drift fields of a heartbeat's self-upgrade report. An older server ignores them. */
57
+ export function heartbeatDrift(ledger, env = process.env) {
58
+ const health = cachedLedgerHealth(ledger);
59
+ const disk = diskHealth(installDir(env));
60
+ return { ...(health ? { fenceHighWater: health.fenceHighWater, ledger: ledgerReport(health) } : {}), ...(disk ? { disk } : {}), npm: npmHealth(env) };
61
+ }
62
+ //# sourceMappingURL=preflight.js.map
@@ -1,12 +1,19 @@
1
1
  import { Hono } from 'hono';
2
2
  import { agentUpgradeRequestSchema } from '@hivemagnus/magnus-contracts';
3
+ import { managedExecution } from '../security/managed-context.js';
3
4
  import { logger } from '../utils/logger.js';
4
5
  import { AGENT_VERSION } from '../version.js';
6
+ import { upgradePreflight } from './preflight.js';
5
7
  import { EXIT_BACKSTOP_MS, SelfUpgradeError, selfUpgradeStatus, stageUpgrade } from './self-upgrade.js';
6
8
  /** Seconds between the reply and the exit that hands over to the launcher. */
7
9
  export const RESTART_DELAY_MS = 3_000;
8
10
  export const agentUpgradeRoutes = new Hono();
9
11
  agentUpgradeRoutes.get('/status', c => c.json({ success: true, data: selfUpgradeStatus(AGENT_VERSION) }));
12
+ // Read only, and a GET, so it needs no lease, no claim and no registry entry. Older agents answer 404.
13
+ agentUpgradeRoutes.get('/preflight', async (c) => {
14
+ const { commandLedger } = await import('../executors/command-executor.js');
15
+ return c.json({ success: true, data: await upgradePreflight({ version: AGENT_VERSION, target: c.req.query('target'), engines: c.req.query('engines'), nonce: c.req.query('nonce'), ledger: commandLedger }) });
16
+ });
10
17
  agentUpgradeRoutes.post('/', async (c) => {
11
18
  const parsed = agentUpgradeRequestSchema.safeParse(await c.req.json().catch(() => null));
12
19
  if (!parsed.success) {
@@ -19,9 +26,17 @@ agentUpgradeRoutes.post('/', async (c) => {
19
26
  return c.json({ success: true, data: { accepted: true, ...result } });
20
27
  logger.info(`Self-upgrade staged: ${result.from} -> ${result.to}; restarting through the launcher`);
21
28
  // Reply first; the launcher starts versions/<to> on trial after this process exits.
22
- setTimeout(() => process.kill(process.pid, 'SIGTERM'), RESTART_DELAY_MS).unref();
23
- // Backstop: a shutdown that hangs must not keep the old version running on the new ./current.
24
- setTimeout(() => process.exit(0), EXIT_BACKSTOP_MS).unref();
29
+ const restart = () => {
30
+ setTimeout(() => process.kill(process.pid, 'SIGTERM'), RESTART_DELAY_MS).unref();
31
+ // Backstop: a shutdown that hangs must not keep the old version running on the new ./current.
32
+ setTimeout(() => process.exit(0), EXIT_BACKSTOP_MS).unref();
33
+ };
34
+ // A managed request's terminal receipt is written after this handler returns: the clock starts when it is durable.
35
+ const managed = managedExecution.getStore();
36
+ if (managed?.afterSettled)
37
+ managed.afterSettled.push(restart);
38
+ else
39
+ restart();
25
40
  return c.json({ success: true, data: { accepted: true, ...result, restartInSeconds: RESTART_DELAY_MS / 1000 } });
26
41
  }
27
42
  catch (error) {
@@ -1,4 +1,4 @@
1
- import { type AgentSelfUpgradeStatus, type AgentUpgradeReport, type AgentUpgradeRequest } from '@hivemagnus/magnus-contracts';
1
+ import { type AgentDiskHealth, type AgentNpmHealth, type AgentSelfUpgradeStatus, type AgentUpgradeReport, type AgentUpgradeRequest } from '@hivemagnus/magnus-contracts';
2
2
  export declare const STATE_FILE = "upgrade-state.json";
3
3
  export declare const LOCK_FILE = "upgrade-state.lock";
4
4
  /** Written while on trial when heartbeats cannot reach the control API; the launcher then extends the trial. */
@@ -18,6 +18,8 @@ export declare const STEP_TIMEOUT_MS: {
18
18
  export declare const STAGE_BUDGET_MS: number;
19
19
  /** After staging, the agent exits even if a graceful shutdown hangs. */
20
20
  export declare const EXIT_BACKSTOP_MS = 60000;
21
+ /** Free space an upgrade needs in the install directory: the staged install, its npm cache and the new version. */
22
+ export declare const REQUIRED_FREE_BYTES: number;
21
23
  export interface UpgradeState {
22
24
  state: 'idle' | 'staged' | 'trial' | 'confirmed' | 'reverted' | 'failed';
23
25
  from: string | null;
@@ -71,8 +73,13 @@ export interface NpmCommand {
71
73
  * often lacks the directory of a version-managed Node.js.
72
74
  */
73
75
  export declare function resolveNpm(env?: NodeJS.ProcessEnv, execPath?: string): NpmCommand;
76
+ /** The paths in the install directory this agent cannot write, relative to it. */
77
+ export declare function unwritablePaths(dir: string): string[];
78
+ /** Free space of the install directory, or null where the filesystem does not say. */
79
+ export declare function diskHealth(dir: string, requiredBytes?: number): AgentDiskHealth | null;
80
+ export declare function npmHealth(env?: NodeJS.ProcessEnv): AgentNpmHealth;
74
81
  /** Whether this agent can upgrade itself, and why not. Reported with every heartbeat. */
75
- export declare function selfUpgradeStatus(version: string, env?: NodeJS.ProcessEnv): AgentSelfUpgradeStatus;
82
+ export declare function selfUpgradeStatus(version: string, env?: NodeJS.ProcessEnv, execPath?: string): AgentSelfUpgradeStatus;
76
83
  /** The upgrade state when `version` is the one on trial. */
77
84
  export declare function trialOf(version: string, env?: NodeJS.ProcessEnv): UpgradeState | null;
78
85
  /** Tell the launcher this version runs but cannot reach the control API, so it extends the trial instead of reverting. */
@@ -95,16 +102,46 @@ export declare function upgradeLauncher(dir: string, version: string, execPath?:
95
102
  */
96
103
  export declare function confirmTrial(version: string, env?: NodeJS.ProcessEnv, now?: Date): boolean;
97
104
  export declare function pruneVersions(dir: string, keep: ReadonlyArray<string | null>): void;
105
+ /**
106
+ * The class of a failed npm step, read only from what npm itself says and from
107
+ * facts the package cannot write. Lifecycle scripts run inside npm, and their
108
+ * output comes back in the same stream, so free text proves nothing:
109
+ * - the first `npm error code X` line decides, because npm prints it before it
110
+ * repeats any script output. A numeric code, ELIFECYCLE or `npm error command`
111
+ * is a script failing, and that is the package's fault whatever it printed.
112
+ * - a killed npm is recognised by how the process ended (SIGKILL, or status 137
113
+ * through a wrapper), never by reading "Killed".
114
+ * - a full disk that surfaces as a tar or JSON parse error is recognised by
115
+ * measuring the disk (`diskLow`), and by npm's own tar warning line.
116
+ * The limit: a script runs as this account and can kill npm or write to npm's own
117
+ * stderr. It gains only that a bad package reads as node-level on the nodes it
118
+ * runs on, and the run then says no node confirmed.
119
+ */
120
+ export declare function npmFailureCode(output: string, facts?: {
121
+ signal?: NodeJS.Signals | null;
122
+ exitCode?: number;
123
+ diskLow?: boolean;
124
+ }): string;
125
+ /** What a failed write of the state file or the links says: the node, unless the errno says nothing. */
126
+ export declare function switchFailureCode(error: unknown): string;
127
+ /** Output a package controls, made safe for a reason or a log: no control characters, and bounded. */
128
+ export declare function cleanPackageText(text: string, max: number): string;
98
129
  export declare class SelfUpgradeError extends Error {
99
130
  readonly code: string;
100
- readonly status: 400 | 409 | 422 | 500 | 504;
101
- constructor(code: string, message: string, status?: 400 | 409 | 422 | 500 | 504);
131
+ readonly status: 400 | 409 | 422;
132
+ /**
133
+ * Always below 500: a thrown SelfUpgradeError means the attempt ended and the
134
+ * staging was cleaned up, so over managed-http it is a settled, signed result
135
+ * rather than an uncertain mutation. Anything else that goes wrong stays a 500.
136
+ */
137
+ constructor(code: string, message: string, status?: 400 | 409 | 422);
102
138
  }
103
139
  export interface RunResult {
104
140
  code: number;
105
141
  stdout: string;
106
142
  stderr: string;
107
143
  timedOut?: boolean;
144
+ signal?: NodeJS.Signals | null;
108
145
  }
109
146
  export interface Runner {
110
147
  (command: string, args: readonly string[], options: {