Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions docs/architecture/local-codex-tui-actor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# Local Codex TUI Actor Contract

Date: 2026-06-02

Status: first implementation slice on issue #28. The repo now supports one
explicit local actor with sanitized lifecycle evidence and fail-fast Codex
workspace-trust preflight; four-lane fanout and trust bootstrap remain
follow-up work.

## Goal

Let `mimetic run` dogfood `mimetic-cli` with a real local Codex TUI actor while
preserving the public-safe harness contract:

```text
spawn actor
-> submit bounded prompt
-> stream sanitized lifecycle events
-> write run bundle
-> render Observer while active
-> verify final bundle
```

This actor proves the local harness substrate. It must not claim target product
behavior beyond the commands and artifacts it actually observes.

## Explicit Opt-In

The first live actor must require an explicit flag or env var. Non-dry-run
without opt-in should continue to fail closed.

Suggested gate:

```bash
mimetic run --actor codex-tui --sims 1 --timeout-ms 120000
```

or:

```bash
MIMETIC_ENABLE_LOCAL_CODEX_TUI=1 mimetic run --sims 1
```

Provider spend, E2B, GitHub mutation, and external network calls remain off
unless separately and explicitly requested.

For deterministic tests or local substrate debugging, `MIMETIC_CODEX_ACTOR_COMMAND`
can point at a safe fixture command. Real Codex TUI launch uses a Linux
`script` PTY wrapper when available because the Codex TUI requires a terminal.

## Lifecycle Events

The actor should append deterministic events to `events.ndjson`:

| Event | Required fields |
| --- | --- |
| `actor.spawned` | `simId`, `streamId`, command name, cwd, startedAt |
| `actor.prompt.submitted` | prompt digest, prompt class, no raw prompt if unsafe |
| `actor.observation` | sanitized transcript tail, byte count, redaction status |
| `actor.artifact` | relative artifact path, kind, digest |
| `actor.verdict` | `passed`, `failed`, `blocked`, or `timed_out`; reason |
| `actor.exited` | exit code, signal, durationMs |
| `actor.timeout` | timeoutMs, last safe observation |
| `actor.cancelled` | signal and operator reason |

The Observer should be able to render a live terminal stream from those events
while the actor is active, then render the final transcript and artifacts after
exit.

## Runtime State

All generated state stays under ignored `.mimetic/`:

```text
.mimetic/runs/<run-id>/
run.json
review.json
review.md
events.ndjson
actor.json
transcripts/codex-tui-sanitized.txt
observer/
index.html
observer-data.json
```

No raw terminal output is public by default. The transcript artifact must be
sanitized before it is linked from the bundle.

## Redaction Rules

Before any transcript tail or event payload is written:

- redact OpenAI, E2B, GitHub, npm, and generic private-key patterns;
- redact absolute local home/workspace paths when they are not necessary for
proof;
- block the run if redaction cannot prove `status: passed`;
- record env var names only, never values.

## Initial Prompt Class

The first actor prompt should be bounded to public-safe dogfood work:

- inspect `mimetic/` dogfood config;
- run `pnpm mimetic -- doctor`;
- run or explain the strongest safe Mimetic proof command available;
- do not commit, push, publish, file issues, or print secrets;
- summarize blockers using public-safe evidence paths.

The raw prompt can live in source only if it contains no private context and no
credential values.

## Stop Conditions

The actor must stop and mark the lane `blocked` or `failed` if:

- Codex CLI is not installed or not authenticated;
- a command requests approval in a non-interactive run;
- redaction fails;
- output contains a likely secret after redaction;
- the actor touches files outside allowed runtime paths without explicit scope;
- timeout is reached.

## Known First-Slice Boundary

The first real local TUI proof in this environment reached Codex's workspace
trust prompt. The current implementation now detects that state before spawn
and writes a `blocked` run bundle instead of waiting for the TUI timeout. The
resulting bundle is still verifiable and Observer-renderable, but it does not
prove autonomous actor completion until the trust root is explicitly approved.

The next implementation slice should add an explicit, public-safe trust
bootstrap before spawning the TUI. Acceptable fixes include:

- allow an explicit operator-approved trust bootstrap for this repository only;
- or add a non-TUI `codex exec` actor mode as a separate contract while keeping
the TUI actor honest about needing a PTY and trust.

## Acceptance For First Slice

The implementation slice for this spec should prove:

```bash
pnpm mimetic:doctor
pnpm mimetic -- run --actor codex-tui --sims 1 --timeout-ms 120000 --json
pnpm mimetic -- watch --run latest --detach --json --no-open
pnpm mimetic -- verify --run latest --json
pnpm check
```

If the 1x actor works, split or follow with a 4x fanout issue. Do not start with
4x fanout before the single actor lifecycle is deterministic.
32 changes: 23 additions & 9 deletions src/observer-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -952,18 +952,22 @@ a { color: inherit; }
background: var(--obs-blue);
}

.tile-cap[data-status="failed"] .tile-cap-bar > span,
.tile-cap[data-status="blocked"] .tile-cap-bar > span { background: var(--obs-red); }
.tile-cap[data-status="failed"] .tile-cap-bar > span { background: var(--obs-red); }
.tile-cap[data-status="blocked"] .tile-cap-bar > span,
.tile-cap[data-status="timed_out"] .tile-cap-bar > span { background: var(--obs-amber); }

.tile-cap[data-status="passed"] .tile-cap-bar > span,
.tile-cap[data-status="complete"] .tile-cap-bar > span,
.tile-cap[data-status="contract_proof_only"] .tile-cap-bar > span { background: var(--obs-green); }

.pip[data-status="queued"] { color: var(--obs-fg-4); }
.pip[data-status="preparing"] { color: var(--obs-sky); }
.pip[data-status="running"] { color: var(--obs-blue); }
.pip[data-status="passed"] { color: var(--obs-green); }
.pip[data-status="complete"] { color: var(--obs-green); }
.pip[data-status="contract_proof_only"] { color: var(--obs-green); }
.pip[data-status="blocked"] { color: var(--obs-amber); }
.pip[data-status="timed_out"] { color: var(--obs-amber); }
.pip[data-status="failed"] { color: var(--obs-red); }

.focus {
Expand Down Expand Up @@ -1081,7 +1085,8 @@ a { color: inherit; }
color: #8aa9ff;
}

.focus-status-badge[data-status="blocked"] {
.focus-status-badge[data-status="blocked"],
.focus-status-badge[data-status="timed_out"] {
background: var(--obs-amber-soft);
color: #e9b04b;
}
Expand All @@ -1091,6 +1096,7 @@ a { color: inherit; }
color: #ee8a7e;
}

.focus-status-badge[data-status="passed"],
.focus-status-badge[data-status="complete"],
.focus-status-badge[data-status="contract_proof_only"] {
background: var(--obs-green-soft);
Expand Down Expand Up @@ -1619,7 +1625,7 @@ export function observerClientJs(): string {
var surface = document.createElement("div");
surface.className = "media-surface";

if (preferScreenshots && stream.ui && stream.ui.screenshotUrl) {
if (stream.ui && stream.ui.screenshotUrl && (preferScreenshots || (stream.embed && stream.embed.kind === "screenshot"))) {
var img = document.createElement("img");
img.src = stream.ui.screenshotUrl;
img.alt = "Screenshot for " + stream.id;
Expand Down Expand Up @@ -1690,7 +1696,15 @@ export function observerClientJs(): string {
spinner.setAttribute("aria-hidden", "true");
var title = document.createElement("div");
title.className = "live-waiting-title";
title.textContent = stream.status === "contract_proof_only" ? "Contract proof only" : "Waiting for live desktop";
title.textContent = stream.status === "contract_proof_only"
? "Contract proof only"
: stream.status === "failed"
? "Live desktop failed"
: stream.status === "timed_out"
? "Live desktop timed out"
: stream.status === "blocked"
? "Live desktop blocked"
: "Waiting for live desktop";
var url = document.createElement("div");
url.className = "live-waiting-url";
url.textContent = stream.ui && stream.ui.route ? stream.ui.route : stream.label;
Expand Down Expand Up @@ -2071,8 +2085,8 @@ export function observerClientJs(): string {
}
if (activeStatus === "all") return true;
if (activeStatus === "running") return stream.status === "running" || stream.status === "preparing";
if (activeStatus === "blocked") return stream.status === "blocked" || stream.status === "failed";
if (activeStatus === "complete") return stream.status === "complete";
if (activeStatus === "blocked") return stream.status === "blocked" || stream.status === "failed" || stream.status === "timed_out";
if (activeStatus === "complete") return stream.status === "complete" || stream.status === "passed";
if (activeStatus === "proof") return stream.status === "contract_proof_only";
return true;
});
Expand All @@ -2081,9 +2095,9 @@ export function observerClientJs(): string {
function countStatuses(streams) {
return streams.reduce(function (acc, stream) {
if (stream.status === "running" || stream.status === "preparing") acc.running += 1;
else if (stream.status === "blocked") acc.blocked += 1;
else if (stream.status === "blocked" || stream.status === "timed_out") acc.blocked += 1;
else if (stream.status === "failed") acc.failed += 1;
else if (stream.status === "complete") acc.complete += 1;
else if (stream.status === "complete" || stream.status === "passed") acc.complete += 1;
else if (stream.status === "contract_proof_only") acc.proof += 1;
else acc.queued += 1;
return acc;
Expand Down
8 changes: 6 additions & 2 deletions src/observer-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface ObserverData {
run: {
runId: string;
mode: RunBundle["mode"];
status: "contract_proof_only" | "running" | "complete" | "failed";
status: RunBundle["review"]["verdict"];
title: string;
createdAt: string;
simCount: number;
Expand Down Expand Up @@ -74,7 +74,7 @@ export function buildObserverData(bundle: RunBundle, generatedAt = new Date().to
});

const warnings = events.filter((event) => event.level === "warn").length;
const blocked = streams.filter((stream) => stream.status === "blocked" || stream.status === "failed").length;
const blocked = streams.filter((stream) => stream.status === "blocked" || stream.status === "failed" || stream.status === "timed_out").length;
const active = streams.filter((stream) => stream.status === "running" || stream.status === "preparing").length;

return {
Expand Down Expand Up @@ -175,10 +175,14 @@ function statusLabel(status: RunStream["status"]): string {
return "Queued";
case "running":
return "Running";
case "passed":
return "Passed";
case "complete":
return "Complete";
case "blocked":
return "Blocked";
case "timed_out":
return "Timed out";
case "failed":
return "Failed";
}
Expand Down
Loading