@team-agent/installer 0.2.1 → 0.2.3

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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/schemas/team.schema.json +6 -0
  3. package/src/team_agent/approvals/runtime_prompts.py +1 -1
  4. package/src/team_agent/cli/commands.py +122 -6
  5. package/src/team_agent/cli/parser.py +42 -1
  6. package/src/team_agent/coordinator/__main__.py +21 -2
  7. package/src/team_agent/coordinator/lifecycle.py +11 -0
  8. package/src/team_agent/diagnose/orphan_cleanup.py +364 -0
  9. package/src/team_agent/events.py +47 -0
  10. package/src/team_agent/launch/core.py +2 -1
  11. package/src/team_agent/leader/__init__.py +273 -60
  12. package/src/team_agent/lifecycle/agents.py +54 -2
  13. package/src/team_agent/lifecycle/operations.py +87 -9
  14. package/src/team_agent/lifecycle/start.py +1 -1
  15. package/src/team_agent/message_store/core.py +8 -7
  16. package/src/team_agent/message_store/leader_notification_log.py +132 -0
  17. package/src/team_agent/message_store/result_watchers.py +144 -1
  18. package/src/team_agent/message_store/schema.py +31 -2
  19. package/src/team_agent/messaging/delivery.py +293 -1
  20. package/src/team_agent/messaging/idle_alerts.py +109 -9
  21. package/src/team_agent/messaging/leader.py +179 -10
  22. package/src/team_agent/messaging/leader_api_errors.py +216 -0
  23. package/src/team_agent/messaging/leader_panes.py +393 -23
  24. package/src/team_agent/messaging/result_delivery.py +219 -4
  25. package/src/team_agent/messaging/results.py +12 -21
  26. package/src/team_agent/messaging/scheduler.py +24 -2
  27. package/src/team_agent/messaging/send.py +21 -26
  28. package/src/team_agent/messaging/tmux_io.py +153 -23
  29. package/src/team_agent/messaging/tmux_prompt.py +87 -0
  30. package/src/team_agent/messaging/trust_auto_answer.py +44 -0
  31. package/src/team_agent/restart/orchestration.py +207 -4
  32. package/src/team_agent/runtime.py +7 -7
  33. package/src/team_agent/rust_core.py +157 -3
  34. package/src/team_agent/sessions/capture.py +65 -15
  35. package/src/team_agent/spec.py +59 -0
  36. package/src/team_agent/state.py +153 -10
  37. package/src/team_agent/status/inbox.py +33 -3
  38. package/src/team_agent/status/queries.py +32 -1
  39. package/src/team_agent/watch/__init__.py +145 -0
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
4
+
3
5
  from team_agent.messaging.deps import (
4
6
  EventLog,
5
7
  RuntimeError,
@@ -9,6 +11,7 @@ from team_agent.messaging.deps import (
9
11
  _tmux_current_client_pane_info as _runtime_tmux_current_client_pane_info,
10
12
  _tmux_list_panes as _runtime_tmux_list_panes,
11
13
  _tmux_pane_info as _runtime_tmux_pane_info,
14
+ _tmux_inject_text,
12
15
  core_list_targets,
13
16
  datetime,
14
17
  os,
@@ -20,6 +23,8 @@ from team_agent.messaging.deps import (
20
23
  from pathlib import Path
21
24
  from typing import Any
22
25
 
26
+ _AMBIGUOUS_DEBOUNCE_SECONDS = 60
27
+
23
28
  def _resolve_leader_pane(
24
29
  pane: str | None,
25
30
  provider: str,
@@ -208,17 +213,40 @@ def _target_fingerprint(pane_info: dict[str, Any]) -> str:
208
213
  )
209
214
 
210
215
 
216
+ def is_bound_pane_still_valid(state: dict[str, Any], store: Any | None = None) -> dict[str, Any]:
217
+ receiver = dict(state.get("leader_receiver") or {})
218
+ owner = state.get("team_owner") if isinstance(state.get("team_owner"), dict) else {}
219
+ if owner and owner.get("leader_session_uuid") and not receiver.get("leader_session_uuid"):
220
+ receiver["leader_session_uuid"] = owner["leader_session_uuid"]
221
+ return _validate_leader_receiver(receiver)
222
+
223
+
211
224
  def _rediscover_leader_receiver(
212
225
  receiver: dict[str, Any],
213
226
  event_log: EventLog,
214
227
  owner_identity: dict[str, Any] | None = None,
228
+ invalidation_reason: str | None = None,
229
+ team_id: str | None = None,
215
230
  ) -> dict[str, Any]:
216
231
  provider = str(receiver.get("provider") or "codex")
217
- if provider != "codex":
218
- return {"status": "missing", "reason": "rediscovery_only_for_codex"}
232
+ if provider == "fake":
233
+ return {"status": "missing", "reason": "rediscovery_not_supported_for_fake"}
219
234
  targets = core_list_targets()
220
235
  if not targets.get("ok"):
221
236
  event_log.write("leader_receiver.rediscover_failed", provider=provider, error=targets.get("error"))
237
+ # Stage 15 CI fix: when the tmux target scan itself fails (no server, no daemon,
238
+ # CI env without tmux), the caller has no way to recover unless we also emit
239
+ # rebind_required. Without this, _refresh_leader_receiver_or_flag_rebind silently
240
+ # returns and report_result queues against the stale pane with zero audit signal.
241
+ event_log.write(
242
+ "leader_receiver.rebind_required",
243
+ old_pane_id=receiver.get("pane_id"),
244
+ reason=invalidation_reason,
245
+ provider=provider,
246
+ team_id=team_id,
247
+ rediscovery_status="failed",
248
+ error=targets.get("error"),
249
+ )
222
250
  return {"status": "failed", "error": targets.get("error")}
223
251
  candidates = [
224
252
  target
@@ -228,16 +256,26 @@ def _rediscover_leader_receiver(
228
256
  if owner_identity:
229
257
  owner_candidates = [target for target in candidates if _target_matches_owner_identity(target, owner_identity)]
230
258
  if len(owner_candidates) == 1:
231
- return _rediscovered_receiver(receiver, provider, owner_candidates[0], event_log, owner_identity)
259
+ return _rediscovered_receiver(receiver, provider, owner_candidates[0], event_log, owner_identity, invalidation_reason)
232
260
  if len(owner_candidates) > 1:
261
+ incident = _broadcast_ambiguous_candidates(
262
+ receiver,
263
+ provider,
264
+ owner_candidates,
265
+ event_log,
266
+ owner_identity,
267
+ team_id,
268
+ )
233
269
  event_log.write(
234
270
  "leader_receiver.rediscover_ambiguous",
235
271
  provider=provider,
236
272
  old_target=receiver.get("pane_id"),
237
273
  candidates=[target.get("pane_id") for target in owner_candidates],
238
274
  owner_identity=owner_identity,
275
+ incident_id=incident.get("incident_id"),
276
+ deduped=incident.get("deduped"),
239
277
  )
240
- return {"status": "ambiguous", "candidates": owner_candidates, "owner_identity": owner_identity}
278
+ return {"status": "ambiguous", "candidates": owner_candidates, "owner_identity": owner_identity, **incident}
241
279
  event_log.write(
242
280
  "leader_receiver.rediscover_missing",
243
281
  provider=provider,
@@ -245,9 +283,19 @@ def _rediscover_leader_receiver(
245
283
  owner_identity=owner_identity,
246
284
  candidate_count=len(candidates),
247
285
  )
286
+ event_log.write(
287
+ "leader_receiver.rebind_required",
288
+ old_pane_id=receiver.get("pane_id"),
289
+ reason=invalidation_reason,
290
+ provider=provider,
291
+ team_id=team_id,
292
+ uuid_prefix=_uuid_prefix(owner_identity),
293
+ owner_identity=owner_identity,
294
+ recovery_action="open the owning leader pane or run team-agent claim-leader --confirm from a matching pane",
295
+ )
248
296
  return {"status": "missing", "owner_identity": owner_identity}
249
297
  if len(candidates) == 1:
250
- return _rediscovered_receiver(receiver, provider, candidates[0], event_log, None)
298
+ return _rediscovered_receiver(receiver, provider, candidates[0], event_log, None, invalidation_reason)
251
299
  if len(candidates) > 1:
252
300
  event_log.write(
253
301
  "leader_receiver.rediscover_ambiguous",
@@ -255,12 +303,19 @@ def _rediscover_leader_receiver(
255
303
  old_target=receiver.get("pane_id"),
256
304
  candidates=[target.get("pane_id") for target in candidates],
257
305
  )
306
+ event_log.write("leader_receiver.rebind_required", old_pane_id=receiver.get("pane_id"), reason=invalidation_reason, provider=provider, team_id=team_id, rediscovery_status="ambiguous")
258
307
  return {"status": "ambiguous", "candidates": candidates}
259
308
  event_log.write("leader_receiver.rediscover_missing", provider=provider, old_target=receiver.get("pane_id"))
309
+ event_log.write("leader_receiver.rebind_required", old_pane_id=receiver.get("pane_id"), reason=invalidation_reason, provider=provider, team_id=team_id, rediscovery_status="missing")
260
310
  return {"status": "missing"}
261
311
 
262
312
 
263
313
  def _target_matches_owner_identity(target: dict[str, Any], owner_identity: dict[str, Any]) -> bool:
314
+ expected_uuid = owner_identity.get("leader_session_uuid")
315
+ if expected_uuid:
316
+ actual_uuid = _target_leader_session_uuid(target)
317
+ if actual_uuid:
318
+ return actual_uuid == expected_uuid
264
319
  env = target.get("leader_env") if isinstance(target.get("leader_env"), dict) else {}
265
320
  return (
266
321
  env.get("TEAM_AGENT_LEADER_PANE_ID") == (owner_identity.get("pane_id") or "")
@@ -269,14 +324,31 @@ def _target_matches_owner_identity(target: dict[str, Any], owner_identity: dict[
269
324
  )
270
325
 
271
326
 
272
- def _rediscovered_receiver(
273
- receiver: dict[str, Any],
274
- provider: str,
275
- target: dict[str, Any],
276
- event_log: EventLog,
277
- owner_identity: dict[str, Any] | None,
278
- ) -> dict[str, Any]:
279
- updated = {
327
+ def _target_leader_session_uuid(target: dict[str, Any]) -> str:
328
+ env = target.get("leader_env") if isinstance(target.get("leader_env"), dict) else {}
329
+ return str(target.get("leader_session_uuid") or env.get("TEAM_AGENT_LEADER_SESSION_UUID") or "")
330
+
331
+
332
+ def _leader_uuid_for_bound_pane(receiver: dict[str, Any], pane_info: dict[str, Any]) -> str:
333
+ direct = _target_leader_session_uuid(pane_info) or _target_leader_session_uuid(receiver)
334
+ if direct:
335
+ return direct
336
+ targets = core_list_targets()
337
+ if not targets.get("ok"):
338
+ return ""
339
+ pane_id = pane_info.get("pane_id")
340
+ for target in targets.get("targets", []):
341
+ if target.get("pane_id") == pane_id:
342
+ return _target_leader_session_uuid(target)
343
+ return ""
344
+
345
+
346
+ def _uuid_prefix(owner_identity: dict[str, Any] | None) -> str:
347
+ return str((owner_identity or {}).get("leader_session_uuid") or "")[:8]
348
+
349
+
350
+ def _receiver_from_target(target: dict[str, Any], provider: str, leader_uuid: str | None, owner_epoch: int | None = None) -> dict[str, Any]:
351
+ receiver = {
280
352
  "mode": "direct_tmux",
281
353
  "status": "attached",
282
354
  "provider": provider,
@@ -289,8 +361,83 @@ def _rediscovered_receiver(
289
361
  "pane_current_command": target["pane_current_command"],
290
362
  "fingerprint": target.get("fingerprint") or _target_fingerprint(target),
291
363
  "attached_at": datetime.now(timezone.utc).isoformat(),
292
- "discovery": "stale_rediscovery_owner_identity" if owner_identity else "stale_rediscovery_unique_candidate",
293
364
  }
365
+ if leader_uuid:
366
+ receiver["leader_session_uuid"] = leader_uuid
367
+ if owner_epoch is not None:
368
+ receiver["owner_epoch"] = owner_epoch
369
+ return receiver
370
+
371
+
372
+ def _broadcast_ambiguous_candidates(
373
+ receiver: dict[str, Any],
374
+ provider: str,
375
+ candidates: list[dict[str, Any]],
376
+ event_log: EventLog,
377
+ owner_identity: dict[str, Any],
378
+ team_id: str | None,
379
+ ) -> dict[str, Any]:
380
+ candidate_ids = sorted(str(candidate.get("pane_id")) for candidate in candidates)
381
+ bucket = _ambiguous_debounce_bucket()
382
+ incident_id = hashlib.sha256("\0".join([str(team_id or ""), *candidate_ids, bucket]).encode("utf-8")).hexdigest()[:16]
383
+ if any(event.get("event") == "leader_receiver.ambiguous_candidates" and event.get("incident_id") == incident_id for event in event_log.tail(200)):
384
+ return {"incident_id": incident_id, "deduped": True}
385
+ prompt = _ambiguous_candidate_prompt(team_id, len(candidates))
386
+ event_log.write(
387
+ "leader_receiver.ambiguous_candidates",
388
+ incident_id=incident_id,
389
+ old_pane_id=receiver.get("pane_id"),
390
+ candidates=candidate_ids,
391
+ provider=provider,
392
+ team_id=team_id,
393
+ uuid_prefix=_uuid_prefix(owner_identity),
394
+ debounce_bucket=bucket,
395
+ )
396
+ for candidate in candidates:
397
+ pane_id = str(candidate.get("pane_id") or "")
398
+ injected = _tmux_inject_text(
399
+ pane_id,
400
+ prompt,
401
+ "Enter",
402
+ f"team-agent-leader-ambiguous-{incident_id}-{pane_id.strip('%')}",
403
+ provider=provider,
404
+ )
405
+ event_log.write(
406
+ "leader_receiver.ambiguous_candidate_queued",
407
+ incident_id=incident_id,
408
+ pane_id=pane_id,
409
+ ok=bool(injected.get("ok")),
410
+ error=injected.get("error"),
411
+ )
412
+ return {"incident_id": incident_id, "deduped": False}
413
+
414
+
415
+ def _ambiguous_debounce_bucket() -> str:
416
+ now = datetime.now(timezone.utc)
417
+ epoch = int(now.timestamp() // _AMBIGUOUS_DEBOUNCE_SECONDS) * _AMBIGUOUS_DEBOUNCE_SECONDS
418
+ return datetime.fromtimestamp(epoch, timezone.utc).isoformat()
419
+
420
+
421
+ def _ambiguous_candidate_prompt(team_id: str | None, candidate_count: int) -> str:
422
+ others = max(candidate_count - 1, 0)
423
+ return (
424
+ f"Team `{team_id or 'current'}` has no bound leader. This window and {others} other window(s) all qualify. "
425
+ "To claim this window as the team leader, run: `team-agent claim-leader --confirm`. "
426
+ "Only the first such call wins; subsequent calls from other windows will be refused."
427
+ )
428
+
429
+
430
+ def _rediscovered_receiver(
431
+ receiver: dict[str, Any],
432
+ provider: str,
433
+ target: dict[str, Any],
434
+ event_log: EventLog,
435
+ owner_identity: dict[str, Any] | None,
436
+ invalidation_reason: str | None = None,
437
+ ) -> dict[str, Any]:
438
+ leader_uuid = _target_leader_session_uuid(target) or (owner_identity or {}).get("leader_session_uuid") or receiver.get("leader_session_uuid")
439
+ updated = _receiver_from_target(target, provider, leader_uuid)
440
+ updated["discovery"] = "stale_rediscovery_owner_identity" if owner_identity else "stale_rediscovery_unique_candidate"
294
441
  event_log.write(
295
442
  "leader_receiver.rediscovered",
296
443
  provider=provider,
@@ -299,6 +446,14 @@ def _rediscovered_receiver(
299
446
  candidate_count=1,
300
447
  owner_identity=owner_identity,
301
448
  )
449
+ event_log.write(
450
+ "leader_receiver.rebind_applied",
451
+ old_pane_id=receiver.get("pane_id"),
452
+ new_pane_id=updated["pane_id"],
453
+ reason=invalidation_reason,
454
+ owner_identity=owner_identity,
455
+ uuid_prefix=_uuid_prefix(owner_identity),
456
+ )
302
457
  return {"status": "updated", "receiver": updated, "owner_identity": owner_identity}
303
458
 
304
459
 
@@ -306,6 +461,26 @@ def _validate_leader_receiver(receiver: dict[str, Any]) -> dict[str, Any]:
306
461
  pane_info = _runtime_tmux_pane_info(receiver.get("pane_id"))
307
462
  if not pane_info:
308
463
  return {"ok": False, "reason": "leader_pane_missing", "error": "tmux pane does not exist"}
464
+ provider = str(receiver.get("provider") or "codex")
465
+ if not _leader_command_looks_usable(pane_info.get("pane_current_command", ""), provider):
466
+ return {
467
+ "ok": False,
468
+ "reason": "leader_pane_wrong_command",
469
+ "error": f"pane command {pane_info.get('pane_current_command')!r} is not a leader host",
470
+ "pane": pane_info,
471
+ }
472
+ expected_uuid = receiver.get("leader_session_uuid")
473
+ if expected_uuid:
474
+ actual_uuid = _leader_uuid_for_bound_pane(receiver, pane_info)
475
+ if not actual_uuid:
476
+ return {"ok": False, "reason": "leader_uuid_missing", "error": "bound pane has no TEAM_AGENT_LEADER_SESSION_UUID", "pane": pane_info}
477
+ if actual_uuid != expected_uuid:
478
+ return {
479
+ "ok": False,
480
+ "reason": "leader_uuid_mismatch",
481
+ "error": "bound pane TEAM_AGENT_LEADER_SESSION_UUID does not match stored team owner",
482
+ "pane": pane_info,
483
+ }
309
484
  capture = run_cmd(["tmux", "capture-pane", "-p", "-S", "-40", "-t", pane_info["pane_id"]], timeout=5)
310
485
  if capture.returncode != 0:
311
486
  return {
@@ -314,14 +489,7 @@ def _validate_leader_receiver(receiver: dict[str, Any]) -> dict[str, Any]:
314
489
  "error": capture.stderr.strip() or "tmux capture-pane failed",
315
490
  "pane": pane_info,
316
491
  }
317
- warning = None
318
- provider = str(receiver.get("provider") or "codex")
319
- if not _leader_command_looks_usable(pane_info.get("pane_current_command", ""), provider):
320
- warning = (
321
- f"pane command {pane_info.get('pane_current_command')!r} is not a typical {provider} host; "
322
- "continuing because tmux capture works"
323
- )
324
- return {"ok": True, "pane": pane_info, "capture": capture.stdout, "warning": warning}
492
+ return {"ok": True, "pane": pane_info, "capture": capture.stdout, "warning": None}
325
493
 
326
494
 
327
495
  def _leader_command_looks_usable(command: str, provider: str) -> bool:
@@ -330,7 +498,209 @@ def _leader_command_looks_usable(command: str, provider: str) -> bool:
330
498
  command_name = Path(command).name
331
499
  if provider == "codex":
332
500
  return command_name in {"codex", "node", "nodejs"}
333
- return bool(command_name)
501
+ if provider in {"claude", "claude_code"}:
502
+ return command_name in {"claude", "claude.exe"}
503
+ return command_name in {"codex", "node", "nodejs", "claude", "claude.exe"}
504
+
505
+
506
+ def attempt_trust_auto_answer(
507
+ workspace: Path,
508
+ pane_id: str | None,
509
+ pane_capture_tail: str,
510
+ event_log: EventLog,
511
+ *,
512
+ spec: dict[str, Any] | None = None,
513
+ state: dict[str, Any] | None = None,
514
+ ) -> dict[str, Any]:
515
+ """Gap 29 (Slice 2 Stage 2) — opt-in auto-answer of the codex first-run trust prompt.
516
+
517
+ Called by the inject path when developer's structured envelope reports
518
+ detected=='codex_trust_prompt'. Auto-answers ONLY when both:
519
+ (1) runtime is opted in. The PREFERRED opt-in is the per-session env var
520
+ TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE in {1,true,yes,on}. The legacy
521
+ spec.runtime.auto_trust_own_workspace=True path is still honoured for
522
+ backwards compatibility but is DEPRECATED (constitution-reviewer F3:
523
+ a YAML field permanently erases the trust prompt's cognitive moment
524
+ across all sessions, defeating its purpose). The spec path will be
525
+ removed in 0.3.0.
526
+ (2) the trust-prompt pane capture references this workspace's absolute path
527
+ (so a worker can only trust its own dir, never some arbitrary path).
528
+
529
+ On match, sends '1' + Enter to the pane and emits
530
+ leader_panes.trust_auto_answered. Default is opt-out — every refusal returns
531
+ answered=False with a structured reason and the existing failure envelope
532
+ bubbles up unchanged.
533
+
534
+ Return: {"ok": bool, "answered": bool, "reason": str, ...}
535
+ """
536
+ if spec is None and state is not None:
537
+ spec_path_str = state.get("spec_path")
538
+ if spec_path_str:
539
+ try:
540
+ from team_agent.spec import load_spec as _load_spec
541
+ spec = _load_spec(Path(spec_path_str))
542
+ except Exception:
543
+ spec = None
544
+ if not _auto_trust_opt_in(spec, event_log=event_log):
545
+ # Spark LOW #6: emit a structured event so the not-opted-in branch is
546
+ # as observable as the workspace_dir_mismatch / tmux_send_keys_failed
547
+ # branches. Keeps the decision matrix uniformly auditable.
548
+ event_log.write(
549
+ "leader_panes.trust_auto_answer_skipped",
550
+ pane_id=pane_id,
551
+ workspace=str(workspace),
552
+ reason="not_opted_in",
553
+ )
554
+ return {"ok": False, "answered": False, "reason": "not_opted_in"}
555
+ if not pane_id:
556
+ event_log.write(
557
+ "leader_panes.trust_auto_answer_skipped",
558
+ pane_id=None,
559
+ workspace=str(workspace),
560
+ reason="pane_id_missing",
561
+ )
562
+ return {"ok": False, "answered": False, "reason": "pane_id_missing"}
563
+ if not _capture_tail_references_workspace(pane_capture_tail, workspace):
564
+ event_log.write(
565
+ "leader_panes.trust_auto_answer_refused",
566
+ pane_id=pane_id,
567
+ workspace=str(workspace),
568
+ reason="workspace_dir_mismatch",
569
+ )
570
+ return {"ok": False, "answered": False, "reason": "workspace_dir_mismatch"}
571
+ answer = _tmux_inject_text(
572
+ str(pane_id),
573
+ "1",
574
+ "Enter",
575
+ f"team-agent-trust-auto-answer-{str(pane_id).strip('%') or 'pane'}",
576
+ attempts=1,
577
+ provider="fake",
578
+ bypass_non_input_gate=True,
579
+ )
580
+ if not answer.get("ok"):
581
+ error = answer.get("error") or "tmux send-keys failed"
582
+ event_log.write(
583
+ "leader_panes.trust_auto_answer_failed",
584
+ pane_id=pane_id,
585
+ workspace=str(workspace),
586
+ error=error,
587
+ )
588
+ return {"ok": False, "answered": False, "reason": "tmux_send_keys_failed", "error": error}
589
+ event_log.write(
590
+ "leader_panes.trust_auto_answered",
591
+ pane_id=pane_id,
592
+ workspace=str(workspace),
593
+ opted_in=True,
594
+ )
595
+ return {"ok": True, "answered": True, "reason": "trust_auto_answered"}
596
+
597
+
598
+ _SPEC_OPT_IN_DEPRECATION_MESSAGE = (
599
+ "WARNING: spec.runtime.auto_trust_own_workspace is deprecated. "
600
+ "Use env TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE=1 per session instead. "
601
+ "Spec-field will be removed in 0.3.0."
602
+ )
603
+
604
+
605
+ def _auto_trust_opt_in(spec: dict[str, Any] | None, *, event_log: EventLog | None = None) -> bool:
606
+ """Constitution-reviewer F3 (2026-05-26): env-var per-session opt-in is the
607
+ preferred path. spec.runtime.auto_trust_own_workspace remains honoured for
608
+ backwards compatibility but emits a one-shot stderr deprecation warning AND
609
+ a structured trust_auto_answer_spec_opt_in_deprecated event so a normalized
610
+ YAML field is auditable from a fresh log."""
611
+ spec_opted_in = (
612
+ isinstance(spec, dict)
613
+ and bool((spec.get("runtime") or {}).get("auto_trust_own_workspace"))
614
+ )
615
+ if spec_opted_in:
616
+ _emit_spec_opt_in_deprecation(event_log)
617
+ env = os.environ.get("TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE", "").strip().lower()
618
+ env_opted_in = env in {"1", "true", "yes", "on"}
619
+ return env_opted_in or spec_opted_in
620
+
621
+
622
+ def _emit_spec_opt_in_deprecation(event_log: EventLog | None) -> None:
623
+ """Emit the deprecation warning once per process. The structured event still
624
+ fires per call so an audit log captures every yaml-driven decision."""
625
+ import sys
626
+ global _SPEC_OPT_IN_DEPRECATION_WARNED
627
+ if not _SPEC_OPT_IN_DEPRECATION_WARNED:
628
+ try:
629
+ print(_SPEC_OPT_IN_DEPRECATION_MESSAGE, file=sys.stderr, flush=True)
630
+ except Exception:
631
+ pass
632
+ _SPEC_OPT_IN_DEPRECATION_WARNED = True
633
+ if event_log is not None:
634
+ try:
635
+ event_log.write(
636
+ "trust_auto_answer_spec_opt_in_deprecated",
637
+ preferred_opt_in="env:TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE",
638
+ deprecated_field="spec.runtime.auto_trust_own_workspace",
639
+ removal_target_version="0.3.0",
640
+ )
641
+ except Exception:
642
+ pass
643
+
644
+
645
+ _SPEC_OPT_IN_DEPRECATION_WARNED = False
646
+
647
+
648
+ def _reset_spec_opt_in_deprecation_state() -> None:
649
+ """Test-only helper: reset the per-process one-shot guard so multiple cases
650
+ in the same interpreter can each observe the warning. Not part of the
651
+ public API."""
652
+ global _SPEC_OPT_IN_DEPRECATION_WARNED
653
+ _SPEC_OPT_IN_DEPRECATION_WARNED = False
654
+
655
+
656
+ def _capture_tail_references_workspace(tail: str, workspace: Path) -> bool:
657
+ """Spark MEDIUM #5: a raw substring match accepted '/repo' inside
658
+ '/repo-backup' and rejected symlinked / trailing-slash spellings. We now
659
+ canonicalize the workspace via Path.resolve, parse candidate absolute paths
660
+ out of the prompt tail (one token per line after stripping codex box-drawing
661
+ glyphs), canonicalize each candidate the same way, and only return True on
662
+ boundary-safe canonical equality."""
663
+ if not tail:
664
+ return False
665
+ workspace_canonical = _canonicalize_path(workspace)
666
+ if not workspace_canonical:
667
+ return False
668
+ for candidate in _candidate_paths_from_prompt(tail):
669
+ if _canonicalize_path(Path(candidate)) == workspace_canonical:
670
+ return True
671
+ return False
672
+
673
+
674
+ _PATH_LINE_RE = re.compile(r"(/[\w\-./~+@]+)")
675
+
676
+
677
+ def _candidate_paths_from_prompt(tail: str) -> list[str]:
678
+ """Pull every absolute-path-shaped token out of the prompt's tail. Codex
679
+ renders the trust prompt's directory inside box-drawing glyphs and on its
680
+ own line; strip leading/trailing whitespace and glyph noise before matching."""
681
+ paths: list[str] = []
682
+ for raw_line in tail.splitlines():
683
+ line = raw_line.strip()
684
+ # Codex draws box-glyph prefixes (▌ ▎ │) that need to be stripped.
685
+ for glyph in ("▌", "▎", "│"):
686
+ line = line.lstrip(glyph).strip()
687
+ if not line:
688
+ continue
689
+ for match in _PATH_LINE_RE.finditer(line):
690
+ token = match.group(1).rstrip("/")
691
+ if token and token not in paths:
692
+ paths.append(token)
693
+ return paths
694
+
695
+
696
+ def _canonicalize_path(p: Path | str) -> str:
697
+ try:
698
+ resolved = Path(p).expanduser().resolve(strict=False)
699
+ except OSError:
700
+ return ""
701
+ text = resolved.as_posix()
702
+ # Strip a trailing slash so boundary-safe equality holds.
703
+ return text.rstrip("/") if text != "/" else "/"
334
704
 
335
705
 
336
706
  def _choose_leader_submit_key(provider: str, capture_text: str) -> tuple[str, str]: