@hivemagnus/magnus-agent 1.1.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.
@@ -11,6 +11,7 @@ import { hash } from './execution-supervisor-state.js';
11
11
  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
+ import { stableCodePath } from '../upgrade/stable-path.js';
14
15
  export const cronRoutes = new Hono();
15
16
  const scheduler = new ManagedScheduler();
16
17
  const execFileAsync = promisify(execFile);
@@ -53,7 +54,7 @@ export function removeCronEntry(raw, name) {
53
54
  }
54
55
  return result.join('\n');
55
56
  }
56
- export function managedCronLine(job, nodePath = process.execPath, runnerPath = fileURLToPath(new URL('./scheduler-runner.js', import.meta.url))) {
57
+ export function managedCronLine(job, nodePath = process.execPath, runnerPath = stableCodePath(fileURLToPath(new URL('./scheduler-runner.js', import.meta.url)))) {
57
58
  for (const path of [nodePath, runnerPath])
58
59
  if (!path.startsWith('/') || /[\x00-\x1f\x7f%]/.test(path))
59
60
  throw new Error('Unsafe fixed cron runner path');
@@ -85,8 +85,8 @@ declare const configurationSchema: z.ZodObject<{
85
85
  token: z.ZodOptional<z.ZodString>;
86
86
  workspaceId: z.ZodDefault<z.ZodString>;
87
87
  defaultScope: z.ZodDefault<z.ZodEnum<{
88
- private: "private";
89
88
  shared: "shared";
89
+ private: "private";
90
90
  }>>;
91
91
  recallMode: z.ZodDefault<z.ZodEnum<{
92
92
  context: "context";
@@ -2,14 +2,22 @@
2
2
  import { config } from 'dotenv';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
4
  import { spawn } from 'node:child_process';
5
- import { constants, createReadStream } from 'node:fs';
5
+ import { constants, createReadStream, realpathSync } from 'node:fs';
6
6
  import { access, chown, chmod, lstat, mkdir, mkdtemp, readFile, readlink, readdir, rename, rm, stat, symlink, writeFile } from 'node:fs/promises';
7
7
  import { dirname, isAbsolute, join, resolve } from 'node:path';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { childEnvironment } from './utils/child-env.js';
11
11
  import { armChildTimeout } from './utils/child-timeout.js';
12
- const invokedAsService = Boolean(process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url));
12
+ // Compare real paths: the service may start this file through a link (node_modules -> current/node_modules).
13
+ const invokedAsService = (() => {
14
+ try {
15
+ return Boolean(process.argv[1] && realpathSync(resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url)));
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ })();
13
21
  if (invokedAsService)
14
22
  config({ path: join(process.env['AGENT_INSTALL_DIR'] ?? '/opt/magnus-agent', '.env'), quiet: true });
15
23
  const api = new URL('/api/v1/hermes-registry/', process.env['CONTROL_API_URL'] ?? 'https://localhost:4000');
@@ -12,7 +12,7 @@
12
12
  * root for it and never calls sudo.
13
13
  */
14
14
  import { execFileSync } from 'node:child_process';
15
- import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
15
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, symlinkSync, writeFileSync } from 'node:fs';
16
16
  import { dirname, join, resolve } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { AGENT_PACKAGE, pointCurrentAt } from './self-upgrade.js';
@@ -188,6 +188,10 @@ export async function installService(argv) {
188
188
  }
189
189
  act(`point ${current} at versions/${version}`, () => pointCurrentAt(dir, version));
190
190
  }
191
+ // Paths written before the versioned layout (cron lines, other units) keep working.
192
+ if (!existsSync(join(dir, 'node_modules')) && !isSymlink(join(dir, 'node_modules'))) {
193
+ act(`link ${join(dir, 'node_modules')} to current/node_modules`, () => symlinkSync(join('current', 'node_modules'), join(dir, 'node_modules'), 'dir'));
194
+ }
191
195
  // The launcher is copied from the release that runs install-service, not linked:
192
196
  // self-upgrades never replace it.
193
197
  act(`install ${join(dir, 'launcher', 'launcher.mjs')}`, () => {
@@ -205,6 +209,8 @@ export async function installService(argv) {
205
209
  execFileSync('chown', [`${user}:${user}`, dir]);
206
210
  execFileSync('chown', ['-R', `${user}:${user}`, join(dir, 'versions'), join(dir, '.npm')]);
207
211
  execFileSync('chown', ['-h', `${user}:${user}`, current]);
212
+ if (isSymlink(join(dir, 'node_modules')))
213
+ execFileSync('chown', ['-h', `${user}:${user}`, join(dir, 'node_modules')]);
208
214
  });
209
215
  }
210
216
  act(`write ${options.unitPath}${existsSync(options.unitPath) ? ` (previous unit kept as ${options.unitPath}.pre-launcher)` : ''}`, () => {
@@ -14,6 +14,12 @@ export interface UpgradeState {
14
14
  }
15
15
  export declare function installDir(env?: NodeJS.ProcessEnv): string;
16
16
  export declare function readUpgradeState(dir: string): UpgradeState | null;
17
+ /**
18
+ * An agent running as root inside a directory another account owns (a deploy
19
+ * user that installs releases there) gives what it writes to that owner, so
20
+ * the owner can still replace or remove it. Otherwise this does nothing.
21
+ */
22
+ export declare function adoptInstallOwner(dir: string, path: string, uid?: number | undefined): void;
17
23
  export declare function writeUpgradeState(dir: string, state: UpgradeState): void;
18
24
  export declare function pointCurrentAt(dir: string, version: string): void;
19
25
  export type Layout = AgentSelfUpgradeStatus['layout'];
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { createHash } from 'node:crypto';
11
11
  import { spawn } from 'node:child_process';
12
- import { accessSync, constants, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
12
+ import { accessSync, constants, existsSync, lchownSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
13
13
  import { join, resolve } from 'node:path';
14
14
  import { AGENT_SELF_UPGRADE_PROTOCOL, checkUpgradeAllowed, compareVersions, } from '@hivemagnus/magnus-contracts';
15
15
  export const STATE_FILE = 'upgrade-state.json';
@@ -25,16 +25,39 @@ export function readUpgradeState(dir) {
25
25
  return null;
26
26
  }
27
27
  }
28
+ /**
29
+ * An agent running as root inside a directory another account owns (a deploy
30
+ * user that installs releases there) gives what it writes to that owner, so
31
+ * the owner can still replace or remove it. Otherwise this does nothing.
32
+ */
33
+ export function adoptInstallOwner(dir, path, uid = process.getuid?.()) {
34
+ if (uid !== 0)
35
+ return;
36
+ const owner = statSync(dir);
37
+ if (owner.uid === 0)
38
+ return;
39
+ const walk = (entry) => {
40
+ const info = lstatSync(entry);
41
+ lchownSync(entry, owner.uid, owner.gid);
42
+ if (info.isDirectory())
43
+ for (const child of readdirSync(entry))
44
+ walk(join(entry, child));
45
+ };
46
+ if (lstatSync(path, { throwIfNoEntry: false }))
47
+ walk(path);
48
+ }
28
49
  export function writeUpgradeState(dir, state) {
29
50
  const path = join(dir, STATE_FILE);
30
51
  const temporary = `${path}.${process.pid}.tmp`;
31
52
  writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
53
+ adoptInstallOwner(dir, temporary);
32
54
  renameSync(temporary, path);
33
55
  }
34
56
  export function pointCurrentAt(dir, version) {
35
57
  const temporary = join(dir, `current.${process.pid}.tmp`);
36
58
  rmSync(temporary, { force: true });
37
59
  symlinkSync(join('versions', version), temporary, 'dir');
60
+ adoptInstallOwner(dir, temporary);
38
61
  renameSync(temporary, join(dir, 'current'));
39
62
  }
40
63
  export function detectLayout(dir, env = process.env) {
@@ -228,6 +251,8 @@ export async function stageUpgrade(options) {
228
251
  }
229
252
  rmSync(target, { recursive: true, force: true });
230
253
  mkdirSync(join(dir, 'versions'), { recursive: true });
254
+ adoptInstallOwner(dir, join(dir, 'versions'));
255
+ adoptInstallOwner(dir, install);
231
256
  renameSync(install, target);
232
257
  const now = (options.now ?? new Date()).toISOString();
233
258
  writeUpgradeState(dir, { state: 'staged', from, to: request.version, requestId: request.requestId, attempt: 0, maxAttempts: 2, reason: null, updatedAt: now });
@@ -236,6 +261,8 @@ export async function stageUpgrade(options) {
236
261
  }
237
262
  finally {
238
263
  rmSync(work, { recursive: true, force: true });
264
+ adoptInstallOwner(dir, join(dir, 'staging'));
265
+ adoptInstallOwner(dir, join(dir, '.npm'));
239
266
  staging = false;
240
267
  }
241
268
  }
@@ -0,0 +1,2 @@
1
+ export declare function stableCodePath(path: string, env?: NodeJS.ProcessEnv): string;
2
+ //# sourceMappingURL=stable-path.d.ts.map
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Paths to this agent's own code that outlive the process (cron lines, for
3
+ * example) must not name versions/<X>: a self-upgrade moves ./current and later
4
+ * prunes old versions. Under the launcher, such a path is rewritten through the
5
+ * install directory: `<dir>/node_modules/…` when that is the compatibility link
6
+ * to current/node_modules (so lines written before the versioned layout keep
7
+ * their exact text), otherwise `<dir>/current/…`.
8
+ */
9
+ import { lstatSync, realpathSync } from 'node:fs';
10
+ import { join, relative, resolve, sep } from 'node:path';
11
+ export function stableCodePath(path, env = process.env) {
12
+ if (env['MAGNUS_AGENT_LAUNCHER'] !== '1' || !env['AGENT_INSTALL_DIR'])
13
+ return path;
14
+ const dir = resolve(env['AGENT_INSTALL_DIR']);
15
+ const inside = relative(join(dir, 'versions'), path);
16
+ if (inside.startsWith('..') || inside === '' || inside.startsWith(sep))
17
+ return path;
18
+ const [, ...rest] = inside.split(sep);
19
+ if (rest.length === 0)
20
+ return path;
21
+ try {
22
+ const link = join(dir, 'node_modules');
23
+ if (rest[0] === 'node_modules' && lstatSync(link).isSymbolicLink()
24
+ && realpathSync(link) === realpathSync(join(dir, 'current', 'node_modules'))) {
25
+ return join(dir, ...rest);
26
+ }
27
+ }
28
+ catch {
29
+ // no compatibility link
30
+ }
31
+ return join(dir, 'current', ...rest);
32
+ }
33
+ //# sourceMappingURL=stable-path.js.map
@@ -14,7 +14,7 @@
14
14
  * `magnus-agent install-service` replaces it. It never escalates privileges.
15
15
  */
16
16
  import { spawn } from 'node:child_process';
17
- import { existsSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync, openSync, fsyncSync, closeSync } from 'node:fs';
17
+ import { closeSync, existsSync, fsyncSync, lchownSync, openSync, readFileSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
18
18
  import { dirname, join, resolve } from 'node:path';
19
19
  import { fileURLToPath, pathToFileURL } from 'node:url';
20
20
 
@@ -61,12 +61,20 @@ export function readState(dir) {
61
61
  }
62
62
  }
63
63
 
64
+ /** Running as root in a directory another account owns: hand what we write to that owner. */
65
+ function adoptOwner(dir, path) {
66
+ if (process.getuid?.() !== 0) return;
67
+ const owner = statSync(dir);
68
+ if (owner.uid !== 0) lchownSync(path, owner.uid, owner.gid);
69
+ }
70
+
64
71
  export function writeState(dir, state) {
65
72
  const path = join(dir, STATE_FILE);
66
73
  const temporary = `${path}.${process.pid}.tmp`;
67
74
  writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
68
75
  const fd = openSync(temporary, 'r');
69
76
  try { fsyncSync(fd); } finally { closeSync(fd); }
77
+ adoptOwner(dir, temporary);
70
78
  renameSync(temporary, path);
71
79
  }
72
80
 
@@ -74,6 +82,7 @@ export function pointCurrentAt(dir, version) {
74
82
  const temporary = join(dir, `current.${process.pid}.tmp`);
75
83
  rmSync(temporary, { force: true });
76
84
  symlinkSync(join('versions', version), temporary, 'dir');
85
+ adoptOwner(dir, temporary);
77
86
  renameSync(temporary, join(dir, 'current'));
78
87
  }
79
88
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hivemagnus/magnus-agent",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Magnus node agent: a small daemon that enrolls a Linux host with a magnus-server and runs authenticated health, command, cron, file and skill management, with optional Hermes Agent, Codex and Ardent management.",
5
5
  "keywords": [
6
6
  "magnus",
@@ -55,22 +55,22 @@
55
55
  "clean": "node -e \"const fs=require('node:fs');fs.rmSync('dist',{recursive:true,force:true});fs.rmSync('tsconfig.tsbuildinfo',{force:true})\""
56
56
  },
57
57
  "dependencies": {
58
- "@hivemagnus/magnus-contracts": "1.1.0",
58
+ "@hivemagnus/magnus-contracts": "1.1.2",
59
59
  "@hono/node-server": "^2.1.5",
60
60
  "dotenv": "^18.0.9",
61
- "hono": "^4.13.14",
61
+ "hono": "^4.13.16",
62
62
  "ky": "^2.2.0",
63
- "nanoid": "^5.1.16",
63
+ "nanoid": "^6.0.2",
64
64
  "tar-stream": "3.2.2",
65
- "undici": "^7.30.1",
65
+ "undici": "^8.12.3",
66
66
  "yaml": "^2.9.1",
67
67
  "zod": "^4.6.7"
68
68
  },
69
69
  "devDependencies": {
70
- "@types/node": "^22.20.5",
70
+ "@types/node": "^24.19.2",
71
71
  "@types/tar-stream": "^3.1.5",
72
72
  "tsx": "^4.23.15",
73
- "typescript": "^5.8.3",
74
- "vitest": "^4.1.12"
73
+ "typescript": "^6.0.3",
74
+ "vitest": "^5.0.5"
75
75
  }
76
76
  }