infinicode 2.8.58 → 2.8.60

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
  }
@@ -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
  }
@@ -73,7 +73,7 @@ Wants=network-online.target
73
73
  [Service]
74
74
  Type=simple
75
75
  ExecStart=${service.command} ${service.args.map(shellQuote).join(' ')}
76
- WorkingDirectory=${service.workingDir ?? '/opt/robopark'}
76
+ WorkingDirectory=${service.workingDir ?? homedir()}
77
77
  Restart=always
78
78
  RestartSec=5
79
79
  ${envLines}
@@ -83,7 +83,15 @@ WantedBy=multi-user.target
83
83
  `;
84
84
  try {
85
85
  writeFileSync(unitPath, unit, 'utf8');
86
- return { ok: true, message: `wrote systemd unit ${unitPath}. Run: systemctl enable --now ${unitName}` };
86
+ const reload = spawnSync('systemctl', ['daemon-reload'], { encoding: 'utf8' });
87
+ if (reload.status !== 0) {
88
+ return { ok: false, message: `wrote ${unitPath}, but systemctl daemon-reload failed: ${reload.stderr || reload.stdout}` };
89
+ }
90
+ const enabled = spawnSync('systemctl', ['enable', '--now', unitName], { encoding: 'utf8' });
91
+ if (enabled.status !== 0) {
92
+ return { ok: false, message: `wrote ${unitPath}, but systemctl enable --now failed: ${enabled.stderr || enabled.stdout}` };
93
+ }
94
+ return { ok: true, message: `enabled and started systemd unit ${unitName}` };
87
95
  }
88
96
  catch (e) {
89
97
  return { ok: false, message: `could not write ${unitPath}: ${e instanceof Error ? e.message : String(e)}` };
@@ -101,11 +101,27 @@ export async function roboparkRobotRuntime(opts) {
101
101
  '--scheduler-url', opts.schedulerUrl, '--robot-id', opts.name,
102
102
  '--video-device', opts.videoDevice ?? 'auto', '--audio-device', opts.audioDevice ?? 'default',
103
103
  '--save-config'];
104
- if (opts.vision !== false)
105
- previewArgs.push('--robovision-url', 'http://127.0.0.1:5000');
106
104
  if (opts.enrollmentToken)
107
105
  previewArgs.push('--enrollment-token', opts.enrollmentToken);
108
- const preview = { label: 'preview', command: process.execPath, args: previewArgs };
106
+ const preview = {
107
+ label: 'preview',
108
+ command: process.execPath,
109
+ args: previewArgs,
110
+ // Tailscale scheduler access goes through the authenticated hub proxy.
111
+ // Keep this in the child environment, never in the persisted robot config.
112
+ env: {
113
+ ROBOPARK_MESH_TOKEN: opts.token,
114
+ // Keep internal service wiring out of the Commander argument boundary.
115
+ // This also lets preview start while RoboVision is still warming up.
116
+ ROBOVISION_URL: opts.vision !== false ? 'http://127.0.0.1:5000' : '',
117
+ ROBOVISION_MEDIA_URL: opts.vision !== false
118
+ ? 'http://127.0.0.1:5000/api/media/inventory'
119
+ : '',
120
+ ROBOVISION_CAMERA: opts.vision !== false ? '1' : '0',
121
+ // Inventory changes should reach the Control Center promptly.
122
+ HEARTBEAT_INTERVAL: '5',
123
+ },
124
+ };
109
125
  const start = (entry) => {
110
126
  const child = spawn(entry.command, entry.args, {
111
127
  env: { ...process.env, ...entry.env, ROBOPARK_ROBOT_RUNTIME: '1' },
@@ -160,20 +176,18 @@ export async function roboparkRobotRuntime(opts) {
160
176
  start(children[0]);
161
177
  if (opts.vision !== false) {
162
178
  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
179
  }
174
180
  start(preview);
175
181
  recycling = false;
176
182
  console.log(chalk.green(' robot runtime updated and healthy'));
183
+ if (opts.vision !== false) {
184
+ void waitForVision().then(ready => {
185
+ if (!stopping)
186
+ console.log(ready
187
+ ? chalk.green(' RoboVision camera/audio inventory ready')
188
+ : chalk.yellow(' RoboVision is degraded; preview heartbeat remains online and will retry'));
189
+ });
190
+ }
177
191
  };
178
192
  console.log(chalk.bold('\n robopark robot up'));
179
193
  console.log(chalk.dim(' ' + '─'.repeat(52)));
@@ -186,21 +200,20 @@ export async function roboparkRobotRuntime(opts) {
186
200
  console.log(chalk.dim(' clearing stale device identity for fresh enrollment…'));
187
201
  resetStaleEnrollment();
188
202
  }
189
- // The mesh can reconnect independently; camera/audio must be healthy before
190
- // preview sends its first inventory heartbeat or opens the motion webhook.
203
+ // Identity and inventory reporting start immediately. RoboVision may still
204
+ // be installing or recovering without making the robot disappear.
191
205
  start(children[0]);
206
+ start(preview);
192
207
  if (opts.vision !== false) {
193
208
  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'));
209
+ console.log(chalk.dim(' RoboVision health check running in background…'));
210
+ void waitForVision().then(ready => {
211
+ if (!stopping)
212
+ console.log(ready
213
+ ? chalk.green(' RoboVision camera/audio inventory ready')
214
+ : chalk.yellow(' RoboVision is degraded; preview heartbeat remains online and will retry'));
215
+ });
202
216
  }
203
- start(preview);
204
217
  const shutdown = () => {
205
218
  stopping = true;
206
219
  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).
@@ -187,13 +189,21 @@ async function setupRobot(config, opts, ctx, internal) {
187
189
  console.log(' ' + chalk.cyan(`robopark ${runtimeArgs.join(' ')}`));
188
190
  console.log(chalk.dim(` network: ${network} (scheduler: ${schedulerUrl})`));
189
191
  console.log();
190
- if (opts.start) {
192
+ // On Linux, registerAutoStart enables and starts the systemd service. Do not
193
+ // also launch a detached copy that can leave mesh healthy while preview is
194
+ // duplicated, blocked, or running under a different HOME.
195
+ if (opts.start && !(opts.autoStart && process.platform === 'linux')) {
191
196
  const robotArgv = [process.execPath, process.argv[1], ...runtimeArgs];
192
197
  console.log(chalk.dim(' starting unified robot runtime…'));
193
198
  runDetached(robotArgv[0], robotArgv.slice(1));
194
199
  }
195
200
  if (opts.autoStart) {
196
- const robotArgv = [process.execPath, process.argv[1], ...runtimeArgs, '--foreground'];
201
+ // Enrollment is a one-time bootstrap action. Persisting it in the
202
+ // supervisor would erase the device identity and attempt a new enrollment
203
+ // on every reboot, creating duplicate robots (or failing after the token
204
+ // is consumed).
205
+ const bootRuntimeArgs = runtimeArgs.filter((arg, index) => arg !== '--enrollment-token' && runtimeArgs[index - 1] !== '--enrollment-token');
206
+ const robotArgv = [process.execPath, process.argv[1], ...bootRuntimeArgs, '--foreground'];
197
207
  for (const role of ['robot', 'robot-preview-agent', 'robot-vision-agent']) {
198
208
  unregisterAutoStart(role, internal.name);
199
209
  }
@@ -135,6 +135,7 @@ program
135
135
  .option('--enrollment-token <token>', 'one-time enrollment token')
136
136
  .option('--video-device <dev>', 'camera: auto, /dev/video0, 0, picamera', 'auto')
137
137
  .option('--audio-device <dev>', 'microphone: default, none, or name/index', 'default')
138
+ .option('--robovision-url <url>', 'RoboVision base URL used for camera streaming and hardware inventory')
138
139
  .option('--width <px>', 'video width', '640')
139
140
  .option('--height <px>', 'video height', '480')
140
141
  .option('--fps <n>', 'video fps', '15')
@@ -183,7 +184,9 @@ robotProgram
183
184
  await roboparkRobotRuntime({
184
185
  name: opts.name,
185
186
  hubUrl: opts.hubUrl,
186
- schedulerUrl: opts.schedulerUrl ?? `http://${hub.hostname}:8080`,
187
+ schedulerUrl: opts.schedulerUrl ?? (opts.tailscale
188
+ ? `${opts.hubUrl.replace(/\/+$/, '')}/robopark`
189
+ : `http://${hub.hostname}:8080`),
187
190
  token: opts.token,
188
191
  port: opts.port,
189
192
  network: opts.tailscale ? 'tailscale' : 'lan',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.58",
3
+ "version": "2.8.60",
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",
@@ -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,
@@ -302,16 +337,26 @@ def _get_lan_ip() -> Optional[str]:
302
337
  return None
303
338
 
304
339
 
305
- async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> Optional[bool]:
340
+ async def _send_heartbeat(
341
+ scheduler_url: str, device_id: str, token: str
342
+ ) -> tuple[Optional[bool], bool]:
306
343
  try:
307
344
  inventory = _get_device_inventory()
345
+ headers = {"Authorization": f"Bearer {token}", **_mesh_proxy_headers()}
308
346
  async with httpx.AsyncClient() as client:
309
347
  response = await client.post(
310
348
  f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
311
349
  json={"status": "online", "ip": _get_lan_ip(), "device_inventory": inventory},
312
- headers={"Authorization": f"Bearer {token}"},
350
+ headers=headers,
313
351
  timeout=10.0,
314
352
  )
353
+ if response.status_code in (401, 404):
354
+ logger.warning(
355
+ "heartbeat credentials rejected for %s (%s)",
356
+ device_id,
357
+ response.status_code,
358
+ )
359
+ return None, False
315
360
  response.raise_for_status()
316
361
  logger.info(
317
362
  "heartbeat accepted: inventory source=%s camera=%d mic=%d speaker=%d",
@@ -320,10 +365,10 @@ async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> Opt
320
365
  len(inventory.get("audio_input", [])),
321
366
  len(inventory.get("audio_output", [])),
322
367
  )
323
- return bool(response.json().get("production_mode", False))
368
+ return bool(response.json().get("production_mode", False)), True
324
369
  except Exception as e:
325
370
  logger.warning(f"heartbeat failed: {e}")
326
- return None
371
+ return None, True
327
372
 
328
373
 
329
374
  @dataclass
@@ -384,19 +429,43 @@ class PreviewAgent:
384
429
  self._last_device_config_poll = 0.0
385
430
  self._motion_reference = None
386
431
  self._last_motion_sample = 0.0
387
- self._motion_capture: Optional[VideoCapture] = None
388
- self._motion_capture_lock = asyncio.Lock()
389
-
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:
432
+ self._motion_capture: Optional[VideoCapture] = None
433
+ self._motion_capture_lock = asyncio.Lock()
434
+ self._mesh_bootstrap_ready = False
435
+ self._next_mesh_bootstrap = 0.0
436
+
437
+ async def _ensure_mesh_identity(self) -> bool:
438
+ if not _mesh_proxy_headers():
439
+ return bool(self.device_id and self.device_token)
440
+ now = time.monotonic()
441
+ if self._mesh_bootstrap_ready:
442
+ return True
443
+ if now < self._next_mesh_bootstrap:
444
+ return False
445
+ self._next_mesh_bootstrap = now + 5.0
446
+ try:
447
+ self.device_id, self.device_token = await _bootstrap_mesh_device(
448
+ self.scheduler_url, self.robot_id
449
+ )
450
+ self._mesh_bootstrap_ready = True
451
+ return True
452
+ except Exception as exc:
453
+ logger.warning("mesh device bootstrap failed: %s", exc)
454
+ return False
455
+
456
+ async def run(self) -> None:
457
+ enrollment_token = os.getenv("ENROLLMENT_TOKEN") or self.enrollment_token
458
+ if _mesh_proxy_headers():
459
+ await self._ensure_mesh_identity()
460
+
461
+ if not self.device_token and enrollment_token:
393
462
  self.device_token = await _enroll(self.scheduler_url, enrollment_token, self.robot_id)
394
463
 
395
464
  if not self.device_token:
396
465
  logger.error("no DEVICE_TOKEN and no ENROLLMENT_TOKEN; cannot poll scheduler")
397
466
  sys.exit(1)
398
467
 
399
- self._session = httpx.AsyncClient()
468
+ self._session = httpx.AsyncClient(headers=_mesh_proxy_headers())
400
469
 
401
470
  # Must come after self._session exists — _resolve_device_id() guards
402
471
  # on it and silently no-ops otherwise. On a fresh enroll (no cached
@@ -703,13 +772,18 @@ class PreviewAgent:
703
772
  await asyncio.to_thread(_play_audio_effect, self.audio_output_device, "disconnect")
704
773
  self._current_state = PreviewState()
705
774
 
706
- async def _heartbeat_loop(self) -> None:
707
- while not self._shutdown.is_set():
708
- if self.device_id and self.device_token:
709
- production_mode = await _send_heartbeat(
710
- self.scheduler_url, self.device_id, self.device_token
711
- )
712
- if production_mode is not None:
775
+ async def _heartbeat_loop(self) -> None:
776
+ while not self._shutdown.is_set():
777
+ if _mesh_proxy_headers() and not self._mesh_bootstrap_ready:
778
+ await self._ensure_mesh_identity()
779
+ if self.device_id and self.device_token:
780
+ production_mode, credentials_ok = await _send_heartbeat(
781
+ self.scheduler_url, self.device_id, self.device_token
782
+ )
783
+ if not credentials_ok:
784
+ self._mesh_bootstrap_ready = False
785
+ self._next_mesh_bootstrap = 0.0
786
+ if production_mode is not None:
713
787
  self.production_mode = production_mode
714
788
  if not production_mode and self._vision_session_id:
715
789
  await self._end_vision_session()
@@ -1630,7 +1704,9 @@ def main() -> None:
1630
1704
  "video_device": args.video_device,
1631
1705
  "audio_device": args.audio_device,
1632
1706
  "robovision_url": args.robovision_url or os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"),
1633
- "use_robovision_camera": bool(args.robovision_url),
1707
+ "use_robovision_camera": bool(args.robovision_url) or os.getenv(
1708
+ "ROBOVISION_CAMERA", ""
1709
+ ).lower() in ("1", "true", "yes", "on"),
1634
1710
  "video_width": args.width,
1635
1711
  "video_height": args.height,
1636
1712
  "video_fps": args.fps,