Skip to content

Add ai.harnesses.experimental and ai.workspaces.experimental - #318

Open
kajogo777 wants to merge 18 commits into
mainfrom
harnesses-experimental
Open

kajogo777 wants to merge 18 commits into
mainfrom
harnesses-experimental

Conversation

@kajogo777

@kajogo777 kajogo777 commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Moves harness-sdk into ai as two experimental areas: ai.harnesses.experimental drives Claude Code and Codex (run, stream, steer, stop, approve, resume, fork, TUI), and ai.workspaces.experimental says where they run (local or Vercel Sandbox).

from ai.harnesses import experimental as harnesses
from ai.workspaces import experimental as workspaces

async with harnesses.claude_code(workspace=workspaces.Local(".")) as agent:
    result = await agent.run("What does this repo do?")
  • New extras claude-code and sandbox, checked at construction (InstallationError). import ai loads neither.
  • Examples in examples/harnesses/, plus examples/apps/afk and examples/apps/deepysec (without its vulnerable fixture).
  • Docs: basics/harnesses, basics/workspaces, and their reference pages.
  • Live tests are opt-in (-m live); plain pytest stays offline.
  • CI: a no-extras job, and claude-agent-sdk in the SDK compatibility job.

Proposal defaults taken: two top-level areas, ToolCall keeps its name, live tests stay out of CI.

Verified: full CI check list, the live suite against real CLIs and sandboxes, and every harness example, afk, and deepysec end to end.

Before merge: vercel/vercel-py#415 is merged and vercel-sandbox now tracks its main; once it's released, set the sandbox extra's floor and drop the git pins. Rerun pytest -m sandbox on the merged API.

Move harness-sdk into ai as two experimental areas. A harness drives a
coding-agent CLI someone else owns (Claude Code, Codex): run, stream
ai.types events, steer, stop, approve tool calls, resume, fork, hand off,
and open the CLI's own TUI. A workspace is where it runs: a local
directory or a Vercel Sandbox microVM.

- Errors gain an Error suffix and split into harnesses and workspaces
  errors modules; both bases subclass ai.AIError.
- claude_code() and VercelSandbox() check their extras (claude-code,
  sandbox) at construction and raise ai.errors.InstallationError.
- codex() refuses sandbox modes that cannot run inside a microVM, and
  codex transcripts name tool calls by native item kind.
- Examples in examples/harnesses, plus the afk and deepysec apps.
- Docs pages, reference pages, skill notes, and contributor guidance.
@vercel

vercel Bot commented Sep 29, 2026

Copy link
Copy Markdown

You must have Developer access to commit code to Vercel Labs on Vercel. If you contact an administrator and receive Developer access, commit again to see your changes.

Learn more: https://vercel.com/docs/accounts/team-members-and-roles/access-roles#team-level-roles

@kajogo777 kajogo777 added the feature New functionality label Sep 29, 2026
@socket-security

socket-security Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

@kajogo777
kajogo777 requested review from 1st1 and anbuzin September 29, 2026 17:25
@vercel-security-reviewer

Copy link
Copy Markdown

Security review details

vercel/vercel-py#415 merged to main (not released yet). Point the dev
group and the afk and deepysec apps at main; uv.lock pins the commit.

The merged API moved terminal I/O onto `InteractiveSession.stream`, an
anyio byte stream that raises at end of stream instead of returning
b"". Use it, and treat a raise in the ready-marker loops as the end.
Local.pty() started the holder with asyncio.create_subprocess_exec, and
on Linux asyncio kills a still-tracked child when its event loop closes,
so every named pty ended with the process that created it. The holder
now daemonizes (--daemon) and releases its launcher over a pipe once its
socket and pid file exist; Local.pty() waits for the launcher. Reading
the pid file no longer races the holder's cleanup.
The no-extras job installs ai without extras and checks that claude_code() and VercelSandbox() raise InstallationError, and that import ai loads neither optional package. The compatibility job adds claude-agent-sdk at its minimum and latest versions, since the claude-code extra depends on its private transport API.
vercel-sandbox 0.7 takes credentials per client rather than per call, so
VercelSandbox(token=, team_id=, project_id=) failed with a TypeError.
Explicit credentials now go through a SandboxClient with a credentials
factory, closed with the workspace. Missing pieces come from the
environment, and a project (OIDC) token alone is enough: it names its
own team and project.

vercel-sandbox on vercel-py main needs an unreleased vercel-internal-core,
so the dev group and the deepysec app take vercel-internal-core and
vercel-oidc from the same main.
The first time afk needs a sandbox it signs in through the Vercel CLI
(running `vercel login` when needed, or saying how to install the CLI),
picks a team (the CLI's current team by default, asked only when there
are several), creates an afk-<random> project, and records only its ids
in ~/.afk/config.json. Every run then mints a short-lived project token
from the login, so there is no `vercel env pull` and nothing to refresh.

The same token reaches AI Gateway, so AI_GATEWAY_API_KEY is optional. The
first push checks once that the team's gateway will serve it, and offers
another team when it will not. `afk setup` chooses a team again.

afk takes vercel-internal-core and vercel-oidc from the same vercel-py
main as vercel-sandbox: `uv tool install` does not read uv.lock.
… at a time

claude switches the terminal to kitty's keyboard protocol, and the bridge
forwards that mode to the real terminal like any output. Ctrl-] then
arrives as ESC[93;5u rather than 0x1d, so the detach key did nothing while
claude ran. The bridge now knows the key in kitty's form and in xterm's
modifyOtherKeys form too.

The bridge sends each keystroke from a task of its own, and the sandbox's
interactive stream refuses a second writer mid-send with anyio's
BusyResourceError, reported as "the sandbox terminal connection dropped".
FramedPty now sends one frame at a time, in order; detach() and close()
wait at most 10s behind a stuck send, so a raw-mode terminal is never held.
The list, drawn with Textual's inline mode under your prompt rather than
over the whole screen. Arrow keys choose a conversation; Enter or a letter
runs the command shown for it (push, push --bg, attach or peek, pull, pull
--files, stop, which asks first), exactly as if you had typed it. The
picker leaves that command in the scrollback, by the name the list showed;
q, Esc or Ctrl-C leaves the list there instead. Local conversations are
there to pick at once, the pushed ones as their sandboxes answer.

The picker only returns argv, and the CLI runs it after the app has
exited: an inline Textual app cannot suspend, and a harness's TUI needs
the whole terminal. Piped, and for every explicit verb, afk is unchanged.
A sandbox process's stdin is an interactive stream that refuses a second
sender mid-send (anyio's BusyResourceError), and callers write from several
tasks: codex's JSON-RPC client steering a turn while it answers the agent.
The live suite's codex steer test failed in a sandbox on exactly that,
"Another task is already writing to this resource". SandboxProcess.write
now takes a lock around opening the conduit and sending, as FramedPty does,
so two first writes also never open two conduits.
The picker's remote rows get `p` for peek. It reads the transcript, so it
watches a TUI as well as an unattended agent; where Enter already peeks,
the key works but is not shown twice.

A sandbox afk cannot ask right now (network, credentials) reached afk as a
WorkspaceError, which the list did not catch: `afk` ended in a traceback,
and in the picker the probe's failure closed the app. It is now listed
`unreachable` and its record kept, as the README says, and a verb that
meets one prints a line, not a traceback. The live test for this failed
before; it passes now.
`image=` names the root filesystem a new VM boots from: a Vercel Container
Registry reference (a managed image such as vercel/sandbox/python:3.14, a
project repository, or a team-scoped one, by tag or digest). Omitted, the
create call carries no image key at all, so the platform's default stays
the platform's choice.

Like egress, the image is fixed when the VM is created. It is refused with
name= (a reconnect keeps the image its VM booted from) and with snapshot=
(a snapshot carries its own filesystem), in the constructor, before
anything boots. `sandbox.image` reports what the platform says the VM
booted from once open, including after a reconnect by name, which is how
a caller like afk that only keeps names can still know it. Measured: the
platform reports a digest, not the tag (vercel/sandbox/node:24 comes back
as vercel/sandbox/node@sha256:...).

Two things only a non-default image reaches, fixed here:

- `exec` of a command not on PATH raised the platform's
  executable_not_found error; it is now exit 127, as in `Local.exec`. The
  harness adapters probe `<cli> --version` and install on a non-zero exit,
  but the default image ships both CLIs, so that path had never run in a
  sandbox.
- A process's stdin pump runs python3 in the VM. node:24 and ubuntu have
  none, and the first write failed as a bare ClosedResourceError; the
  conduit now raises a WorkspaceError naming python3. The docs say what an
  image needs for harnesses.

Offline tests pin the refusals, the create request, exit 127 and the
conduit error. Live tests (-m sandbox) boot ubuntu and node:24 and check
what is on PATH, the reported digest, the workdir and copy in a custom
image, a reconnect reporting the image, a missing image failing with 404,
the python3 error, and both harnesses answering in node:24 once python3
is installed (over https: the gateway's firewall resets plain http).
`SessionInfo.running` read only the SDK's own lock, so a `claude` or
`codex` a person typed in another terminal listed as idle. It now reports
any process that has the conversation open, and `SessionInfo.pid` says
which one, when known.

- claude records each of its processes in <config>/sessions/<pid>.json,
  with the conversation it is on and its start time (UTC, as `ps` prints
  it). A record is a holder only while `ps` shows that pid with that start
  time: a record left behind, or a pid reused by a later process, is not.
- codex records nothing per process. Its writer lock is held by the
  app-server daemon its TUI talks to, not by the TUI, and stays held a
  while after the TUI exits (measured, codex 0.159), so it cannot name a
  process. A codex whose command line names the thread
  (`codex resume <id>`, `codex exec resume <id>`) is found; a fresh
  `codex` is not.
- Both read the workspace's own `ps` and files, so this holds in a
  sandbox as on this machine, and merge with the SDK lock's holders, whose
  pid the lock now returns.

`resume()` still refuses only clients holding the SDK lock; the docs say
so. Offline tests pin the record matching (live, left behind, reused pid,
malformed), the lock merge, and which codex command lines name a thread,
with real processes standing in for the CLIs.
…ewhere

In the picker a key now means one thing on every row it is on, and Enter
puts you in the conversation wherever it is:

- here, it hands the terminal to the harness's own resume
  (`claude --resume <id>`, `codex resume <id>`): afk execs it, so the
  scrollback shows the command that ran. Push moves to `u` (`b` is still
  push --bg), since `p` already peeks.
- open in another terminal, it peeks: resuming it too would make two
  writers. `p` peeks any conversation here, and `s` stops the process it
  is open in, asking first and naming the pid. Stop is offered only when
  the harness can say which process that is.

`afk peek <id>` and `afk stop <id>` look here when nothing pushed matches.
Peek reads this directory's store; stop sends SIGTERM, so that terminal gets
its prompt back and the conversation stays, to resume. Neither signs in to
Vercel unless something was pushed from here.

Which conversations are open, and by which process, comes from the SDK's
`SessionInfo.running` and `.pid`: any `claude`, and a `codex` started as
`codex resume <id>`. A fresh `codex` names no conversation and shows idle.

Tested offline (picker keys, local stop and peek) and end to end against
a real claude TUI in a pty: listed in use, peeked, stopped from the CLI and
from the picker, then resumed from the picker.
…hen idle

Enter on a conversation open in another terminal now takes it over, as
attach does a pushed TUI open elsewhere: after a y, the picker runs
`afk stop <id> && claude --resume <id>`, so that process ends first and the
TUI opens here. A picker key now stands for a chain of commands, run in
order, each only if the one before succeeded; the scrollback shows them
joined by &&. Where afk cannot name the process, Enter still peeks.

`afk stop` on a conversation here waits until the process has exited, so
a take-over's resume never overlaps it, and says so if it does not exit
within 10s.

Peek follows like `tail -f`, which nothing on screen said. On a terminal
it now keeps a status line under the transcript, redrawn in place:
"following <id> · nothing new for Ns · Ctrl-C to stop", replaced by why it
ended. It also ended only when the transcript ended on the agent's reply;
an interrupted turn or an unanswered prompt never does, so a session still
open was followed forever (3 of the 10 conversations in this repo end that
way). A conversation a person drives, here or a pushed TUI, now stops being
followed after 30s with nothing new, unless a tool call is out. Ctrl-C says
the agent keeps going.

Tested offline (picker chains and confirmations, stop waiting and timing
out against a process that ignores SIGTERM, peek's quiet stop, a pending
tool call, the status line, piped output) and end to end in a pty against
a real claude TUI: the status line from the picker, and Enter taking over.
…atus

The holder sent the exit frame and closed right away. A client still
sending (the bridge's first resize) then wrote to a closed socket, and
asyncio drops what it has received but not read yet when a write fails:
the output and the exit frame were lost, and tty.bridge reported the
connection ended. Seen in CI as test_bridge_shows_a_program_that_ended_
before_it_was_ready failing on Linux.

The holder now stops writing after the exit frame and reads until the
client hangs up, bounded by LINGER, before closing.
verbs only imports it, and mypy does not let a test reach an imported
name through another module, which failed check-examples in CI.
@kajogo777
kajogo777 force-pushed the harnesses-experimental branch from 0adfe94 to f184ecc Compare October 9, 2026 00:30
`afk attach` on a conversation pushed with --bg opened nothing: only a
TUI could be attached, and Enter on such a row peeked. Now attach ends the
unattended agent that still holds the conversation (asking first if it is
mid-turn, since that ends the turn), waits until it has exited, and opens
the TUI on the same conversation in its sandbox. The row becomes a TUI from
then on, so the next attach reconnects to its pty. Enter in the picker
attaches on every remote row.
@kajogo777
kajogo777 marked this pull request as ready for review October 9, 2026 00:42

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant