@myvillage/cli 1.89.1 → 1.89.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.
- package/package.json +1 -1
- package/src/agent-runtime/loop.js +26 -1
- package/src/agent-runtime/runner.js +28 -7
- package/src/commands/agent-local.js +22 -0
- package/src/utils/auth.js +9 -1
package/package.json
CHANGED
|
@@ -121,6 +121,19 @@ export async function agentLoop(agentName, { signal }) {
|
|
|
121
121
|
// max_tokens_per_loop and the rate limiter regardless of this ceiling.
|
|
122
122
|
const maxSteps = config.brain?.max_steps_per_loop || 25;
|
|
123
123
|
|
|
124
|
+
// Refresh BEFORE the first MCP connect. Whoever spawned us put a snapshot of
|
|
125
|
+
// MYVILLAGE_ACCESS_TOKEN in our env, and the MCP client bakes it into its
|
|
126
|
+
// Authorization header at construction with no refresh path of its own. The
|
|
127
|
+
// per-iteration self-heal below only runs once we're already in the loop, so
|
|
128
|
+
// without this the very first connect uses a possibly-dead token and comes up
|
|
129
|
+
// with zero tools. Doing it here covers every start path (fleet runner,
|
|
130
|
+
// Studio's local Start, `myvillage agent start`) in one place.
|
|
131
|
+
try {
|
|
132
|
+
if (isTokenExpired()) await refreshAccessToken();
|
|
133
|
+
const startupToken = getAccessToken();
|
|
134
|
+
if (startupToken) process.env.MYVILLAGE_ACCESS_TOKEN = startupToken;
|
|
135
|
+
} catch { /* fall through with whatever we were handed */ }
|
|
136
|
+
|
|
124
137
|
// Initialize MCP tools
|
|
125
138
|
let tools;
|
|
126
139
|
try {
|
|
@@ -183,8 +196,20 @@ export async function agentLoop(agentName, { signal }) {
|
|
|
183
196
|
// 401s until the daemon is restarted. Re-read credentials each loop and
|
|
184
197
|
// swap the MCP client when the token changes.
|
|
185
198
|
try {
|
|
199
|
+
// refreshAccessToken() RETURNS false on failure, it does not throw, so the
|
|
200
|
+
// catch below never fires for a dead refresh token. Check the return value
|
|
201
|
+
// or the whole failure is silent: no mcp_reconnected, no error, just an
|
|
202
|
+
// agent that 401s on every call for the rest of its life.
|
|
186
203
|
if (isTokenExpired()) {
|
|
187
|
-
await refreshAccessToken();
|
|
204
|
+
const refreshed = await refreshAccessToken();
|
|
205
|
+
if (!refreshed) {
|
|
206
|
+
logActivity(agentDir, {
|
|
207
|
+
type: 'error',
|
|
208
|
+
error:
|
|
209
|
+
'Could not refresh the MyVillageOS sign-in on this machine. Tools and the model will keep ' +
|
|
210
|
+
'returning 401 until you run `myvillage login` here.',
|
|
211
|
+
});
|
|
212
|
+
}
|
|
188
213
|
}
|
|
189
214
|
const currentToken = getAccessToken();
|
|
190
215
|
if (currentToken && currentToken !== process.env.MYVILLAGE_ACCESS_TOKEN) {
|
|
@@ -22,7 +22,8 @@ import os from 'os';
|
|
|
22
22
|
import fs from 'fs';
|
|
23
23
|
import path from 'path';
|
|
24
24
|
import { getConfig } from '../utils/config.js';
|
|
25
|
-
import { getAccessToken } from '../utils/auth.js';
|
|
25
|
+
import { getAccessToken, isTokenExpired } from '../utils/auth.js';
|
|
26
|
+
import { refreshAccessToken } from '../utils/api.js';
|
|
26
27
|
import { listLocalAgents, readAgentConfig, getAgentDir, writeAgentConfig } from '../utils/local-agent.js';
|
|
27
28
|
import { startDaemon, stopDaemon, isDaemonRunning, getDaemonInfo } from './daemon.js';
|
|
28
29
|
|
|
@@ -75,13 +76,29 @@ function makeClient(machineKey, machineId) {
|
|
|
75
76
|
};
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Read the signed-in user's OAuth bearer, refreshing it first when it is stale.
|
|
81
|
+
*
|
|
82
|
+
* Every headless machine needs this. On a desktop, Studio's own API traffic
|
|
83
|
+
* keeps the stored token fresh as a side effect; on a Pi nothing else uses the
|
|
84
|
+
* OAuth credential (the runner itself authenticates with its machine API key),
|
|
85
|
+
* so the token on disk simply expires and stays expired. Handing that dead
|
|
86
|
+
* token to a registration call or to a spawned daemon is a guaranteed 401.
|
|
87
|
+
*
|
|
88
|
+
* Returns null when there is no usable credential at all.
|
|
89
|
+
*/
|
|
90
|
+
async function currentUserToken() {
|
|
91
|
+
if (isTokenExpired()) await refreshAccessToken();
|
|
92
|
+
return getAccessToken();
|
|
93
|
+
}
|
|
94
|
+
|
|
78
95
|
/**
|
|
79
96
|
* One-shot client authenticated as the signed-in *user* (OAuth bearer). Used
|
|
80
97
|
* only to register a machine and receive its minted credential.
|
|
81
98
|
*/
|
|
82
|
-
function makeUserClient() {
|
|
99
|
+
async function makeUserClient() {
|
|
83
100
|
const { networkBaseUrl } = getConfig();
|
|
84
|
-
const token =
|
|
101
|
+
const token = await currentUserToken();
|
|
85
102
|
if (!token) return null;
|
|
86
103
|
return async function cp(pathname, { method = 'GET', body } = {}) {
|
|
87
104
|
const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` };
|
|
@@ -198,9 +215,13 @@ function indexLocalAgentsByProfileId() {
|
|
|
198
215
|
return map;
|
|
199
216
|
}
|
|
200
217
|
|
|
201
|
-
function startAgent(name, config, machineId, leaseEpoch) {
|
|
202
|
-
|
|
218
|
+
async function startAgent(name, config, machineId, leaseEpoch) {
|
|
219
|
+
// Refresh before snapshotting. The daemon inherits this env and bakes the
|
|
220
|
+
// token into its MCP Authorization header at construction, so a stale value
|
|
221
|
+
// here means the agent comes up with zero tools and no model access.
|
|
222
|
+
const token = await currentUserToken();
|
|
203
223
|
if (token) process.env.MYVILLAGE_ACCESS_TOKEN = token;
|
|
224
|
+
else log(`no usable sign-in on this machine — ${name} will start unauthenticated; run \`myvillage login\``);
|
|
204
225
|
if (config.man?.agent_id) process.env.MYVILLAGE_AGENT_ID = config.man.agent_id;
|
|
205
226
|
if (config.man?.village_agent_id) process.env.MYVILLAGE_VILLAGE_AGENT_ID = config.man.village_agent_id;
|
|
206
227
|
process.env.MYVILLAGE_MACHINE_ID = machineId;
|
|
@@ -262,7 +283,7 @@ async function resolveMachineIdentity(opts) {
|
|
|
262
283
|
return { machineId, machineKey: saved.machineKey };
|
|
263
284
|
}
|
|
264
285
|
|
|
265
|
-
const userClient = makeUserClient();
|
|
286
|
+
const userClient = await makeUserClient();
|
|
266
287
|
if (!userClient) {
|
|
267
288
|
throw new Error(
|
|
268
289
|
'Not signed in. Run `myvillage login` on this machine (or pass --key with a machine:runner API key).',
|
|
@@ -370,7 +391,7 @@ async function reconcileOnce(ctx) {
|
|
|
370
391
|
continue;
|
|
371
392
|
}
|
|
372
393
|
|
|
373
|
-
const pid = startAgent(entry.name, entry.config, ctx.machineId, epoch);
|
|
394
|
+
const pid = await startAgent(entry.name, entry.config, ctx.machineId, epoch);
|
|
374
395
|
ctx.leases.set(a.agentProfileId, { name: entry.name, epoch });
|
|
375
396
|
log(`started ${entry.name} (pid ${pid}, epoch ${epoch})`);
|
|
376
397
|
}
|
|
@@ -771,7 +771,29 @@ export async function agentLogsCommand(name, options = {}) {
|
|
|
771
771
|
console.log('');
|
|
772
772
|
}
|
|
773
773
|
|
|
774
|
+
// Calendar day of the last entry printed, so we only emit a date header when
|
|
775
|
+
// the day actually changes. readAgentLogs walks the per-day files newest-first
|
|
776
|
+
// and keeps accumulating until it hits `limit`, so one invocation routinely
|
|
777
|
+
// spans midnight. Without a header, yesterday's 10:21 and today's 10:21 render
|
|
778
|
+
// identically and there is no way to tell a live agent from a stale log.
|
|
779
|
+
let lastPrintedDay = null;
|
|
780
|
+
|
|
781
|
+
function printDayHeader(ts) {
|
|
782
|
+
const d = new Date(ts);
|
|
783
|
+
if (Number.isNaN(d.getTime())) return;
|
|
784
|
+
const day = d.toLocaleDateString(undefined, {
|
|
785
|
+
weekday: 'short',
|
|
786
|
+
year: 'numeric',
|
|
787
|
+
month: 'short',
|
|
788
|
+
day: 'numeric',
|
|
789
|
+
});
|
|
790
|
+
if (day === lastPrintedDay) return;
|
|
791
|
+
lastPrintedDay = day;
|
|
792
|
+
console.log(` ${chalk.dim(`── ${day} ──`)}`);
|
|
793
|
+
}
|
|
794
|
+
|
|
774
795
|
function printLogEntry(entry) {
|
|
796
|
+
printDayHeader(entry.ts);
|
|
775
797
|
const time = brand.teal(new Date(entry.ts).toLocaleTimeString());
|
|
776
798
|
const type = entry.type || 'info';
|
|
777
799
|
|
package/src/utils/auth.js
CHANGED
|
@@ -100,7 +100,15 @@ export function getAccessToken() {
|
|
|
100
100
|
|
|
101
101
|
export function isTokenExpired() {
|
|
102
102
|
const creds = loadCredentials();
|
|
103
|
-
|
|
103
|
+
// Nothing stored (or nothing decryptable): not "expired", just signed out.
|
|
104
|
+
// Callers treat a null token as unauthenticated on their own.
|
|
105
|
+
if (!creds?.access_token) return false;
|
|
106
|
+
// Fail closed. A credential with no recorded expiry cannot be proven valid,
|
|
107
|
+
// so treat it as expired and let the refresh path replace it — which also
|
|
108
|
+
// writes a real expires_at, so this self-corrects after one call. Returning
|
|
109
|
+
// false here meant such a token was considered valid forever: a long-lived
|
|
110
|
+
// agent daemon would 401 on every request and never once try to refresh.
|
|
111
|
+
if (!creds.expires_at) return true;
|
|
104
112
|
// Consider expired if within 60 seconds of expiry
|
|
105
113
|
return Date.now() >= (creds.expires_at - 60000);
|
|
106
114
|
}
|