infinicode 2.8.57 → 2.8.59
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/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +4 -0
- package/dist/kernel/federation/self-update.js +16 -1
- package/dist/kernel/federation/transport-http.d.ts +4 -0
- package/dist/kernel/federation/transport-http.js +26 -1
- package/dist/robopark/robot-runtime.js +30 -22
- package/dist/robopark/setup.js +9 -2
- package/dist/robopark-cli.js +3 -1
- package/package.json +1 -2
- package/packages/robopark/scheduler/main.py +91 -9
- package/packages/robopark/scheduler/preview_agent.py +58 -14
|
@@ -5530,6 +5530,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5530
5530
|
if(greetings && document.activeElement!==greetings) greetings.value=(device.greeting_phrases||[]).join(String.fromCharCode(10));
|
|
5531
5531
|
var note=$('pk-device-note');
|
|
5532
5532
|
if(note){
|
|
5533
|
+
if(!device.id && n && n.connected){
|
|
5534
|
+
note.textContent='mesh connected; scheduler identity is bootstrapping or failed';
|
|
5535
|
+
return;
|
|
5536
|
+
}
|
|
5533
5537
|
var source=inv.source||'robot heartbeat', counts=(inv.video||[]).length+'/'+(inv.audio_input||[]).length+'/'+(inv.audio_output||[]).length;
|
|
5534
5538
|
note.textContent=hasRealInventory(inv) ? ('inventory from '+source+' · camera/mic/speaker '+counts) : (device.id ? 'no real hardware inventory reported yet · restart vision-agent + preview-agent on this robot' : 'waiting for robot heartbeat inventory');
|
|
5535
5539
|
}
|
|
@@ -35,7 +35,22 @@ export async function performSelfUpdate(opts = {}) {
|
|
|
35
35
|
try {
|
|
36
36
|
// Use npm.cmd on Windows so we never need a shell (no injection surface).
|
|
37
37
|
const bin = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
38
|
-
const
|
|
38
|
+
const args = ['install', '-g', spec];
|
|
39
|
+
let res;
|
|
40
|
+
try {
|
|
41
|
+
res = await execa(bin, args, { all: true, timeout: 300_000 });
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
const message = String(err.all ?? err.message ?? err);
|
|
45
|
+
const unprivileged = process.platform !== 'win32' && process.getuid?.() !== 0;
|
|
46
|
+
if (!unprivileged || !/\bEACCES\b|permission denied/i.test(message))
|
|
47
|
+
throw err;
|
|
48
|
+
// RoboPark systemd services normally run as root. This fallback also
|
|
49
|
+
// supports an operator-launched satellite when sudo is intentionally
|
|
50
|
+
// configured non-interactively; it never prompts or shells out.
|
|
51
|
+
info('[update] global npm directory is root-owned; retrying with sudo -n…');
|
|
52
|
+
res = await execa('sudo', ['-n', bin, ...args], { all: true, timeout: 300_000 });
|
|
53
|
+
}
|
|
39
54
|
const tail = String(res.all ?? res.stdout ?? '').split('\n').filter(Boolean).slice(-2).join(' ').trim();
|
|
40
55
|
info(`[update] installed ${spec}${tail ? ' — ' + tail : ''}`);
|
|
41
56
|
}
|
|
@@ -63,6 +63,10 @@ export declare class MeshServer {
|
|
|
63
63
|
* (browsers, which can't set headers on navigation or EventSource). */
|
|
64
64
|
private presentedToken;
|
|
65
65
|
private authOk;
|
|
66
|
+
/** A robot uses this header only when it talks to the hub's scheduler proxy.
|
|
67
|
+
* Its device token remains the Authorization header and is forwarded to the
|
|
68
|
+
* scheduler. Requiring both keeps arbitrary Tailnet callers out. */
|
|
69
|
+
private schedulerProxyAuthOk;
|
|
66
70
|
private handle;
|
|
67
71
|
private openStream;
|
|
68
72
|
private readBody;
|
|
@@ -106,8 +106,24 @@ export class MeshServer {
|
|
|
106
106
|
this.readBody(req)
|
|
107
107
|
.then(async (body) => {
|
|
108
108
|
const headers = { 'content-type': req.headers['content-type'] ?? 'application/json' };
|
|
109
|
+
const meshProxyToken = req.headers['x-robopark-mesh-token'];
|
|
110
|
+
if (typeof meshProxyToken === 'string') {
|
|
111
|
+
// The scheduler uses this only for device bootstrap. The mesh server
|
|
112
|
+
// has already validated it before this proxy method is reached.
|
|
113
|
+
headers['x-robopark-mesh-token'] = meshProxyToken;
|
|
114
|
+
}
|
|
109
115
|
if (this.opts.schedulerToken)
|
|
110
116
|
headers['authorization'] = `Bearer ${this.opts.schedulerToken}`;
|
|
117
|
+
else {
|
|
118
|
+
const inbound = req.headers.authorization;
|
|
119
|
+
const presented = this.presentedToken(req);
|
|
120
|
+
const isMeshToken = this.opts.token && presented && safeEqual(presented, this.opts.token);
|
|
121
|
+
// Robot device tokens must reach the scheduler unchanged. Mesh tokens
|
|
122
|
+
// authenticate the proxy itself and are not scheduler credentials.
|
|
123
|
+
if (!isMeshToken && typeof inbound === 'string' && inbound.startsWith('Bearer ')) {
|
|
124
|
+
headers.authorization = inbound;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
111
127
|
const upstream = await fetch(target, {
|
|
112
128
|
method: req.method,
|
|
113
129
|
headers,
|
|
@@ -250,6 +266,15 @@ export class MeshServer {
|
|
|
250
266
|
const presented = this.presentedToken(req);
|
|
251
267
|
return presented !== undefined && safeEqual(presented, this.opts.token);
|
|
252
268
|
}
|
|
269
|
+
/** A robot uses this header only when it talks to the hub's scheduler proxy.
|
|
270
|
+
* Its device token remains the Authorization header and is forwarded to the
|
|
271
|
+
* scheduler. Requiring both keeps arbitrary Tailnet callers out. */
|
|
272
|
+
schedulerProxyAuthOk(req, path) {
|
|
273
|
+
if (!path.startsWith('/robopark/api/') || !this.opts.token)
|
|
274
|
+
return false;
|
|
275
|
+
const presented = req.headers['x-robopark-mesh-token'];
|
|
276
|
+
return typeof presented === 'string' && safeEqual(presented, this.opts.token);
|
|
277
|
+
}
|
|
253
278
|
handle(req, res) {
|
|
254
279
|
const url = req.url ?? '/';
|
|
255
280
|
const path = url.split('?', 1)[0];
|
|
@@ -257,7 +282,7 @@ export class MeshServer {
|
|
|
257
282
|
res.writeHead(204).end();
|
|
258
283
|
return;
|
|
259
284
|
}
|
|
260
|
-
if (!this.authOk(req)) {
|
|
285
|
+
if (!this.authOk(req) && !this.schedulerProxyAuthOk(req, path)) {
|
|
261
286
|
res.writeHead(401).end('unauthorized');
|
|
262
287
|
return;
|
|
263
288
|
}
|
|
@@ -105,7 +105,18 @@ export async function roboparkRobotRuntime(opts) {
|
|
|
105
105
|
previewArgs.push('--robovision-url', 'http://127.0.0.1:5000');
|
|
106
106
|
if (opts.enrollmentToken)
|
|
107
107
|
previewArgs.push('--enrollment-token', opts.enrollmentToken);
|
|
108
|
-
const preview = {
|
|
108
|
+
const preview = {
|
|
109
|
+
label: 'preview',
|
|
110
|
+
command: process.execPath,
|
|
111
|
+
args: previewArgs,
|
|
112
|
+
// Tailscale scheduler access goes through the authenticated hub proxy.
|
|
113
|
+
// Keep this in the child environment, never in the persisted robot config.
|
|
114
|
+
env: {
|
|
115
|
+
ROBOPARK_MESH_TOKEN: opts.token,
|
|
116
|
+
// Inventory changes should reach the Control Center promptly.
|
|
117
|
+
HEARTBEAT_INTERVAL: '5',
|
|
118
|
+
},
|
|
119
|
+
};
|
|
109
120
|
const start = (entry) => {
|
|
110
121
|
const child = spawn(entry.command, entry.args, {
|
|
111
122
|
env: { ...process.env, ...entry.env, ROBOPARK_ROBOT_RUNTIME: '1' },
|
|
@@ -160,20 +171,18 @@ export async function roboparkRobotRuntime(opts) {
|
|
|
160
171
|
start(children[0]);
|
|
161
172
|
if (opts.vision !== false) {
|
|
162
173
|
start(children[1]);
|
|
163
|
-
if (!await waitForVision()) {
|
|
164
|
-
console.error(chalk.red(' RoboVision did not recover after the mesh update; retrying in 5s'));
|
|
165
|
-
// Stop the partially restarted stack before retrying. Otherwise a
|
|
166
|
-
// second mesh child would collide with the existing listener.
|
|
167
|
-
await Promise.all([...children, preview].map(stopChild));
|
|
168
|
-
recycling = false;
|
|
169
|
-
setTimeout(() => { if (!stopping)
|
|
170
|
-
void recycleAfterMeshUpdate(); }, RESTART_DELAY_MS);
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
174
|
}
|
|
174
175
|
start(preview);
|
|
175
176
|
recycling = false;
|
|
176
177
|
console.log(chalk.green(' robot runtime updated and healthy'));
|
|
178
|
+
if (opts.vision !== false) {
|
|
179
|
+
void waitForVision().then(ready => {
|
|
180
|
+
if (!stopping)
|
|
181
|
+
console.log(ready
|
|
182
|
+
? chalk.green(' RoboVision camera/audio inventory ready')
|
|
183
|
+
: chalk.yellow(' RoboVision is degraded; preview heartbeat remains online and will retry'));
|
|
184
|
+
});
|
|
185
|
+
}
|
|
177
186
|
};
|
|
178
187
|
console.log(chalk.bold('\n robopark robot up'));
|
|
179
188
|
console.log(chalk.dim(' ' + '─'.repeat(52)));
|
|
@@ -186,21 +195,20 @@ export async function roboparkRobotRuntime(opts) {
|
|
|
186
195
|
console.log(chalk.dim(' clearing stale device identity for fresh enrollment…'));
|
|
187
196
|
resetStaleEnrollment();
|
|
188
197
|
}
|
|
189
|
-
//
|
|
190
|
-
//
|
|
198
|
+
// Identity and inventory reporting start immediately. RoboVision may still
|
|
199
|
+
// be installing or recovering without making the robot disappear.
|
|
191
200
|
start(children[0]);
|
|
201
|
+
start(preview);
|
|
192
202
|
if (opts.vision !== false) {
|
|
193
203
|
start(children[1]);
|
|
194
|
-
console.log(chalk.dim('
|
|
195
|
-
|
|
196
|
-
stopping
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
console.log(chalk.green(' ✓ RoboVision ready; starting preview agent'));
|
|
204
|
+
console.log(chalk.dim(' RoboVision health check running in background…'));
|
|
205
|
+
void waitForVision().then(ready => {
|
|
206
|
+
if (!stopping)
|
|
207
|
+
console.log(ready
|
|
208
|
+
? chalk.green(' RoboVision camera/audio inventory ready')
|
|
209
|
+
: chalk.yellow(' RoboVision is degraded; preview heartbeat remains online and will retry'));
|
|
210
|
+
});
|
|
202
211
|
}
|
|
203
|
-
start(preview);
|
|
204
212
|
const shutdown = () => {
|
|
205
213
|
stopping = true;
|
|
206
214
|
for (const entry of [...children, preview])
|
package/dist/robopark/setup.js
CHANGED
|
@@ -163,7 +163,9 @@ async function setupRobot(config, opts, ctx, internal) {
|
|
|
163
163
|
// preview-agent) silently kept talking to whatever hub auto-discovery
|
|
164
164
|
// found (e.g. a real hub reachable over Tailscale), not the one requested.
|
|
165
165
|
const hubHost = new URL(hubUrl).hostname;
|
|
166
|
-
const schedulerUrl =
|
|
166
|
+
const schedulerUrl = network === 'tailscale'
|
|
167
|
+
? `${hubUrl.replace(/\/+$/, '')}/robopark`
|
|
168
|
+
: `http://${hubHost}:${opts.schedulerPort ? parseInt(opts.schedulerPort, 10) : DEFAULT_SCHEDULER_PORT}`;
|
|
167
169
|
// Vision (camera/motion detection -> presence-triggered session) is the
|
|
168
170
|
// production trigger — on by default. --no-vision opts out (e.g. no camera
|
|
169
171
|
// on this box, or RoboVisionAI_PI isn't installed).
|
|
@@ -193,7 +195,12 @@ async function setupRobot(config, opts, ctx, internal) {
|
|
|
193
195
|
runDetached(robotArgv[0], robotArgv.slice(1));
|
|
194
196
|
}
|
|
195
197
|
if (opts.autoStart) {
|
|
196
|
-
|
|
198
|
+
// Enrollment is a one-time bootstrap action. Persisting it in the
|
|
199
|
+
// supervisor would erase the device identity and attempt a new enrollment
|
|
200
|
+
// on every reboot, creating duplicate robots (or failing after the token
|
|
201
|
+
// is consumed).
|
|
202
|
+
const bootRuntimeArgs = runtimeArgs.filter((arg, index) => arg !== '--enrollment-token' && runtimeArgs[index - 1] !== '--enrollment-token');
|
|
203
|
+
const robotArgv = [process.execPath, process.argv[1], ...bootRuntimeArgs, '--foreground'];
|
|
197
204
|
for (const role of ['robot', 'robot-preview-agent', 'robot-vision-agent']) {
|
|
198
205
|
unregisterAutoStart(role, internal.name);
|
|
199
206
|
}
|
package/dist/robopark-cli.js
CHANGED
|
@@ -183,7 +183,9 @@ robotProgram
|
|
|
183
183
|
await roboparkRobotRuntime({
|
|
184
184
|
name: opts.name,
|
|
185
185
|
hubUrl: opts.hubUrl,
|
|
186
|
-
schedulerUrl: opts.schedulerUrl ??
|
|
186
|
+
schedulerUrl: opts.schedulerUrl ?? (opts.tailscale
|
|
187
|
+
? `${opts.hubUrl.replace(/\/+$/, '')}/robopark`
|
|
188
|
+
: `http://${hub.hostname}:8080`),
|
|
187
189
|
token: opts.token,
|
|
188
190
|
port: opts.port,
|
|
189
191
|
network: opts.tailscale ? 'tailscale' : 'lan',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infinicode",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.59",
|
|
4
4
|
"description": "OpenKernel — provider-agnostic AI execution kernel. Native coding agent + mission-driven execution runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/kernel/index.js",
|
|
@@ -74,7 +74,6 @@
|
|
|
74
74
|
"node": ">=20"
|
|
75
75
|
},
|
|
76
76
|
"dependencies": {
|
|
77
|
-
"@anthropic-ai/claude-code": "^2.1.207",
|
|
78
77
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
79
78
|
"chalk": "^5.3.0",
|
|
80
79
|
"commander": "^12.1.0",
|
|
@@ -43,6 +43,21 @@ ROBOPARK_AGENT_TOKEN_FILE = os.getenv(
|
|
|
43
43
|
"ROBOPARK_AGENT_TOKEN_FILE",
|
|
44
44
|
os.path.join(os.getenv("SCHEDULER_DATA_DIR", "/app/data"), ".robopark-agent-token"),
|
|
45
45
|
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _load_mesh_token() -> str:
|
|
49
|
+
"""Load the hub credential shared with unified robot runtimes."""
|
|
50
|
+
configured = os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
|
|
51
|
+
if configured:
|
|
52
|
+
return configured
|
|
53
|
+
try:
|
|
54
|
+
with open(os.path.expanduser("~/.robopark/mesh.token"), "r", encoding="utf8") as handle:
|
|
55
|
+
return handle.read().strip()
|
|
56
|
+
except OSError:
|
|
57
|
+
return ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
ROBOPARK_MESH_TOKEN = _load_mesh_token()
|
|
46
61
|
|
|
47
62
|
# ElevenLabs credentials used by the /api/voices/elevenlabs endpoint. We accept
|
|
48
63
|
# both the long-form ELEVENLABS_API_KEY and a shorter ELEVENLABS_KEY alias so
|
|
@@ -180,7 +195,7 @@ class DeviceCreate(BaseModel):
|
|
|
180
195
|
notes: Optional[str] = None
|
|
181
196
|
enrollment_token: Optional[str] = None # if omitted, one is auto-generated
|
|
182
197
|
|
|
183
|
-
class DeviceEnrollRequest(BaseModel):
|
|
198
|
+
class DeviceEnrollRequest(BaseModel):
|
|
184
199
|
enrollment_token: str
|
|
185
200
|
name: Optional[str] = None
|
|
186
201
|
tailscale_ip: Optional[str] = None
|
|
@@ -188,8 +203,13 @@ class DeviceEnrollRequest(BaseModel):
|
|
|
188
203
|
motor_server_url: Optional[str] = None
|
|
189
204
|
livekit_url: Optional[str] = None
|
|
190
205
|
character_id: Optional[str] = None
|
|
191
|
-
video_device: Optional[str] = None
|
|
192
|
-
audio_device: Optional[str] = None
|
|
206
|
+
video_device: Optional[str] = None
|
|
207
|
+
audio_device: Optional[str] = None
|
|
208
|
+
|
|
209
|
+
class DeviceBootstrapRequest(BaseModel):
|
|
210
|
+
name: str
|
|
211
|
+
lan_ip: Optional[str] = None
|
|
212
|
+
tailscale_ip: Optional[str] = None
|
|
193
213
|
|
|
194
214
|
class DeviceEnrollResponse(BaseModel):
|
|
195
215
|
device_id: str
|
|
@@ -2343,7 +2363,7 @@ async def rotate_device_token(device_id: str):
|
|
|
2343
2363
|
await _log_history("device", "device", device_id, "token_rotated", "operator")
|
|
2344
2364
|
return {"device_id": device_id, "device_token": new_token}
|
|
2345
2365
|
|
|
2346
|
-
async def _authorize_device(device_id: str, authorization: Optional[str]) -> bool:
|
|
2366
|
+
async def _authorize_device(device_id: str, authorization: Optional[str]) -> bool:
|
|
2347
2367
|
"""Verify Bearer token matches stored hash for this device."""
|
|
2348
2368
|
if not authorization or not authorization.lower().startswith("bearer "):
|
|
2349
2369
|
return False
|
|
@@ -2351,9 +2371,18 @@ async def _authorize_device(device_id: str, authorization: Optional[str]) -> boo
|
|
|
2351
2371
|
async with aiosqlite.connect(DB_PATH) as db:
|
|
2352
2372
|
async with db.execute("SELECT token_hash FROM devices WHERE id = ?", (device_id,)) as c:
|
|
2353
2373
|
row = await c.fetchone()
|
|
2354
|
-
if not row or not row[0]:
|
|
2355
|
-
return False
|
|
2356
|
-
return hmac.compare_digest(row[0], _hash(token))
|
|
2374
|
+
if not row or not row[0]:
|
|
2375
|
+
return False
|
|
2376
|
+
return hmac.compare_digest(row[0], _hash(token))
|
|
2377
|
+
|
|
2378
|
+
|
|
2379
|
+
def _authorize_mesh_bootstrap(presented: Optional[str]) -> bool:
|
|
2380
|
+
"""Authenticate bootstrap before a device-scoped token exists."""
|
|
2381
|
+
return bool(
|
|
2382
|
+
ROBOPARK_MESH_TOKEN
|
|
2383
|
+
and presented
|
|
2384
|
+
and hmac.compare_digest(ROBOPARK_MESH_TOKEN, presented.strip())
|
|
2385
|
+
)
|
|
2357
2386
|
|
|
2358
2387
|
async def _log_history(category: str, entity_type: Optional[str] = None, entity_id: Optional[str] = None,
|
|
2359
2388
|
action: Optional[str] = None, actor: Optional[str] = None, details: Optional[str] = None):
|
|
@@ -2430,8 +2459,61 @@ async def _authorize_fleet(authorization: Optional[str]) -> bool:
|
|
|
2430
2459
|
rows = await c.fetchall()
|
|
2431
2460
|
return any(r[0] and hmac.compare_digest(r[0], token_hash) for r in rows)
|
|
2432
2461
|
|
|
2433
|
-
@app.post("/api/devices/
|
|
2434
|
-
async def
|
|
2462
|
+
@app.post("/api/devices/bootstrap", response_model=DeviceEnrollResponse)
|
|
2463
|
+
async def bootstrap_device(
|
|
2464
|
+
payload: DeviceBootstrapRequest,
|
|
2465
|
+
x_robopark_mesh_token: Optional[str] = Header(default=None),
|
|
2466
|
+
):
|
|
2467
|
+
"""Create or recover one scheduler identity for a mesh-authenticated robot."""
|
|
2468
|
+
if not _authorize_mesh_bootstrap(x_robopark_mesh_token):
|
|
2469
|
+
raise HTTPException(401, "Invalid or missing RoboPark mesh token")
|
|
2470
|
+
name = payload.name.strip()
|
|
2471
|
+
if not name:
|
|
2472
|
+
raise HTTPException(400, "name is required")
|
|
2473
|
+
|
|
2474
|
+
new_token = secrets.token_urlsafe(32)
|
|
2475
|
+
new_hash = _hash(new_token)
|
|
2476
|
+
now = datetime.utcnow().isoformat()
|
|
2477
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
2478
|
+
db.row_factory = aiosqlite.Row
|
|
2479
|
+
async with db.execute(
|
|
2480
|
+
"""SELECT id FROM devices WHERE lower(name) = lower(?)
|
|
2481
|
+
ORDER BY CASE lower(status) WHEN 'online' THEN 0 WHEN 'running' THEN 1 ELSE 2 END,
|
|
2482
|
+
last_heartbeat DESC, enrolled_at DESC LIMIT 1""",
|
|
2483
|
+
(name,),
|
|
2484
|
+
) as cursor:
|
|
2485
|
+
existing = await cursor.fetchone()
|
|
2486
|
+
if existing:
|
|
2487
|
+
device_id = existing["id"]
|
|
2488
|
+
await db.execute(
|
|
2489
|
+
"""UPDATE devices SET token_hash = ?, status = 'enrolled',
|
|
2490
|
+
lan_ip = COALESCE(?, lan_ip), tailscale_ip = COALESCE(?, tailscale_ip),
|
|
2491
|
+
enrolled_at = COALESCE(enrolled_at, ?) WHERE id = ?""",
|
|
2492
|
+
(new_hash, payload.lan_ip, payload.tailscale_ip, now, device_id),
|
|
2493
|
+
)
|
|
2494
|
+
else:
|
|
2495
|
+
device_id = f"dev_{secrets.token_hex(4)}"
|
|
2496
|
+
await db.execute(
|
|
2497
|
+
"""INSERT INTO devices
|
|
2498
|
+
(id, name, lan_ip, tailscale_ip, token_hash, status, enrolled_at, created_at)
|
|
2499
|
+
VALUES (?, ?, ?, ?, ?, 'enrolled', ?, ?)""",
|
|
2500
|
+
(device_id, name, payload.lan_ip, payload.tailscale_ip, new_hash, now, now),
|
|
2501
|
+
)
|
|
2502
|
+
await db.execute(
|
|
2503
|
+
"INSERT OR IGNORE INTO robots (id, name, status) VALUES (?, ?, 'idle')",
|
|
2504
|
+
(device_id, name),
|
|
2505
|
+
)
|
|
2506
|
+
await db.execute("UPDATE robots SET name = ? WHERE id = ?", (name, device_id))
|
|
2507
|
+
await db.commit()
|
|
2508
|
+
|
|
2509
|
+
await broadcast("device_bootstrapped", {"device_id": device_id, "name": name})
|
|
2510
|
+
await _log_history("device", "device", device_id, "bootstrapped", "mesh", f"name={name}")
|
|
2511
|
+
scheduler_url = os.getenv("SCHEDULER_PUBLIC_URL", f"http://localhost:{os.getenv('SCHEDULER_PORT', '8080')}")
|
|
2512
|
+
return DeviceEnrollResponse(device_id=device_id, device_token=new_token, scheduler_url=scheduler_url)
|
|
2513
|
+
|
|
2514
|
+
|
|
2515
|
+
@app.post("/api/devices/enroll", response_model=DeviceEnrollResponse)
|
|
2516
|
+
async def enroll_device(payload: DeviceEnrollRequest):
|
|
2435
2517
|
"""First-boot enrollment for a Pi. Requires production_mode = true and a valid enrollment token.
|
|
2436
2518
|
Returns a long-lived device_token the Pi will use for heartbeats."""
|
|
2437
2519
|
if not await get_production_mode():
|
|
@@ -68,7 +68,7 @@ logger = logging.getLogger("robopark.preview_agent")
|
|
|
68
68
|
|
|
69
69
|
_DEVICE_INVENTORY_CACHE: Optional[dict] = None
|
|
70
70
|
_DEVICE_INVENTORY_CACHE_AT = 0.0
|
|
71
|
-
DEVICE_INVENTORY_CACHE_SECONDS =
|
|
71
|
+
DEVICE_INVENTORY_CACHE_SECONDS = 5.0
|
|
72
72
|
ROBOVISION_MEDIA_URL = os.getenv("ROBOVISION_MEDIA_URL", "http://127.0.0.1:5000/api/media/inventory")
|
|
73
73
|
|
|
74
74
|
|
|
@@ -256,13 +256,48 @@ def _load_token() -> Optional[str]:
|
|
|
256
256
|
return None
|
|
257
257
|
|
|
258
258
|
|
|
259
|
-
def _save_token(token: str) -> None:
|
|
260
|
-
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
261
|
-
TOKEN_FILE.write_text(token, encoding="utf8")
|
|
262
|
-
os.chmod(TOKEN_FILE, 0o600)
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
259
|
+
def _save_token(token: str) -> None:
|
|
260
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
TOKEN_FILE.write_text(token, encoding="utf8")
|
|
262
|
+
os.chmod(TOKEN_FILE, 0o600)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _mesh_proxy_headers() -> dict:
|
|
266
|
+
"""Add hub authentication only when the runtime supplied it.
|
|
267
|
+
|
|
268
|
+
Direct LAN scheduler calls work unchanged. Tailscale calls use the hub's
|
|
269
|
+
/robopark proxy, which needs this separate mesh credential while the normal
|
|
270
|
+
Authorization header remains the scheduler device token.
|
|
271
|
+
"""
|
|
272
|
+
token = os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
|
|
273
|
+
return {"X-RoboPark-Mesh-Token": token} if token else {}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
async def _bootstrap_mesh_device(scheduler_url: str, robot_id: str) -> tuple[str, str]:
|
|
277
|
+
"""Create or recover this robot's scheduler identity using mesh auth."""
|
|
278
|
+
async with httpx.AsyncClient(headers=_mesh_proxy_headers()) as client:
|
|
279
|
+
response = await client.post(
|
|
280
|
+
f"{scheduler_url.rstrip('/')}/api/devices/bootstrap",
|
|
281
|
+
json={"name": robot_id, "lan_ip": _get_lan_ip()},
|
|
282
|
+
timeout=30.0,
|
|
283
|
+
)
|
|
284
|
+
response.raise_for_status()
|
|
285
|
+
data = response.json()
|
|
286
|
+
device_id = str(data.get("device_id") or "").strip()
|
|
287
|
+
device_token = str(data.get("device_token") or "").strip()
|
|
288
|
+
if not device_id or not device_token:
|
|
289
|
+
raise RuntimeError("mesh bootstrap did not return device credentials")
|
|
290
|
+
_save_token(device_token)
|
|
291
|
+
cfg = _load_config()
|
|
292
|
+
cfg["device_id"] = device_id
|
|
293
|
+
cfg["device_token"] = device_token
|
|
294
|
+
cfg["scheduler_url"] = scheduler_url
|
|
295
|
+
_save_config(cfg)
|
|
296
|
+
logger.info("mesh bootstrap resolved scheduler device %s", device_id)
|
|
297
|
+
return device_id, device_token
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> str:
|
|
266
301
|
"""Enroll this Pi with the scheduler and return the device token."""
|
|
267
302
|
import socket
|
|
268
303
|
payload = {
|
|
@@ -270,7 +305,7 @@ async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> s
|
|
|
270
305
|
"name": robot_id,
|
|
271
306
|
"lan_ip": _get_lan_ip(),
|
|
272
307
|
}
|
|
273
|
-
async with httpx.AsyncClient() as client:
|
|
308
|
+
async with httpx.AsyncClient(headers=_mesh_proxy_headers()) as client:
|
|
274
309
|
r = await client.post(
|
|
275
310
|
f"{scheduler_url.rstrip('/')}/api/devices/enroll",
|
|
276
311
|
json=payload,
|
|
@@ -305,11 +340,12 @@ def _get_lan_ip() -> Optional[str]:
|
|
|
305
340
|
async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> Optional[bool]:
|
|
306
341
|
try:
|
|
307
342
|
inventory = _get_device_inventory()
|
|
343
|
+
headers = {"Authorization": f"Bearer {token}", **_mesh_proxy_headers()}
|
|
308
344
|
async with httpx.AsyncClient() as client:
|
|
309
345
|
response = await client.post(
|
|
310
346
|
f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
|
|
311
347
|
json={"status": "online", "ip": _get_lan_ip(), "device_inventory": inventory},
|
|
312
|
-
headers=
|
|
348
|
+
headers=headers,
|
|
313
349
|
timeout=10.0,
|
|
314
350
|
)
|
|
315
351
|
response.raise_for_status()
|
|
@@ -387,16 +423,24 @@ class PreviewAgent:
|
|
|
387
423
|
self._motion_capture: Optional[VideoCapture] = None
|
|
388
424
|
self._motion_capture_lock = asyncio.Lock()
|
|
389
425
|
|
|
390
|
-
async def run(self) -> None:
|
|
391
|
-
enrollment_token = os.getenv("ENROLLMENT_TOKEN") or self.enrollment_token
|
|
392
|
-
if
|
|
426
|
+
async def run(self) -> None:
|
|
427
|
+
enrollment_token = os.getenv("ENROLLMENT_TOKEN") or self.enrollment_token
|
|
428
|
+
if _mesh_proxy_headers():
|
|
429
|
+
try:
|
|
430
|
+
self.device_id, self.device_token = await _bootstrap_mesh_device(
|
|
431
|
+
self.scheduler_url, self.robot_id
|
|
432
|
+
)
|
|
433
|
+
except Exception as exc:
|
|
434
|
+
logger.warning("mesh device bootstrap failed: %s", exc)
|
|
435
|
+
|
|
436
|
+
if not self.device_token and enrollment_token:
|
|
393
437
|
self.device_token = await _enroll(self.scheduler_url, enrollment_token, self.robot_id)
|
|
394
438
|
|
|
395
439
|
if not self.device_token:
|
|
396
440
|
logger.error("no DEVICE_TOKEN and no ENROLLMENT_TOKEN; cannot poll scheduler")
|
|
397
441
|
sys.exit(1)
|
|
398
442
|
|
|
399
|
-
self._session = httpx.AsyncClient()
|
|
443
|
+
self._session = httpx.AsyncClient(headers=_mesh_proxy_headers())
|
|
400
444
|
|
|
401
445
|
# Must come after self._session exists — _resolve_device_id() guards
|
|
402
446
|
# on it and silently no-ops otherwise. On a fresh enroll (no cached
|