@stage-labs/metro 0.1.0-beta.246 → 0.1.0-beta.247

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.
@@ -7,7 +7,7 @@ const STOP_MS = 30_000;
7
7
  const SETTLE_MS = 5_000;
8
8
  const POLL_MS = 250;
9
9
  const given = (value) => value !== undefined && value.trim() !== '' ? value.trim() : undefined;
10
- const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
10
+ export const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
11
11
  export const keepInSession = (env) => given(env[AGENT_VIEW_OFF]) === undefined ? { ...env, [AGENT_VIEW_OFF]: '1' } : env;
12
12
  export const claudeDir = (env = process.env) => given(env.CLAUDE_CONFIG_DIR) ?? join(given(env.HOME) ?? homedir(), '.claude');
13
13
  export const projectDir = (dir, cwd) => join(dir, 'projects', cwd.replace(/[^A-Za-z0-9]/g, '-'));
@@ -0,0 +1,161 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { X509Certificate } from 'node:crypto';
4
+ import { createRequire } from 'node:module';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { isRecord } from './background.js';
8
+ import { ISSUER, isVersion, PUBLISHER, RELEASE_DIR } from './root-release.js';
9
+ import { PACKAGE_NAME } from './version.js';
10
+ const REGISTRY = 'https://registry.npmjs.org';
11
+ const ENCODED = PACKAGE_NAME.replace('/', '%2F');
12
+ const ROOT_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
13
+ const SETUP = join('runtime', 'node_modules', '@metro-labs', 'daemon', 'src', 'metro-user', 'install.ts');
14
+ const MAX_TARBALL = 64 * 1024 * 1024;
15
+ const TIMEOUT_MS = 60_000;
16
+ const isSigstore = (mod) => isRecord(mod) && typeof mod.verify === 'function';
17
+ export function rootOwned(path) {
18
+ let at = realpathSync(path);
19
+ for (;;) {
20
+ const st = statSync(at);
21
+ if (st.uid !== 0 || (st.mode & 0o022) !== 0)
22
+ return false;
23
+ if (at === dirname(at))
24
+ return true;
25
+ at = dirname(at);
26
+ }
27
+ }
28
+ function mustBeRootOwned(path, what) {
29
+ if (!existsSync(path) || !rootOwned(path))
30
+ throw new Error(`${what} at ${path} is missing or not owned by root alone; refused`);
31
+ return path;
32
+ }
33
+ async function getJson(url) {
34
+ const res = await fetch(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });
35
+ if (!res.ok)
36
+ throw new Error(`${url} answered ${String(res.status)}`);
37
+ return res.json();
38
+ }
39
+ async function published(version) {
40
+ const body = await getJson(`${REGISTRY}/${ENCODED}`);
41
+ const tags = isRecord(body) && isRecord(body['dist-tags']) ? body['dist-tags'] : {};
42
+ const entry = isRecord(body) && isRecord(body.versions) ? body.versions[version] : undefined;
43
+ const dist = isRecord(entry) && isRecord(entry.dist) ? entry.dist : {};
44
+ if (typeof dist.tarball !== 'string' || typeof dist.integrity !== 'string')
45
+ throw new Error(`npm has no ${PACKAGE_NAME}@${version}`);
46
+ return { tags, tarball: dist.tarball, integrity: dist.integrity };
47
+ }
48
+ async function download(url) {
49
+ const res = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
50
+ if (!res.ok)
51
+ throw new Error(`${url} answered ${String(res.status)}`);
52
+ if (Number(res.headers.get('content-length') ?? 0) > MAX_TARBALL)
53
+ throw new Error(`${url} is too large`);
54
+ const bytes = Buffer.from(await res.arrayBuffer());
55
+ if (bytes.length > MAX_TARBALL)
56
+ throw new Error(`${url} is too large`);
57
+ return bytes;
58
+ }
59
+ function loadSigstore() {
60
+ const npm = mustBeRootOwned(join(dirname(dirname(process.execPath)), 'lib', 'node_modules', 'npm'), "npm's sigstore");
61
+ const mod = createRequire(join(npm, 'package.json'))('sigstore');
62
+ if (!isSigstore(mod))
63
+ throw new Error(`no sigstore verifier in ${npm}; refused`);
64
+ return mod;
65
+ }
66
+ function leafCertificate(bundle) {
67
+ const material = isRecord(bundle) && isRecord(bundle.verificationMaterial) ? bundle.verificationMaterial : {};
68
+ const chain = isRecord(material.x509CertificateChain) && Array.isArray(material.x509CertificateChain.certificates) ? material.x509CertificateChain.certificates : [];
69
+ const leaf = isRecord(material.certificate) ? material.certificate : chain[0];
70
+ return isRecord(leaf) && typeof leaf.rawBytes === 'string' ? leaf.rawBytes : null;
71
+ }
72
+ export function signedByPublisher(bundle) {
73
+ const raw = leafCertificate(bundle);
74
+ if (raw === null)
75
+ return false;
76
+ const names = new X509Certificate(Buffer.from(raw, 'base64')).subjectAltName?.split(', ') ?? [];
77
+ return names.includes(`URI:${PUBLISHER}`);
78
+ }
79
+ async function verifySignature(bundle) {
80
+ if (!signedByPublisher(bundle))
81
+ throw new Error(`the provenance was not signed for ${PUBLISHER}; refused`);
82
+ const sigstore = loadSigstore();
83
+ const tuf = mkdtempSync(join(tmpdir(), 'metro-tuf-'));
84
+ try {
85
+ await sigstore.verify(bundle, { certificateIdentityURI: PUBLISHER, certificateIssuer: ISSUER, tufCachePath: tuf });
86
+ }
87
+ catch (err) {
88
+ throw new Error(`the provenance signature does not check out: ${err instanceof Error ? err.message : String(err)}; refused`);
89
+ }
90
+ finally {
91
+ rmSync(tuf, { recursive: true, force: true });
92
+ }
93
+ }
94
+ function checked(file, args, cwd = '/') {
95
+ const run = spawnSync(file, args, { cwd, env: { PATH: ROOT_PATH }, stdio: ['ignore', 'inherit', 'inherit'] });
96
+ if (run.error !== undefined)
97
+ throw run.error;
98
+ if (run.status !== 0)
99
+ throw new Error(`${file} ${args.join(' ')} failed (exit ${String(run.status)})`);
100
+ }
101
+ function manifestVersion(dir) {
102
+ const file = join(dir, 'package.json');
103
+ if (!existsSync(file))
104
+ return null;
105
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
106
+ return isRecord(raw) && raw.name === PACKAGE_NAME && typeof raw.version === 'string' && isVersion(raw.version) ? raw.version : null;
107
+ }
108
+ function unpack(tarball, version) {
109
+ const work = mkdtempSync(join(tmpdir(), 'metro-release-'));
110
+ const staged = `${RELEASE_DIR}.new`;
111
+ try {
112
+ const file = join(work, 'metro.tgz');
113
+ writeFileSync(file, tarball, { mode: 0o600 });
114
+ rmSync(staged, { recursive: true, force: true });
115
+ mkdirSync(staged, { recursive: true, mode: 0o755 });
116
+ checked('tar', ['-xzf', file, '-C', staged, '--strip-components=1', '--no-same-owner', '--no-same-permissions']);
117
+ }
118
+ finally {
119
+ rmSync(work, { recursive: true, force: true });
120
+ }
121
+ if (manifestVersion(staged) !== version)
122
+ throw new Error(`the unpacked package is not ${PACKAGE_NAME}@${version}; refused`);
123
+ return staged;
124
+ }
125
+ function findBun() {
126
+ const found = ROOT_PATH.split(':')
127
+ .map((dir) => join(dir, 'bun'))
128
+ .find((path) => existsSync(path));
129
+ if (found === undefined)
130
+ throw new Error(`no bun on ${ROOT_PATH}`);
131
+ return mustBeRootOwned(found, 'bun');
132
+ }
133
+ function setup(dir) {
134
+ const node = mustBeRootOwned(process.execPath, 'node');
135
+ checked(findBun(), ['--no-install', join(dir, SETUP), '--node', node]);
136
+ }
137
+ function swap(staged) {
138
+ const old = `${RELEASE_DIR}.old`;
139
+ rmSync(old, { recursive: true, force: true });
140
+ if (existsSync(RELEASE_DIR))
141
+ renameSync(RELEASE_DIR, old);
142
+ renameSync(staged, RELEASE_DIR);
143
+ rmSync(old, { recursive: true, force: true });
144
+ }
145
+ export function realReleaseDeps() {
146
+ process.umask(0o022);
147
+ mkdirSync(dirname(RELEASE_DIR), { recursive: true, mode: 0o755 });
148
+ return {
149
+ installed: () => manifestVersion(RELEASE_DIR),
150
+ published,
151
+ download,
152
+ attestations: (version) => getJson(`${REGISTRY}/-/npm/v1/attestations/${ENCODED}@${version}`),
153
+ verifySignature,
154
+ unpack,
155
+ setup,
156
+ swap,
157
+ out: (line) => {
158
+ process.stderr.write(`metro root: ${line}\n`);
159
+ },
160
+ };
161
+ }
@@ -0,0 +1,74 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isRecord } from './background.js';
3
+ import { compareVersions, PACKAGE_NAME } from './version.js';
4
+ export const RELEASE_DIR = '/usr/local/lib/metro/release';
5
+ export const PUBLISHER = 'https://github.com/bonustrack/metro/.github/workflows/publish-cli.yml@refs/heads/main';
6
+ export const ISSUER = 'https://token.actions.githubusercontent.com';
7
+ const REGISTRY = 'https://registry.npmjs.org';
8
+ const PROVENANCE = 'https://slsa.dev/provenance/v1';
9
+ const IN_TOTO = 'application/vnd.in-toto+json';
10
+ const VERSION_RE = /^\d{1,9}\.\d{1,9}\.\d{1,9}(?:-[0-9A-Za-z]{1,20}(?:\.[0-9A-Za-z]{1,20}){0,4})?$/;
11
+ export const isVersion = (raw) => VERSION_RE.test(raw);
12
+ export const tarballUrl = (version) => `${REGISTRY}/${PACKAGE_NAME}/-/metro-${version}.tgz`;
13
+ const subjectName = (version) => `pkg:npm/${PACKAGE_NAME.replace('@', '%40')}@${version}`;
14
+ function provenanceBundle(body, version) {
15
+ const list = isRecord(body) && Array.isArray(body.attestations) ? body.attestations : [];
16
+ const entry = list.find((a) => isRecord(a) && a.predicateType === PROVENANCE);
17
+ if (!isRecord(entry) || !isRecord(entry.bundle))
18
+ throw new Error(`${PACKAGE_NAME}@${version} has no build provenance on npm; refused`);
19
+ return entry.bundle;
20
+ }
21
+ function statementOf(bundle) {
22
+ const envelope = bundle.dsseEnvelope;
23
+ if (!isRecord(envelope) || envelope.payloadType !== IN_TOTO || typeof envelope.payload !== 'string')
24
+ throw new Error('the provenance is not a signed in-toto statement; refused');
25
+ const statement = JSON.parse(Buffer.from(envelope.payload, 'base64').toString('utf8'));
26
+ if (!isRecord(statement))
27
+ throw new Error('the provenance statement is unreadable; refused');
28
+ return statement;
29
+ }
30
+ export function provenanceNames(statement, version, sha512) {
31
+ const subjects = Array.isArray(statement.subject) ? statement.subject : [];
32
+ return (statement.predicateType === PROVENANCE &&
33
+ subjects.some((s) => isRecord(s) && s.name === subjectName(version) && isRecord(s.digest) && s.digest.sha512 === sha512));
34
+ }
35
+ async function checkProvenance(version, tarball, deps) {
36
+ const bundle = provenanceBundle(await deps.attestations(version), version);
37
+ await deps.verifySignature(bundle);
38
+ const sha512 = createHash('sha512').update(tarball).digest('hex');
39
+ if (!provenanceNames(statementOf(bundle), version, sha512))
40
+ throw new Error(`the signed provenance does not name this tarball of ${PACKAGE_NAME}@${version}; refused`);
41
+ }
42
+ async function fetchChecked(version, trigger, deps) {
43
+ const published = await deps.published(version);
44
+ if (trigger === 'metro' && published.tags.beta !== version && published.tags.latest !== version)
45
+ throw new Error(`${version} is not what the beta or latest tag of ${PACKAGE_NAME} points to now; refused`);
46
+ if (published.tarball !== tarballUrl(version))
47
+ throw new Error(`npm names an unexpected tarball for ${version}: ${published.tarball}; refused`);
48
+ const tarball = await deps.download(published.tarball);
49
+ if (`sha512-${createHash('sha512').update(tarball).digest('base64')}` !== published.integrity)
50
+ throw new Error(`the tarball of ${version} does not match the sha512 npm publishes for it; refused`);
51
+ await checkProvenance(version, tarball, deps);
52
+ return tarball;
53
+ }
54
+ export async function installRelease(version, trigger, deps) {
55
+ if (!isVersion(version))
56
+ throw new Error(`'${version}' is not a ${PACKAGE_NAME} version`);
57
+ const have = deps.installed();
58
+ if (have !== null && compareVersions(version, have) <= 0) {
59
+ if (trigger === 'metro') {
60
+ deps.out(`the root side is at ${have}, and ${version} is not newer: nothing to do`);
61
+ return 'current';
62
+ }
63
+ deps.setup(RELEASE_DIR);
64
+ deps.out(`the root side stays at ${have} (it never goes back) and its root setup ran again`);
65
+ return 'refreshed';
66
+ }
67
+ deps.out(`checking ${PACKAGE_NAME}@${version} (npm sha512 and the GitHub provenance of ${PUBLISHER})`);
68
+ const tarball = await fetchChecked(version, trigger, deps);
69
+ const staged = deps.unpack(tarball, version);
70
+ deps.setup(staged);
71
+ deps.swap(staged);
72
+ deps.out(`the root side is now ${PACKAGE_NAME}@${version}, verified, in ${RELEASE_DIR}`);
73
+ return 'installed';
74
+ }
package/dist/service.js CHANGED
@@ -3,9 +3,12 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { homedir, platform, userInfo } from 'node:os';
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  import { serveLockedBy } from './control.js';
6
- import { findBun, runtimeDir } from './runtime.js';
6
+ import { installRelease } from './root-release.js';
7
+ import { realReleaseDeps } from './root-release-io.js';
8
+ import { findBun } from './runtime.js';
7
9
  import { findTailscale, parseServeArgs, requireOwner } from './serve.js';
8
- const USAGE = 'usage: metro service install [--port <n>] [--owner <organization id>] [--user metro] | uninstall | status';
10
+ import { currentVersion } from './version.js';
11
+ const USAGE = 'usage: metro service install [--port <n>] [--owner <organization id>] [--user metro] | uninstall | status | root-upgrade <version> (root only)';
9
12
  const SERVICE = 'metro';
10
13
  const LABEL = 'box.metro.serve';
11
14
  const CARRIED = /^(METRO_.*|XDG_CACHE_HOME|PATH|HOME)$/;
@@ -110,7 +113,7 @@ function systemdPlan(host, serveArgs, runAs) {
110
113
  file,
111
114
  content: runAs === undefined ? systemdUnit(host, exec, system ? 'multi-user.target' : 'default.target') : runAsUnit(host, runAs, serveArgs),
112
115
  dirs: [],
113
- install: [...(runAs === undefined ? [] : [{ args: ['bun', runAs.helper] }]), { args: [...ctl, 'daemon-reload'] }, { args: [...ctl, 'enable', '--now', SERVICE] }],
116
+ install: [{ args: [...ctl, 'daemon-reload'] }, { args: [...ctl, 'enable', '--now', SERVICE] }],
114
117
  uninstall: [{ args: [...ctl, 'disable', '--now', SERVICE], mayFail: true }],
115
118
  reload: [{ args: [...ctl, 'daemon-reload'], mayFail: true }],
116
119
  status: { args: [...ctl, 'is-active', SERVICE], mayFail: true },
@@ -176,18 +179,31 @@ function runAsOf(name, deps) {
176
179
  const account = deps.account(name);
177
180
  if (account === null)
178
181
  throw new Error(`there is no user ${name} on this machine; create it first (useradd --system --create-home --home-dir /var/lib/${name} ${name})`);
179
- return { ...account, helper: deps.helperEntry() };
182
+ return account;
183
+ }
184
+ async function refreshRoot(plan, deps) {
185
+ await deps.release(deps.version, 'root');
186
+ deps.out(`metro is already installed as a ${plan.kind} service (${plan.file}); its root side is current and follows every update by itself from now on, and the service keeps running`);
187
+ return 0;
188
+ }
189
+ function writeService(plan, deps) {
190
+ for (const dir of plan.dirs)
191
+ deps.mkdir(dir);
192
+ deps.write(plan.file, plan.content);
193
+ for (const command of plan.install)
194
+ deps.run(command);
195
+ deps.out(`Installed ${plan.file}: metro serve now starts at boot and after a crash, and the Server page on metro.box can stop, start and restart it.`);
196
+ for (const hint of plan.hints)
197
+ deps.out(hint);
198
+ return 0;
180
199
  }
181
200
  function install(argv, deps) {
182
201
  const { user, rest: serveArgs } = splitUser(argv);
183
202
  const args = parseServeArgs(serveArgs);
184
203
  const runAs = runAsOf(user, deps);
185
204
  const plan = servicePlan(deps.host, serveArgs, runAs);
186
- if (deps.exists(plan.file) && runAs !== undefined) {
187
- deps.run({ args: ['bun', runAs.helper] });
188
- deps.out(`metro is already installed as a ${plan.kind} service (${plan.file}); its root helper, sudo rules and metadata guard are now current, and the service keeps running`);
189
- return 0;
190
- }
205
+ if (deps.exists(plan.file) && runAs !== undefined)
206
+ return refreshRoot(plan, deps);
191
207
  if (deps.exists(plan.file)) {
192
208
  deps.out(`metro is already installed as a ${plan.kind} service (${plan.file}) and restarts on its own; metro service status says whether it is running. To change its arguments: metro service uninstall, then install again`);
193
209
  return 0;
@@ -196,14 +212,14 @@ function install(argv, deps) {
196
212
  const pid = deps.running();
197
213
  if (pid !== null)
198
214
  throw new Error(`a metro serve is running on this machine (pid ${String(pid)}). Stop it first (metro stop), then install: the service takes over from there`);
199
- for (const dir of plan.dirs)
200
- deps.mkdir(dir);
201
- deps.write(plan.file, plan.content);
202
- for (const command of plan.install)
203
- deps.run(command);
204
- deps.out(`Installed ${plan.file}: metro serve now starts at boot and after a crash, and the Server page on metro.box can stop, start and restart it.`);
205
- for (const hint of plan.hints)
206
- deps.out(hint);
215
+ if (runAs === undefined)
216
+ return writeService(plan, deps);
217
+ return deps.release(deps.version, 'root').then(() => writeService(plan, deps));
218
+ }
219
+ async function rootUpgrade(version, deps) {
220
+ if (deps.host.platform !== 'linux' || deps.host.uid !== 0)
221
+ throw new Error('metro service root-upgrade is for root on Linux; the metro-root-upgrade unit runs it');
222
+ await deps.release(version, 'metro');
207
223
  return 0;
208
224
  }
209
225
  function uninstall(deps) {
@@ -275,7 +291,8 @@ function realDeps() {
275
291
  },
276
292
  running: serveLockedBy,
277
293
  account: lookupAccount,
278
- helperEntry: () => join(runtimeDir(), 'node_modules', '@metro-labs', 'daemon', 'src', 'metro-user', 'install.ts'),
294
+ version: currentVersion(),
295
+ release: (version, trigger) => installRelease(version, trigger, realReleaseDeps()),
279
296
  preflight: (args, agents) => {
280
297
  requireOwner(args, agents);
281
298
  findBun();
@@ -305,5 +322,7 @@ export function service(argv, deps = realDeps()) {
305
322
  return Promise.resolve(uninstall(deps));
306
323
  if (verb === 'status' && rest.length === 0)
307
324
  return Promise.resolve(status(deps));
325
+ if (verb === 'root-upgrade' && rest.length === 1)
326
+ return rootUpgrade(rest[0] ?? '', deps);
308
327
  throw new Error(USAGE);
309
328
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.246",
3
+ "version": "0.1.0-beta.247",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.246",
3
+ "version": "0.1.0-beta.247",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -27,6 +27,7 @@ import { runningAsRoot } from '../metro-user/privilege.js';
27
27
  import { provisionAgentUser } from '../agent-user/provision.js';
28
28
  import { applyVault } from '../vault/index.js';
29
29
  import { blockImds } from '../metro-user/imds.js';
30
+ import { followRelease } from '../metro-user/root-follow.js';
30
31
  import { unwatchSession, watchSession } from '../claude/session.js';
31
32
  import { liveEvents, memoryRoutine, tryClaudeSetup } from '../claude/setup.js';
32
33
  import { tryMemoryJob } from '../claude/memory-routine.js';
@@ -174,6 +175,7 @@ async function startClaude(): Promise<void> {
174
175
  if (agentUser === 'failed') return;
175
176
  if (agentUser === 'ready') {
176
177
  blockImds();
178
+ followRelease();
177
179
  const vault = (await applyVault()).status;
178
180
  if (vault.enabled) log.info({ running: vault.running, problem: vault.problem }, 'vault: the agent reaches the internet through the vault');
179
181
  }
@@ -1,10 +1,16 @@
1
- export const HELPER_PATH = '/usr/local/lib/metro/root-helper';
2
- const HELPER_VERSION = 2;
1
+ const ROOT_DIR = '/usr/local/lib/metro';
2
+ export const HELPER_PATH = `${ROOT_DIR}/root-helper`;
3
+ const HELPER_VERSION = 3;
3
4
  export const SUDOERS_PATH = '/etc/sudoers.d/metro';
4
5
  export const IMDS_UNIT = 'metro-imds.service';
5
6
  export const IMDS_UNIT_PATH = `/etc/systemd/system/${IMDS_UNIT}`;
7
+ export const UPGRADE_UNIT = 'metro-root-upgrade@.service';
8
+ export const UPGRADE_UNIT_PATH = `/etc/systemd/system/${UPGRADE_UNIT}`;
9
+ const RELEASE_DIR = `${ROOT_DIR}/release`;
6
10
  export const METRO_USER = 'metro';
7
11
  const METRO_HOME = '/var/lib/metro';
12
+ const VAULT_DROP_IN = '11-metro-vault.conf';
13
+ const PRIVILEGED_EXEC = ['ExecConditionEx', 'ExecStartPreEx', 'ExecStartEx', 'ExecStartPostEx', 'ExecReloadEx', 'ExecStopEx', 'ExecStopPostEx'];
8
14
 
9
15
  export const sudoersText = (agent: string): string =>
10
16
  [
@@ -22,31 +28,23 @@ const CHECKS = lines(
22
28
  'die() { echo "root-helper: $*" >&2; exit 2; }',
23
29
  'agent_uid() { id -u "$AGENT" 2>/dev/null || die "no agent user"; }',
24
30
  'service_ok() {',
25
- ' printf %s "$1" | grep -Eq "^[A-Za-z0-9@_.-]+\\.service$" || die "bad service name"',
31
+ ' case "$1" in [!A-Za-z0-9]*|*[!A-Za-z0-9@_.-]*|"") die "bad service name";; esac',
32
+ ' case "$1" in *.service) ;; *) die "bad service name";; esac',
26
33
  ' case "$1" in metro*) die "not a metro unit";; esac',
27
34
  ' frag=$(systemctl show "$1" -p FragmentPath --value)',
28
35
  ' case "$frag" in "$UNITS"/*) ;; *) die "not a unit under $UNITS";; esac',
29
36
  ' systemctl show "$1" -p TriggeredBy --value | grep -q "\\.timer" || die "not started by a timer"',
30
37
  '}',
38
+ 'agent_only() {',
39
+ ' [ "$(systemctl show "$1" -p User --value)" = "$AGENT" ] || die "that job does not run as $AGENT"',
40
+ ' [ "$(systemctl show "$1" -p PermissionsStartOnly --value)" != yes ] || die "that job runs commands as root"',
41
+ ' [ -n "$(systemctl show "$1" -p ExecStartEx --value)" ] || die "cannot read the commands of that job"',
42
+ ` if systemctl show "$1" ${PRIVILEGED_EXEC.map((p) => `-p ${p}`).join(' ')} --value | grep -Eq "flags=[^;]*(privileged|no-setuid|ambient)"; then die "that job runs commands as root"; fi`,
43
+ '}',
31
44
  'dropin_ok() {',
32
- ' case "$1" in 10-metro-agent.conf|11-metro-vault.conf) ;; *) die "bad drop-in name";; esac',
45
+ ` [ "$1" = ${VAULT_DROP_IN} ] || die "bad drop-in name"`,
33
46
  ' home=$(getent passwd "$AGENT" | cut -d: -f6)',
34
- ' saw_user=0',
35
- ' while IFS= read -r line; do',
36
- ' case "$line" in',
37
- ' "[Service]"|"") ;;',
38
- ' "User=$AGENT") saw_user=1 ;;',
39
- ' Group=*) printf %s "${line#Group=}" | grep -Eq "^[0-9]+$" || die "bad group" ;;',
40
- ' "WorkingDirectory=$home"|"WorkingDirectory=$home"/*) ;;',
41
- ' Environment=*) ;;',
42
- ' "EnvironmentFile=-$home/.metro/vault.env") ;;',
43
- ' "ExecStart=") ;;',
44
- ' ExecStart=[+!@]*) die "privileged ExecStart refused" ;;',
45
- ' ExecStart=*) ;;',
46
- ' *) die "line not allowed: $line" ;;',
47
- ' esac',
48
- ' done < "$2"',
49
- ' if [ "$1" = 10-metro-agent.conf ] && [ "$saw_user" != 1 ]; then die "the drop-in must set User=$AGENT"; fi',
47
+ ' printf \'[Service]\\nEnvironmentFile=-%s/.metro/vault.env\\n\' "$home" | cmp -s - "$2" || die "only the vault environment file may be added"',
50
48
  '}',
51
49
  );
52
50
 
@@ -112,6 +110,20 @@ export const imdsUnitText = (): string =>
112
110
  '',
113
111
  );
114
112
 
113
+ export const upgradeUnitText = (node: string): string =>
114
+ lines(
115
+ '[Unit]',
116
+ 'Description=Metro: bring the root side to Metro %i, checked against npm and its GitHub provenance',
117
+ 'After=network-online.target',
118
+ 'Wants=network-online.target',
119
+ '',
120
+ '[Service]',
121
+ 'Type=oneshot',
122
+ `ExecStart=/usr/bin/flock ${ROOT_DIR} ${node} ${RELEASE_DIR}/dist/cli.js service root-upgrade %i`,
123
+ 'TimeoutStartSec=900',
124
+ '',
125
+ );
126
+
115
127
  const ACTIONS = lines(
116
128
  'action="${1:-}"; [ "$#" -gt 0 ] && shift',
117
129
  'case "$action" in',
@@ -126,10 +138,10 @@ const ACTIONS = lines(
126
138
  ' uid=$(agent_uid); gid=$(id -g "$AGENT")',
127
139
  ' exec systemd-run --scope --quiet --collect --unit="metro-claude-$(date +%s%N)" -p MemoryMax="$max" -p MemoryHigh="$high" -p OOMPolicy=continue -- setpriv --reuid="$uid" --regid="$gid" --init-groups -- "$@" ;;',
128
140
  ' dropin-write)',
129
- ' service_ok "$1"; tmp=$(mktemp); trap \'rm -f "$tmp"\' EXIT; cat > "$tmp"; dropin_ok "$2" "$tmp"',
141
+ ' service_ok "$1"; agent_only "$1"; tmp=$(mktemp); trap \'rm -f "$tmp"\' EXIT; head -c 4096 > "$tmp"; dropin_ok "$2" "$tmp"',
130
142
  ' mkdir -p "$UNITS/$1.d"; install -m 644 "$tmp" "$UNITS/$1.d/$2" ;;',
131
143
  ' dropin-remove)',
132
- ' service_ok "$1"; case "$2" in 10-metro-agent.conf|11-metro-vault.conf) ;; *) die "bad drop-in name";; esac',
144
+ ` service_ok "$1"; [ "$2" = ${VAULT_DROP_IN} ] || die "bad drop-in name"`,
133
145
  ' rm -f "$UNITS/$1.d/$2" ;;',
134
146
  ' daemon-reload) systemctl daemon-reload ;;',
135
147
  ' start-job)',
@@ -149,6 +161,11 @@ const ACTIONS = lines(
149
161
  ' install -m 600 "$cur" "$backups/crontab-root.$(date +%Y-%m-%dT%H-%M-%S).bak"',
150
162
  ' crontab -u root "$kept"; rm -f "$cur" "$kept" ;;',
151
163
  ' imds-block) imds_block ;;',
164
+ ' upgrade)',
165
+ ' [ "$#" -eq 1 ] || die "usage: upgrade <version>"',
166
+ ' case "$1" in *[!0-9A-Za-z.-]*|"") die "bad version";; esac',
167
+ ' printf %s "$1" | grep -Eqx "[0-9]{1,9}\\.[0-9]{1,9}\\.[0-9]{1,9}(-[0-9A-Za-z]{1,20}(\\.[0-9A-Za-z]{1,20}){0,4})?" || die "bad version"',
168
+ ` systemctl start --no-block "${UPGRADE_UNIT.replace('@.', '@$1.')}" ;;`,
152
169
  ' firewall-on) firewall_on ;;',
153
170
  ' firewall-off) firewall_off ;;',
154
171
  ' trust-ca)',
@@ -1,7 +1,6 @@
1
- import { homedir } from 'node:os';
2
- import { join } from 'node:path';
3
1
  import { log } from '@metro-labs/core/log';
4
- import { rootHelper, type HelperResult } from './privilege.js';
2
+ import { METRO_VERSION } from '@metro-labs/core/version';
3
+ import { rootHelper, rootStepHint, type HelperResult } from './privilege.js';
5
4
 
6
5
  export function blockImds(run: (args: string[]) => HelperResult = rootHelper): boolean {
7
6
  const result = run(['imds-block']);
@@ -10,7 +9,7 @@ export function blockImds(run: (args: string[]) => HelperResult = rootHelper): b
10
9
  return true;
11
10
  }
12
11
  log.warn(
13
- { err: result.stderr.trim(), fix: `as root: ${join(homedir(), '.npm-global', 'bin', 'metro')} service install --user metro` },
12
+ { err: result.stderr.trim(), fix: rootStepHint(METRO_VERSION) },
14
13
  'imds: the agent can still read the instance metadata (setup data, instance role), since the root helper on this box predates the guard',
15
14
  );
16
15
  return false;
@@ -2,7 +2,17 @@ import { spawnSync } from 'node:child_process';
2
2
  import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
- import { HELPER_PATH, helperScript, IMDS_UNIT, IMDS_UNIT_PATH, imdsUnitText, SUDOERS_PATH, sudoersText } from './helper-script.js';
5
+ import {
6
+ HELPER_PATH,
7
+ helperScript,
8
+ IMDS_UNIT,
9
+ IMDS_UNIT_PATH,
10
+ imdsUnitText,
11
+ SUDOERS_PATH,
12
+ sudoersText,
13
+ UPGRADE_UNIT_PATH,
14
+ upgradeUnitText,
15
+ } from './helper-script.js';
6
16
  import { AGENT_NAME } from '../agent-user/user.js';
7
17
 
8
18
  interface InstallPaths {
@@ -41,8 +51,28 @@ export function installImdsGuard(unit: string = IMDS_UNIT_PATH, run: Run = check
41
51
  run('systemctl', ['restart', IMDS_UNIT]);
42
52
  }
43
53
 
54
+ const PLAIN_PATH = /^\/[A-Za-z0-9_./+-]+$/;
55
+
56
+ export function installUpgradeUnit(node: string, unit: string = UPGRADE_UNIT_PATH, run: Run = checked): void {
57
+ if (!PLAIN_PATH.test(node)) throw new Error(`the root upgrade needs node by a plain absolute path, not '${node}'`);
58
+ writeFileSync(unit, upgradeUnitText(node), { mode: 0o644 });
59
+ chmodSync(unit, 0o644);
60
+ run('systemctl', ['daemon-reload']);
61
+ }
62
+
63
+ export function nodeArg(argv: string[]): string {
64
+ const at = argv.indexOf('--node');
65
+ const node = at < 0 ? undefined : argv[at + 1];
66
+ if (node === undefined) throw new Error('usage: install.ts --node <absolute path of the node that runs the root upgrade>');
67
+ return node;
68
+ }
69
+
44
70
  if (import.meta.main) {
71
+ const node = nodeArg(process.argv.slice(2));
45
72
  installRootHelper();
46
73
  installImdsGuard();
47
- process.stdout.write(`metro: wrote ${HELPER_PATH}, ${SUDOERS_PATH} and ${IMDS_UNIT_PATH}; only root and metro reach the instance metadata service\n`);
74
+ installUpgradeUnit(node);
75
+ process.stdout.write(
76
+ `metro: wrote ${HELPER_PATH}, ${SUDOERS_PATH}, ${IMDS_UNIT_PATH} and ${UPGRADE_UNIT_PATH}; only root and metro reach the instance metadata service, and the root side follows every update\n`,
77
+ );
48
78
  }
@@ -46,3 +46,5 @@ export function mustHelper(args: string[], input?: string): string {
46
46
  }
47
47
 
48
48
  export const helperArgv = (args: string[]): [string, string[]] => ['sudo', ['-n', HELPER_PATH, ...args]];
49
+
50
+ export const rootStepHint = (version: string): string => `as root, once: npx -y @stage-labs/metro@${version} service install --user metro`;
@@ -0,0 +1,16 @@
1
+ import { log } from '@metro-labs/core/log';
2
+ import { METRO_VERSION } from '@metro-labs/core/version';
3
+ import { rootHelper, rootStepHint, type HelperResult } from './privilege.js';
4
+
5
+ export function followRelease(run: (args: string[]) => HelperResult = rootHelper, version: string = METRO_VERSION): boolean {
6
+ const result = run(['upgrade', version]);
7
+ if (result.status === 0) {
8
+ log.info({ version, journal: `journalctl -u metro-root-upgrade@${version}` }, 'root: asked the root side to follow this version');
9
+ return true;
10
+ }
11
+ log.warn(
12
+ { err: result.stderr.trim(), fix: rootStepHint(version) },
13
+ 'root: the root side cannot follow updates by itself yet, since the root helper on this box predates it',
14
+ );
15
+ return false;
16
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.246"
2
+ "version": "0.1.0-beta.247"
3
3
  }