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.
@@ -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 res = await execa(bin, ['install', '-g', spec], { all: true, timeout: 300_000 });
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 = { label: 'preview', command: process.execPath, args: previewArgs };
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
- // The mesh can reconnect independently; camera/audio must be healthy before
190
- // preview sends its first inventory heartbeat or opens the motion webhook.
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(' waiting for RoboVision camera/audio inventory…'));
195
- if (!await waitForVision()) {
196
- stopping = true;
197
- for (const entry of children)
198
- entry.child?.kill('SIGTERM');
199
- throw new Error('RoboVision did not become healthy at http://127.0.0.1:5000 within 120s');
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])
@@ -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 = `http://${hubHost}:${opts.schedulerPort ? parseInt(opts.schedulerPort, 10) : DEFAULT_SCHEDULER_PORT}`;
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
- const robotArgv = [process.execPath, process.argv[1], ...runtimeArgs, '--foreground'];
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
  }
@@ -183,7 +183,9 @@ robotProgram
183
183
  await roboparkRobotRuntime({
184
184
  name: opts.name,
185
185
  hubUrl: opts.hubUrl,
186
- schedulerUrl: opts.schedulerUrl ?? `http://${hub.hostname}:8080`,
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.57",
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/enroll", response_model=DeviceEnrollResponse)
2434
- async def enroll_device(payload: DeviceEnrollRequest):
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 = 30.0
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
- async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> str:
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={"Authorization": f"Bearer {token}"},
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 not self.device_token and enrollment_token:
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