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
10 changes: 7 additions & 3 deletions platform/cluster/flux/apps/agents/AGENT-PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,15 @@ so the entrypoint seeds them on every boot:
`approval_policy` / `sandbox_mode` from `~/.codex/config.toml` on
the `codex-credentials` PVC. The entrypoint writes a
trusted + non-interactive config only when none exists, so a
hand-edited file on the PVC is never overwritten.
hand-edited file on the PVC is never overwritten. Agent-gateway also
passes `--dangerously-bypass-hook-trust` for the managed runner
process so repo-managed hooks do not block startup on the interactive
"Hooks need review" prompt.

Without this seeding every fresh session re-shows the theme picker
and onboarding wizard (Claude) and the directory-trust prompt
(Codex), all of which block the non-interactive tmux pane.
and onboarding wizard (Claude), the directory-trust prompt (Codex), or
the hook trust prompt (Codex), all of which block the non-interactive
tmux pane.

## Domain / runtime

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,21 @@ class PlatformAgentMcpFluxTest {
.resolve("services/agent-runner/entrypoint.sh")
.toFile()
.readText()
val agentGatewayConfig =
repositoryRoot
.resolve("services/agent-gateway/src/main/resources/application.yml")
.toFile()
.readText()
val githubMcpWrapper =
repositoryRoot
.resolve("services/agent-runner/gh-mcp-wrapper.sh")
.toFile()
.readText()
val githubTokenHelper =
repositoryRoot
.resolve("services/agent-runner/agent-github-token.sh")
.toFile()
.readText()
val mcpConfigMap =
repositoryRoot
.resolve("platform/cluster/flux/apps/agents/mcp/agents-mcp-servers-configmap.yaml")
Expand All @@ -74,14 +84,25 @@ class PlatformAgentMcpFluxTest {
.contains("url.https://github.com/.insteadOf git@github.com:")
.contains("url.https://github.com/.insteadOf ssh://git@github.com/")

assertThat(agentGatewayConfig)
.contains("--dangerously-bypass-approvals-and-sandbox")
.contains("--dangerously-bypass-hook-trust")

assertThat(githubMcpWrapper)
.contains("GITHUB_MCP_TOKEN_RETRIES:-4")
.contains("GITHUB_MCP_TOKEN_RETRY_SLEEP_SECONDS:-3")
.contains("GITHUB_APP_TOKEN_URL")
.contains("GITHUB_APP_TOKEN_BEARER")
.contains("GITHUB_MCP_TOOLSETS:-repos,pull_requests,issues,actions,git")
.contains("GITHUB_MCP_EXCLUDE_TOOLS:-create_repository,fork_repository")
.contains("--toolsets")
.contains("--exclude-tools")

assertThat(githubTokenHelper)
.contains("WORKSPACE_ROOT=\"\${WORKSPACE_ROOT:-/workspace}\"")
.contains("git -C \"\$WORKSPACE_ROOT\" remote get-url origin")
.contains("--arg repoUrl \"\$repo_url\"")

assertThat(mcpConfigMap)
.contains("[mcp_servers.github]")
.contains("command = \"gh-mcp-wrapper\"")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,9 @@ class HeadlessJobManager(
/**
* Build the one-shot CLI command for a headless run. Claude uses
* `-p` (print mode) with `--output-format stream-json` for machine-
* parseable streaming events. Codex uses `exec --json`; when a
* `cliSessionId` is provided it resumes an existing context via
* `exec resume <id> --json`.
* parseable streaming events. Codex uses `exec --json` plus the
* configured Codex CLI args; when a `cliSessionId` is explicitly
* provided it resumes that exact context via `exec resume <id> --json`.
*/
private fun headlessCommandFor(
kind: AgentKind,
Expand All @@ -188,9 +188,13 @@ class HeadlessJobManager(

AgentKind.CODEX ->
if (cliSessionId != null) {
listOf(props.cli.codex, "exec", "resume", cliSessionId, "--json", "--", prompt)
listOf(props.cli.codex, "exec", "resume") +
props.cli.codexArgs +
listOf("--json", cliSessionId, "--", prompt)
} else {
listOf(props.cli.codex, "exec", "--json", "--", prompt)
listOf(props.cli.codex, "exec") +
props.cli.codexArgs +
listOf("--json", "--", prompt)
}

AgentKind.SHELL -> listOf("/bin/sh", "-c", prompt)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ data class AgentSession(
val logFile: Path,
val cwd: String,
val createdAt: Instant,
// Native CLI session id for resume on wake. Set by the gateway for
// Claude (from the --session-id flag); null for Shell sessions and
// for Codex until async discovery is implemented.
// Native CLI session id for observability and future explicit
// continuation flows. Set by the gateway for Claude (from the
// --session-id flag); null for Shell sessions and for Codex until
// async discovery is implemented.
val cliSessionId: String? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ class AgentSessionManager(
fun spawn(
kind: AgentKind,
workspacePath: String? = null,
resumeCliSessionId: String? = null,
): AgentSession {
val id = UUID.randomUUID().toString().substring(0, 8)
val tmuxSession = "agent-$id"
Expand All @@ -79,7 +78,7 @@ class AgentSessionManager(
Files.deleteIfExists(logFile)
Files.createFile(logFile)

val (command, cliSessionId) = commandAndSessionIdFor(kind, resumeCliSessionId)
val (command, cliSessionId) = commandAndSessionIdFor(kind)
tmux.newSession(tmuxSession, command, cwd)
tmux.startPipeToFile(tmuxSession, logFile)

Expand Down Expand Up @@ -170,52 +169,26 @@ class AgentSessionManager(
/**
* Build the CLI command and return the native session id alongside it.
*
* For Claude: when [resumeCliSessionId] is a UUID the existing session is
* continued via `--resume <id>` and that id is echoed back. Otherwise a
* fresh UUID is generated and passed as `--session-id <uuid>` so the
* conversation can be resumed in a future Pod restart.
* For Claude: a fresh UUID is generated and passed as `--session-id
* <uuid>` so the CLI process has a stable native identity without
* inheriting another conversation.
*
* For Codex: session files live in `$CODEX_HOME/sessions/` and survive Pod
* restarts (codex-credentials PVC). When [resumeCliSessionId] is the
* sentinel [CODEX_RESUME_LAST], `codex resume --last` picks up the most
* recent session whose CWD matches the workspace — no stored ID required.
* When it is a specific UUID, `codex resume <id>` targets that session.
* For Codex: launch the interactive CLI directly. `codex resume --last`
* is intentionally not used here because it can attach a new gateway
* agent to a different saved Codex session on the shared credentials PVC.
*
* Shell has no session id.
*/
private fun commandAndSessionIdFor(
kind: AgentKind,
resumeCliSessionId: String?,
): Pair<List<String>, String?> =
private fun commandAndSessionIdFor(kind: AgentKind): Pair<List<String>, String?> =
when (kind) {
AgentKind.CLAUDE -> {
if (resumeCliSessionId != null) {
val cmd =
listOf(props.cli.claude) + props.cli.claudeArgs +
listOf("--resume", resumeCliSessionId)
cmd to resumeCliSessionId
} else {
val cliSessionId = UUID.randomUUID().toString()
val cmd =
listOf(props.cli.claude) + props.cli.claudeArgs +
listOf("--session-id", cliSessionId)
cmd to cliSessionId
}
}
AgentKind.CODEX -> {
when (resumeCliSessionId) {
CODEX_RESUME_LAST -> {
val cmd = listOf(props.cli.codex, "resume", "--last") + props.cli.codexArgs
cmd to null
}
null -> (listOf(props.cli.codex) + props.cli.codexArgs) to null
else -> {
// Specific session UUID — direct resume
val cmd = listOf(props.cli.codex, "resume", resumeCliSessionId) + props.cli.codexArgs
cmd to resumeCliSessionId
}
}
val cliSessionId = UUID.randomUUID().toString()
val cmd =
listOf(props.cli.claude) + props.cli.claudeArgs +
listOf("--session-id", cliSessionId)
cmd to cliSessionId
}
AgentKind.CODEX -> (listOf(props.cli.codex) + props.cli.codexArgs) to null
AgentKind.SHELL -> listOf("/bin/bash", "-l") to null
}

Expand All @@ -233,8 +206,6 @@ class AgentSessionManager(
private fun timestamp(): String = STAGED_INPUT_TIMESTAMP.format(Instant.now())

companion object {
/** Sentinel value for [resumeCliSessionId] that tells the gateway to run `codex resume --last`. */
const val CODEX_RESUME_LAST = "LATEST"
private const val DEFAULT_STAGED_INPUT_NAME = "input.txt"
private const val ID_PREVIEW_CHARS = 8
private const val MAX_STAGED_INPUT_NAME_CHARS = 80
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class AgentController(
fun spawn(
@RequestBody req: SpawnAgentRequest,
): ResponseEntity<AgentResponse> {
val session = sessions.spawn(req.kind, req.workspacePath, req.resumeCliSessionId)
val session = sessions.spawn(req.kind, req.workspacePath)
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(session))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import com.jorisjonkers.personalstack.agentgateway.tmux.AgentKind
data class SpawnAgentRequest(
val kind: AgentKind,
val workspacePath: String? = null,
val resumeCliSessionId: String? = null,
)

data class SendInputRequest(
Expand Down
1 change: 1 addition & 0 deletions services/agent-gateway/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ agent-gateway:
- --dangerously-skip-permissions
codex-args:
- --dangerously-bypass-approvals-and-sandbox
- --dangerously-bypass-hook-trust
git:
# The deploy-key Secret is mounted into the Pod at this path by
# assistant-api when it creates the runner Pod (Step 3).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ class HeadlessJobManagerTest {
GatewayProperties.Cli(
claude = "/usr/local/bin/claude",
codex = "/usr/local/bin/codex",
codexArgs =
listOf(
"--dangerously-bypass-approvals-and-sandbox",
"--dangerously-bypass-hook-trust",
),
),
git = GatewayProperties.Git(deployKeyDir = "/x"),
)
Expand Down Expand Up @@ -89,6 +94,36 @@ class HeadlessJobManagerTest {
mgr.destroy()
}

@Test
fun `launch codex headless uses bypass flags and starts fresh by default`(
@TempDir tmp: Path,
) {
val latch = CountDownLatch(1)
var capturedCommand: List<String>? = null
val mgr =
manager(tmp) { command, _ ->
capturedCommand = command
latch.countDown()
processOf(exitCode = 0, output = "done")
}

mgr.launch(AgentKind.CODEX, "inspect repo")

assertThat(latch.await(3, TimeUnit.SECONDS)).isTrue()
assertThat(capturedCommand)
.containsExactly(
"/usr/local/bin/codex",
"exec",
"--dangerously-bypass-approvals-and-sandbox",
"--dangerously-bypass-hook-trust",
"--json",
"--",
"inspect repo",
)
assertThat(capturedCommand).doesNotContain("resume")
mgr.destroy()
}

@Test
fun `cancel kills the process and marks job CANCELLED`(
@TempDir tmp: Path,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ class AgentSessionManagerTest {
claude = "/usr/local/bin/claude",
codex = "/usr/local/bin/codex",
claudeArgs = listOf("--dangerously-skip-permissions"),
codexArgs = listOf("--dangerously-bypass-approvals-and-sandbox"),
codexArgs =
listOf(
"--dangerously-bypass-approvals-and-sandbox",
"--dangerously-bypass-hook-trust",
),
),
git = GatewayProperties.Git(deployKeyDir = "/x"),
stagedInputs = stagedInputs,
Expand Down Expand Up @@ -71,7 +75,8 @@ class AgentSessionManagerTest {
) {
val mgr = manager(tmp)
val s = mgr.spawn(AgentKind.CLAUDE)
// Claude gets --session-id <uuid> appended so native resume is possible.
// Claude gets --session-id <uuid> appended so each gateway agent has
// a fresh native session instead of inheriting another conversation.
assertThat(s.cliSessionId).isNotNull()
verify {
tmux.newSession(
Expand All @@ -87,57 +92,43 @@ class AgentSessionManagerTest {
}

@Test
fun `spawn resumes claude session when resumeCliSessionId is provided`(
fun `spawn never resumes claude from another session`(
@TempDir tmp: Path,
) {
val mgr = manager(tmp)
val previousId = "prev-session-uuid"
val s = mgr.spawn(AgentKind.CLAUDE, resumeCliSessionId = previousId)
assertThat(s.cliSessionId).isEqualTo(previousId)
val s = mgr.spawn(AgentKind.CLAUDE)
verify {
tmux.newSession(
s.tmuxSession,
match { cmd ->
cmd.containsAll(listOf("/usr/local/bin/claude", "--dangerously-skip-permissions")) &&
cmd.contains("--resume") &&
cmd[cmd.indexOf("--resume") + 1] == previousId &&
!cmd.contains("--session-id")
cmd.contains("--session-id") &&
cmd[cmd.indexOf("--session-id") + 1] == s.cliSessionId &&
!cmd.contains("--resume")
},
"/workspace",
)
}
}

@Test
fun `spawn resumes last codex session when LATEST sentinel is provided`(
fun `spawn never resumes codex last session`(
@TempDir tmp: Path,
) {
val mgr = manager(tmp)
val s = mgr.spawn(AgentKind.CODEX, resumeCliSessionId = "LATEST")
val s = mgr.spawn(AgentKind.CODEX)
assertThat(s.cliSessionId).isNull()
verify {
tmux.newSession(
s.tmuxSession,
match { cmd -> cmd.containsAll(listOf("/usr/local/bin/codex", "resume", "--last")) },
"/workspace",
)
}
}

@Test
fun `spawn resumes specific codex session when a UUID is provided`(
@TempDir tmp: Path,
) {
val mgr = manager(tmp)
val sessionId = "prev-codex-uuid"
val s = mgr.spawn(AgentKind.CODEX, resumeCliSessionId = sessionId)
assertThat(s.cliSessionId).isEqualTo(sessionId)
verify {
tmux.newSession(
s.tmuxSession,
match { cmd ->
cmd.containsAll(listOf("/usr/local/bin/codex", "resume", sessionId)) &&
!cmd.contains("--last")
cmd.containsAll(
listOf(
"/usr/local/bin/codex",
"--dangerously-bypass-approvals-and-sandbox",
"--dangerously-bypass-hook-trust",
),
) && !cmd.contains("resume") && !cmd.contains("--last")
},
"/workspace",
)
Expand All @@ -155,7 +146,11 @@ class AgentSessionManagerTest {
verify {
tmux.newSession(
s.tmuxSession,
listOf("/usr/local/bin/codex", "--dangerously-bypass-approvals-and-sandbox"),
listOf(
"/usr/local/bin/codex",
"--dangerously-bypass-approvals-and-sandbox",
"--dangerously-bypass-hook-trust",
),
"/workspace",
)
}
Expand Down
Loading
Loading