@stage-labs/metro 0.1.0-beta.246 → 0.1.0-beta.248
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/dist/background.js +1 -1
- package/dist/root-release-io.js +161 -0
- package/dist/root-release.js +74 -0
- package/dist/service.js +37 -18
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/STAGE.md +15 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-tools.ts +14 -9
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +20 -3
- package/runtime/node_modules/@metro-labs/daemon/src/metro-user/helper-script.ts +39 -22
- package/runtime/node_modules/@metro-labs/daemon/src/metro-user/imds.ts +3 -4
- package/runtime/node_modules/@metro-labs/daemon/src/metro-user/install.ts +32 -2
- package/runtime/node_modules/@metro-labs/daemon/src/metro-user/privilege.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/metro-user/root-follow.ts +16 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +15 -5
- package/runtime/node_modules/@metro-labs/xmtp/src/codecs.ts +18 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/frames.ts +3 -3
- package/runtime/node_modules/@metro-labs/xmtp/src/wallet.ts +28 -0
- package/runtime/runtime.json +1 -1
package/dist/background.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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: [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
deps
|
|
201
|
-
deps.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
deps.
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.248",
|
|
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",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: stage
|
|
3
|
-
description: How to use Stage channels (XMTP groups, https://stage.box) through metro. Create a channel, rename it, add or remove members, set labels and assignees, read its info, and send, reply, react, unreact, delete and read messages, and post frames (interactive ChatKit widget views). Works only when this agent has an XMTP account in metro.
|
|
3
|
+
description: How to use Stage channels (XMTP groups, https://stage.box) through metro. Create a channel, rename it, add or remove members, set labels and assignees, read its info, and send, reply, react, unreact, delete and read messages, and post frames (interactive ChatKit widget views) and wallet cards (payment and signature requests, receipts). Works only when this agent has an XMTP account in metro.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Stage (https://stage.box) is a messenger built on XMTP. A Stage channel is an XMTP group. You use it with the metro tools below (`mcp__metro__<name>`).
|
|
@@ -73,3 +73,17 @@ Move it from Backlog or To-do to In progress, then In review, then Done. Use Blo
|
|
|
73
73
|
]}}
|
|
74
74
|
}}}
|
|
75
75
|
```
|
|
76
|
+
|
|
77
|
+
**Wallet cards.** The payment and signature cards Stage's own Request payment and Request signature send. Sending one moves nothing and signs nothing: the person taps Pay or Sign and confirms in Stage. Send one only when you were asked to.
|
|
78
|
+
- Send: `send {"line": "…", "wallet": {"type": "<type>", "content": {…}}}`. With `text` or a `frame` as well, the card goes last, and the `message_id` you get back is the card's.
|
|
79
|
+
- Payment request, `walletSendCalls`, a card with a Pay button. Stage pays a request with one call; a Stage wallet pays on Base only.
|
|
80
|
+
```json
|
|
81
|
+
{"type": "walletSendCalls", "content": {"version": "1.0", "chainId": "0x2105", "from": "0x<your address>", "calls": [
|
|
82
|
+
{"to": "0x<who gets paid>", "value": "0x38d7ea4c68000", "metadata": {"description": "Dinner", "transactionType": "transfer",
|
|
83
|
+
"currency": "ETH", "amount": 0.001, "decimals": 18, "toAddress": "0x<who gets paid>"}}
|
|
84
|
+
]}}
|
|
85
|
+
```
|
|
86
|
+
`chainId` is a hex string (`0x2105` is Base) and `value` is in wei, hex. For a token, `to` is the token contract, `data` its `transfer(address,uint256)` call data, `value` is left out, and `metadata` has `currency`, `amount` and `toAddress` (who gets paid).
|
|
87
|
+
- Receipt, `transactionReference`: `{"networkId": 8453, "reference": "0x<tx hash>", "metadata": {"currency": "USDC", "amount": 5}}`. Stage shows "Payment sent · 5 USDC" (or "Transaction sent" without an amount) and a link to the explorer. Only post a real transaction hash.
|
|
88
|
+
- Signature request, `signatureRequest`, a card with a Sign button: `{"id": "<any id>", "kind": "personal", "message": "The text to sign", "description": "Why"}`, or `"kind": "eip712"` with `"eip712": {"domain", "types", "primaryType", "message"}` instead of `message`.
|
|
89
|
+
- Signature, `signatureReference`: `{"requestId": "<message_id of the request>", "signature": "0x…", "signer": "0x…"}`. Stage shows it as Signed.
|
|
@@ -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
|
}
|
|
@@ -166,6 +166,7 @@ const PRIOR_STAGE: ReadonlySet<string> = new Set([
|
|
|
166
166
|
'e3b8f9bbdb205311bc643aaf91d9d903a91204fd5b55ca949b85cdfa6fd00136',
|
|
167
167
|
'08a30f5cd5cf8013ab70c1fa9e1109018c0337552296a8efe5c0cf543b284991',
|
|
168
168
|
'f87e9494ea6151d140b742c1b88b4f435ae653559be641fddc2160cd24dd4e1b',
|
|
169
|
+
'cb4307b016855c1a09d48bc8b3924a3080bd78b205963df3d6f20401d0486df9',
|
|
169
170
|
]);
|
|
170
171
|
const PRIOR_MEMORY: ReadonlySet<string> = new Set(['08795399635c8cdec2b50cb4e336f89ae880f3997ae52560ef647a9cdc8f1b08']);
|
|
171
172
|
|
|
@@ -60,16 +60,20 @@ function assertDelivered(
|
|
|
60
60
|
);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
const
|
|
64
|
-
|
|
63
|
+
const STAGE_PARTS = ['frame', 'wallet'] as const;
|
|
64
|
+
|
|
65
|
+
const stagePartsIn = (m: MessageArgs): string[] => STAGE_PARTS.filter((key) => m.a[key] !== undefined);
|
|
66
|
+
|
|
67
|
+
const stagePartArgs = (m: MessageArgs): Record<string, unknown> =>
|
|
68
|
+
Object.fromEntries(stagePartsIn(m).map((key) => [key, m.a[key]]));
|
|
65
69
|
|
|
66
70
|
const sentLabels = (m: MessageArgs, text: string | undefined): string[] => [
|
|
67
71
|
...(text ? ['text'] : []),
|
|
68
|
-
...(m
|
|
72
|
+
...stagePartsIn(m),
|
|
69
73
|
];
|
|
70
74
|
|
|
71
75
|
function nativeBody(m: MessageArgs, text: string | undefined, replyTo: string | undefined): Record<string, unknown> {
|
|
72
|
-
const body: Record<string, unknown> = { line: m.line, ...
|
|
76
|
+
const body: Record<string, unknown> = { line: m.line, ...stagePartArgs(m) };
|
|
73
77
|
if (text) body.text = text;
|
|
74
78
|
if (text && replyTo) body.replyTo = replyTo;
|
|
75
79
|
return body;
|
|
@@ -101,7 +105,7 @@ async function sendForwarded(
|
|
|
101
105
|
atts: ResolvedAttachment[],
|
|
102
106
|
): Promise<Sent> {
|
|
103
107
|
const { line, ctx, station } = m;
|
|
104
|
-
const args: Record<string, unknown> = { line, ...
|
|
108
|
+
const args: Record<string, unknown> = { line, ...stagePartArgs(m) };
|
|
105
109
|
if (text) args.text = text;
|
|
106
110
|
if (typeof m.a.subject === 'string' && m.a.subject.trim() !== '') args.subject = m.a.subject;
|
|
107
111
|
if (replyTo) args.replyTo = replyTo;
|
|
@@ -123,12 +127,13 @@ const unsupported = (station: Station, atts: CanonicalAttachment[]): string =>
|
|
|
123
127
|
.map((a) => kindOf(a.mime ?? '', a.path ?? a.url ?? a.name ?? ''))
|
|
124
128
|
.join(', ')}); send a link in \`text\` instead`;
|
|
125
129
|
|
|
126
|
-
const NOTHING_TO_SEND = 'send requires `text`, `attachments` or `
|
|
130
|
+
const NOTHING_TO_SEND = 'send requires `text`, `attachments`, `frame` or `wallet`';
|
|
127
131
|
|
|
128
132
|
function refusal(m: MessageArgs, text: string | undefined, requested: CanonicalAttachment[]): string | undefined {
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
|
|
133
|
+
const stageOnly = stagePartsIn(m);
|
|
134
|
+
if (!text && !requested.length && !stageOnly.length) return NOTHING_TO_SEND;
|
|
135
|
+
if (stageOnly.length && m.station.sendsFrames !== true)
|
|
136
|
+
return `${m.station.name} cannot send \`${stageOnly.join('` or `')}\`; that is Stage (XMTP) only`;
|
|
132
137
|
if (requested.length && m.station.attachmentMode === 'none') return unsupported(m.station, requested);
|
|
133
138
|
return undefined;
|
|
134
139
|
}
|
|
@@ -45,7 +45,7 @@ function argText(value: unknown): string | undefined {
|
|
|
45
45
|
|
|
46
46
|
function fieldText(key: string, value: unknown): string | undefined {
|
|
47
47
|
if (key === 'attachments' && Array.isArray(value)) return `${String(value.length)} file(s)`;
|
|
48
|
-
return key === 'frame' ? argText(value) : valueText(value);
|
|
48
|
+
return key === 'frame' || key === 'wallet' ? argText(value) : valueText(value);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function metroLines(tool: string, input: Record<string, unknown>): string[] {
|
|
@@ -34,7 +34,7 @@ export const COMMON_TOOLS: ToolDef[] = [
|
|
|
34
34
|
group: 'write',
|
|
35
35
|
description:
|
|
36
36
|
'Send a message (and/or media) to a Metro conversation. Args: line, text?, reply_to?, ' +
|
|
37
|
-
'subject?, attachments?, frame?. The station is derived from the line. Outlook and Gmail: send to ' +
|
|
37
|
+
'subject?, attachments?, frame?, wallet?. The station is derived from the line. Outlook and Gmail: send to ' +
|
|
38
38
|
'metro://<outlook|gmail>/<account>/<email address> to start a new email (subject defaults to the ' +
|
|
39
39
|
'first line of text); the result names the new thread line. Each attachment names EXACTLY ONE ' +
|
|
40
40
|
'source, and the choice matters: `upload` (an `upload_id` from `create_upload`, the ' +
|
|
@@ -42,8 +42,8 @@ export const COMMON_TOOLS: ToolDef[] = [
|
|
|
42
42
|
'`data` (base64 inline -- TINY FILES ONLY, a few KB, because the base64 has to be ' +
|
|
43
43
|
'written out verbatim in the call and longer than that it corrupts); `url` (the daemon ' +
|
|
44
44
|
'fetches it, so the file must already be public); `path` (resolved on the DAEMON host, ' +
|
|
45
|
-
'which is not your machine). Stage (XMTP) only: `frame` posts an interactive view
|
|
46
|
-
'of text/attachments/frame is required. ' +
|
|
45
|
+
'which is not your machine). Stage (XMTP) only: `frame` posts an interactive view, `wallet` a payment ' +
|
|
46
|
+
'or signature card. At least one of text/attachments/frame/wallet is required. ' +
|
|
47
47
|
'The success line names each attachment the station actually delivered; a station that ' +
|
|
48
48
|
'cannot carry a file errors instead of reporting success.',
|
|
49
49
|
inputSchema: {
|
|
@@ -99,6 +99,23 @@ export const COMMON_TOOLS: ToolDef[] = [
|
|
|
99
99
|
description: { type: 'string', description: 'Card description in the chat (1000 characters at most).' },
|
|
100
100
|
},
|
|
101
101
|
},
|
|
102
|
+
wallet: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
description:
|
|
105
|
+
'Stage (XMTP) only: a wallet card. `type` walletSendCalls is a payment request with a Pay button, ' +
|
|
106
|
+
'transactionReference a payment receipt with an explorer link, signatureRequest a Sign button, ' +
|
|
107
|
+
'signatureReference a signed proof. `content` is that type\'s JSON as Stage reads it; the stage skill ' +
|
|
108
|
+
'has an example of each. Sending it moves nothing: the person taps Pay or Sign and confirms in Stage. ' +
|
|
109
|
+
"It is sent after the text and any frame, and the message_id returned is the card's.",
|
|
110
|
+
properties: {
|
|
111
|
+
type: {
|
|
112
|
+
type: 'string',
|
|
113
|
+
enum: ['walletSendCalls', 'transactionReference', 'signatureRequest', 'signatureReference'],
|
|
114
|
+
},
|
|
115
|
+
content: { type: 'object', description: 'The JSON of that content type.', additionalProperties: true },
|
|
116
|
+
},
|
|
117
|
+
required: ['type', 'content'],
|
|
118
|
+
},
|
|
102
119
|
},
|
|
103
120
|
required: ['line'],
|
|
104
121
|
},
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
const
|
|
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
|
-
'
|
|
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
|
-
|
|
45
|
+
` [ "$1" = ${VAULT_DROP_IN} ] || die "bad drop-in name"`,
|
|
33
46
|
' home=$(getent passwd "$AGENT" | cut -d: -f6)',
|
|
34
|
-
'
|
|
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;
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -9,11 +9,13 @@ import {
|
|
|
9
9
|
AttachmentCodec,
|
|
10
10
|
type Attachment,
|
|
11
11
|
} from '@xmtp/content-type-remote-attachment';
|
|
12
|
+
import type { EncodedContent } from '@xmtp/content-type-primitives';
|
|
12
13
|
import { convOf, type Account } from './accounts.js';
|
|
13
14
|
import { resolveMsgId } from './wire.js';
|
|
14
15
|
import { emitOutbound } from './emit.js';
|
|
15
16
|
import { CallSignalCodec, FrameCodec, PollCodec, buildPollContent, encodeDeleteMessage } from './codecs.js';
|
|
16
17
|
import { buildFrameContent } from './frames.js';
|
|
18
|
+
import { buildWalletContent } from './wallet.js';
|
|
17
19
|
import { callLeftovers } from './call-leftovers.js';
|
|
18
20
|
import { convHandlers } from './actions-conv.js';
|
|
19
21
|
import { messagingAliases } from '@metro-labs/core/stations/messaging-normalize';
|
|
@@ -30,19 +32,27 @@ const noConv = (line: string): TrainError =>
|
|
|
30
32
|
const badArgs = (message: string): TrainError =>
|
|
31
33
|
new TrainError('INVALID_ARGS', message);
|
|
32
34
|
|
|
35
|
+
function framePart(raw: unknown): { encoded: EncodedContent; summary: string } {
|
|
36
|
+
const built = buildFrameContent(raw);
|
|
37
|
+
return { encoded: new FrameCodec().encode(built.frame), summary: `Frame: ${built.title}` };
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
async function send(id: string, args: Args): Promise<void> {
|
|
34
41
|
const { line, text } = args as { line: string; text: string };
|
|
35
|
-
const
|
|
42
|
+
const parts = [
|
|
43
|
+
...(args.frame === undefined ? [] : [framePart(args.frame)]),
|
|
44
|
+
...(args.wallet === undefined ? [] : [buildWalletContent(args.wallet)]),
|
|
45
|
+
];
|
|
36
46
|
const { acct, conv } = await convOf(line);
|
|
37
47
|
if (!conv) throw noConv(line);
|
|
38
48
|
let messageId: string | undefined;
|
|
39
|
-
if (text ||
|
|
49
|
+
if (text || parts.length === 0) {
|
|
40
50
|
messageId = await conv.sendText(text);
|
|
41
51
|
emitOutbound(acct.cfg.id, line, messageId, text);
|
|
42
52
|
}
|
|
43
|
-
|
|
44
|
-
messageId = await conv.send(
|
|
45
|
-
emitOutbound(acct.cfg.id, line, messageId,
|
|
53
|
+
for (const part of parts) {
|
|
54
|
+
messageId = await conv.send(part.encoded);
|
|
55
|
+
emitOutbound(acct.cfg.id, line, messageId, part.summary);
|
|
46
56
|
}
|
|
47
57
|
respond(id, { result: { messageId } });
|
|
48
58
|
}
|
|
@@ -166,6 +166,24 @@ export const SignatureReferenceCodec =
|
|
|
166
166
|
c.signature ? `[Signature] ${c.signature}` : '[Signature]',
|
|
167
167
|
);
|
|
168
168
|
|
|
169
|
+
const xmtpOrg = (typeId: string): ContentTypeId => ({
|
|
170
|
+
authorityId: 'xmtp.org',
|
|
171
|
+
typeId,
|
|
172
|
+
versionMajor: 1,
|
|
173
|
+
versionMinor: 0,
|
|
174
|
+
});
|
|
175
|
+
export const WalletSendCallsCodec = makeJsonCodec<{ calls?: { metadata?: { description?: string } }[] }>(
|
|
176
|
+
xmtpOrg('walletSendCalls'),
|
|
177
|
+
(c) => {
|
|
178
|
+
const desc = c.calls?.[0]?.metadata?.description;
|
|
179
|
+
return desc ? `[Transaction request] ${desc}` : '[Transaction request]';
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
export const TransactionReferenceCodec = makeJsonCodec<{ reference?: string }>(
|
|
183
|
+
xmtpOrg('transactionReference'),
|
|
184
|
+
(c) => (c.reference ? `[Transaction] ${c.reference}` : '[Transaction]'),
|
|
185
|
+
);
|
|
186
|
+
|
|
169
187
|
export const ContentTypeFrame: ContentTypeId = {
|
|
170
188
|
authorityId: 'stage.box',
|
|
171
189
|
typeId: 'frame',
|
|
@@ -12,10 +12,10 @@ const TEXT_TYPES = new Set(['Text', 'Caption', 'Markdown']);
|
|
|
12
12
|
|
|
13
13
|
type Node = Record<string, unknown>;
|
|
14
14
|
|
|
15
|
-
const isNode = (v: unknown): v is Node =>
|
|
15
|
+
export const isNode = (v: unknown): v is Node =>
|
|
16
16
|
typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
17
17
|
|
|
18
|
-
const bad = (message: string): TrainError => new TrainError('INVALID_ARGS', message);
|
|
18
|
+
export const bad = (message: string): TrainError => new TrainError('INVALID_ARGS', message);
|
|
19
19
|
|
|
20
20
|
function plain(value: unknown): string {
|
|
21
21
|
if (typeof value !== 'string') return '';
|
|
@@ -56,7 +56,7 @@ function clip(value: unknown, max: number, name: string): string | undefined {
|
|
|
56
56
|
return out === '' ? undefined : out;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
function parseJson(raw: unknown, name: string): unknown {
|
|
59
|
+
export function parseJson(raw: unknown, name: string): unknown {
|
|
60
60
|
if (typeof raw !== 'string') return raw;
|
|
61
61
|
try {
|
|
62
62
|
return JSON.parse(raw);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { EncodedContent } from '@xmtp/content-type-primitives';
|
|
2
|
+
import {
|
|
3
|
+
SignatureReferenceCodec,
|
|
4
|
+
SignatureRequestCodec,
|
|
5
|
+
TransactionReferenceCodec,
|
|
6
|
+
WalletSendCallsCodec,
|
|
7
|
+
} from './codecs.js';
|
|
8
|
+
import { bad, isNode, parseJson } from './frames.js';
|
|
9
|
+
|
|
10
|
+
const WALLET_TYPES: Record<string, { codec: { encode(c: Record<string, unknown>): EncodedContent }; needs: string[] }> = {
|
|
11
|
+
walletSendCalls: { codec: new WalletSendCallsCodec(), needs: ['version', 'chainId', 'from', 'calls'] },
|
|
12
|
+
transactionReference: { codec: new TransactionReferenceCodec(), needs: ['networkId', 'reference'] },
|
|
13
|
+
signatureRequest: { codec: new SignatureRequestCodec(), needs: ['id', 'kind'] },
|
|
14
|
+
signatureReference: { codec: new SignatureReferenceCodec(), needs: ['requestId', 'signature', 'signer'] },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function buildWalletContent(raw: unknown): { encoded: EncodedContent; summary: string } {
|
|
18
|
+
const args = parseJson(raw, 'wallet');
|
|
19
|
+
const type = isNode(args) && typeof args.type === 'string' ? args.type : '';
|
|
20
|
+
const spec = Object.hasOwn(WALLET_TYPES, type) ? WALLET_TYPES[type] : undefined;
|
|
21
|
+
if (!isNode(args) || !spec) throw bad(`wallet type must be one of ${Object.keys(WALLET_TYPES).join(', ')}`);
|
|
22
|
+
const content = parseJson(args.content, 'wallet content');
|
|
23
|
+
if (!isNode(content)) throw bad(`wallet content must be the ${type} JSON object`);
|
|
24
|
+
const missing = spec.needs.filter((key) => content[key] === undefined);
|
|
25
|
+
if (missing.length > 0) throw bad(`${type} content needs ${missing.join(', ')}`);
|
|
26
|
+
const encoded = spec.codec.encode(content);
|
|
27
|
+
return { encoded, summary: encoded.fallback ?? type };
|
|
28
|
+
}
|
package/runtime/runtime.json
CHANGED