@myvillage/cli 1.87.0 → 1.88.1
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/package.json +1 -1
- package/src/agent-runtime/runner.js +116 -23
package/package.json
CHANGED
|
@@ -8,10 +8,15 @@
|
|
|
8
8
|
* acquires the lease, and stops+releases anything it holds that is no longer
|
|
9
9
|
* assigned-and-running here.
|
|
10
10
|
*
|
|
11
|
-
* Auth model: the runner authenticates
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* Auth model: the runner authenticates as a machine with a `machine:runner` API
|
|
12
|
+
* key sent as `x-api-key`. That key is normally provisioned automatically — on
|
|
13
|
+
* first start the runner registers using the signed-in user's OAuth token and
|
|
14
|
+
* the control plane mints + returns a machine-scoped credential, which is saved
|
|
15
|
+
* to ~/.myvillage/runner.json (0600) and reused thereafter. So a machine that
|
|
16
|
+
* has run `myvillage login` (or Studio) never needs a hand-made key; supplying
|
|
17
|
+
* MYVILLAGE_MACHINE_KEY / --key stays supported for unattended provisioning.
|
|
18
|
+
* Spawned agent daemons still use the owner's OAuth token from
|
|
19
|
+
* ~/.myvillage/credentials.json.
|
|
15
20
|
*/
|
|
16
21
|
import os from 'os';
|
|
17
22
|
import fs from 'fs';
|
|
@@ -45,7 +50,9 @@ function collectMetadata() {
|
|
|
45
50
|
function persistRunnerState(state) {
|
|
46
51
|
try {
|
|
47
52
|
fs.mkdirSync(path.dirname(RUNNER_STATE_FILE), { recursive: true });
|
|
48
|
-
|
|
53
|
+
// 0600: the file holds this machine's credential.
|
|
54
|
+
fs.writeFileSync(RUNNER_STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
55
|
+
try { fs.chmodSync(RUNNER_STATE_FILE, 0o600); } catch { /* best-effort */ }
|
|
49
56
|
} catch { /* best-effort */ }
|
|
50
57
|
}
|
|
51
58
|
|
|
@@ -64,18 +71,36 @@ function makeClient(machineKey, machineId) {
|
|
|
64
71
|
return async function cp(pathname, { method = 'GET', body } = {}) {
|
|
65
72
|
const headers = { 'Content-Type': 'application/json', 'x-api-key': machineKey };
|
|
66
73
|
if (machineId) headers['X-Machine-Id'] = machineId;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
return request(networkBaseUrl, pathname, method, body, headers);
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* One-shot client authenticated as the signed-in *user* (OAuth bearer). Used
|
|
80
|
+
* only to register a machine and receive its minted credential.
|
|
81
|
+
*/
|
|
82
|
+
function makeUserClient() {
|
|
83
|
+
const { networkBaseUrl } = getConfig();
|
|
84
|
+
const token = getAccessToken();
|
|
85
|
+
if (!token) return null;
|
|
86
|
+
return async function cp(pathname, { method = 'GET', body } = {}) {
|
|
87
|
+
const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` };
|
|
88
|
+
return request(networkBaseUrl, pathname, method, body, headers);
|
|
76
89
|
};
|
|
77
90
|
}
|
|
78
91
|
|
|
92
|
+
async function request(networkBaseUrl, pathname, method, body, headers) {
|
|
93
|
+
const res = await fetch(`${networkBaseUrl}${pathname}`, {
|
|
94
|
+
method,
|
|
95
|
+
headers,
|
|
96
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
97
|
+
});
|
|
98
|
+
const text = await res.text();
|
|
99
|
+
let json;
|
|
100
|
+
try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
|
|
101
|
+
return { status: res.status, ok: res.ok, json };
|
|
102
|
+
}
|
|
103
|
+
|
|
79
104
|
// ── Local agent indexing ─────────────────────────────────────────────────────
|
|
80
105
|
|
|
81
106
|
// Map control-plane agentProfileId -> local agent { name, config } via the
|
|
@@ -118,7 +143,57 @@ async function register(cp, opts) {
|
|
|
118
143
|
};
|
|
119
144
|
const { ok, status, json } = await cp('/machines', { method: 'POST', body });
|
|
120
145
|
if (!ok) throw new Error(`register failed (${status}): ${JSON.stringify(json)}`);
|
|
121
|
-
|
|
146
|
+
// machineKey is present only on the user-auth path (minted once, server-side).
|
|
147
|
+
return { machineId: json.data.id, machineKey: json.data.machineKey ?? null };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolve this machine's credential without asking a human for one:
|
|
152
|
+
* 1. an explicit --key / MYVILLAGE_MACHINE_KEY (unattended provisioning)
|
|
153
|
+
* 2. the credential saved on a previous start (~/.myvillage/runner.json)
|
|
154
|
+
* 3. otherwise register as the signed-in user; the control plane mints one
|
|
155
|
+
*
|
|
156
|
+
* Returns { machineId, machineKey }.
|
|
157
|
+
*/
|
|
158
|
+
async function resolveMachineIdentity(opts) {
|
|
159
|
+
const explicitKey = opts.machineKey || process.env.MYVILLAGE_MACHINE_KEY || null;
|
|
160
|
+
const saved = readRunnerState();
|
|
161
|
+
|
|
162
|
+
if (explicitKey) {
|
|
163
|
+
const { machineId } = await register(makeClient(explicitKey, null), opts);
|
|
164
|
+
persistRunnerState({
|
|
165
|
+
machineId,
|
|
166
|
+
machineKey: explicitKey,
|
|
167
|
+
name: opts.name || os.hostname(),
|
|
168
|
+
registeredAt: new Date().toISOString(),
|
|
169
|
+
});
|
|
170
|
+
return { machineId, machineKey: explicitKey };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (saved?.machineKey) {
|
|
174
|
+
// Re-register with the stored credential so name/metadata/version refresh.
|
|
175
|
+
const { machineId } = await register(makeClient(saved.machineKey, null), opts);
|
|
176
|
+
persistRunnerState({ ...saved, machineId, name: opts.name || saved.name, registeredAt: new Date().toISOString() });
|
|
177
|
+
return { machineId, machineKey: saved.machineKey };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const userClient = makeUserClient();
|
|
181
|
+
if (!userClient) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
'Not signed in. Run `myvillage login` on this machine (or pass --key with a machine:runner API key).',
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const { machineId, machineKey } = await register(userClient, opts);
|
|
187
|
+
if (!machineKey) {
|
|
188
|
+
throw new Error('The control plane did not return a machine credential. Update the backend and retry.');
|
|
189
|
+
}
|
|
190
|
+
persistRunnerState({
|
|
191
|
+
machineId,
|
|
192
|
+
machineKey,
|
|
193
|
+
name: opts.name || os.hostname(),
|
|
194
|
+
registeredAt: new Date().toISOString(),
|
|
195
|
+
});
|
|
196
|
+
return { machineId, machineKey };
|
|
122
197
|
}
|
|
123
198
|
|
|
124
199
|
async function reconcileOnce(ctx) {
|
|
@@ -176,6 +251,21 @@ async function reconcileOnce(ctx) {
|
|
|
176
251
|
continue;
|
|
177
252
|
}
|
|
178
253
|
const epoch = lease.json.data.leaseEpoch;
|
|
254
|
+
|
|
255
|
+
// Never fork a second daemon on top of a live one. An agent started outside
|
|
256
|
+
// the fleet (Studio's local Start, or a previous CLI `agent start`) has no
|
|
257
|
+
// lease, so it reaches here with a fresh lease and an already-running
|
|
258
|
+
// process; forking again would double-run it and orphan the first daemon
|
|
259
|
+
// (the pid file is last-writer-wins). Adopt it instead.
|
|
260
|
+
if (isDaemonRunning(entry.name)) {
|
|
261
|
+
ctx.leases.set(a.agentProfileId, { name: entry.name, epoch });
|
|
262
|
+
log(
|
|
263
|
+
`adopted already-running ${entry.name} under lease (epoch ${epoch}) — started outside the fleet, so it ` +
|
|
264
|
+
`reports no fence; stop/start it from Studio to relaunch it fenced`,
|
|
265
|
+
);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
179
269
|
const pid = startAgent(entry.name, entry.config, ctx.machineId, epoch);
|
|
180
270
|
ctx.leases.set(a.agentProfileId, { name: entry.name, epoch });
|
|
181
271
|
log(`started ${entry.name} (pid ${pid}, epoch ${epoch})`);
|
|
@@ -199,17 +289,20 @@ function sleep(ms, signal) {
|
|
|
199
289
|
}
|
|
200
290
|
|
|
201
291
|
export async function runRunner(opts = {}) {
|
|
202
|
-
const machineKey = opts.machineKey || process.env.MYVILLAGE_MACHINE_KEY;
|
|
203
|
-
if (!machineKey) {
|
|
204
|
-
throw new Error('MYVILLAGE_MACHINE_KEY is required (a machine:runner API key). Set the env var or pass --key.');
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const cpForRegister = makeClient(machineKey, null);
|
|
208
292
|
log(`registering machine "${opts.name || os.hostname()}" (${process.platform}/${process.arch})…`);
|
|
209
|
-
const machineId = await
|
|
210
|
-
persistRunnerState({ machineId, name: opts.name || os.hostname(), registeredAt: new Date().toISOString() });
|
|
293
|
+
const { machineId, machineKey } = await resolveMachineIdentity(opts);
|
|
211
294
|
log(`registered as machine ${machineId}`);
|
|
212
295
|
|
|
296
|
+
// Record our pid so Studio (or `runner status`) can tell whether this
|
|
297
|
+
// machine's runner is alive, and stop it. Cleared on exit.
|
|
298
|
+
const markRunning = (pid) => {
|
|
299
|
+
const s = readRunnerState() || {};
|
|
300
|
+
persistRunnerState({ ...s, pid, startedAt: pid ? new Date().toISOString() : undefined });
|
|
301
|
+
};
|
|
302
|
+
markRunning(process.pid);
|
|
303
|
+
const clearPid = () => { try { markRunning(null); } catch { /* best-effort */ } };
|
|
304
|
+
process.on('exit', clearPid);
|
|
305
|
+
|
|
213
306
|
const ctx = {
|
|
214
307
|
cp: makeClient(machineKey, machineId),
|
|
215
308
|
machineId,
|