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
6 changes: 3 additions & 3 deletions .agents/skills/add-public-service/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
interface:
display_name: "Add Public Service"
short_description: "Add stack public services safely"
default_prompt: "Use $add-public-service to add a new SSO-protected public app."
display_name: 'Add Public Service'
short_description: 'Add stack public services safely'
default_prompt: 'Use $add-public-service to add a new SSO-protected public app.'
16 changes: 8 additions & 8 deletions .agents/skills/agent-session-bootstrap/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
interface:
display_name: "Agent Session Bootstrap"
short_description: "Bootstrap durable agent context"
default_prompt: "Use $agent-session-bootstrap to set up agent hooks, skills, and durable instructions."
display_name: 'Agent Session Bootstrap'
short_description: 'Bootstrap durable agent context'
default_prompt: 'Use $agent-session-bootstrap to set up agent hooks, skills, and durable instructions.'
dependencies:
tools:
- type: "mcp"
value: "knowledge"
description: "Knowledge-base MCP server"
transport: "streamable_http"
url: "http://knowledge-api.knowledge-system.svc.cluster.local:8080/mcp"
- type: 'mcp'
value: 'knowledge'
description: 'Knowledge-base MCP server'
transport: 'streamable_http'
url: 'http://knowledge-api.knowledge-system.svc.cluster.local:8080/mcp'
6 changes: 3 additions & 3 deletions .agents/skills/fleet-change/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
interface:
display_name: "Fleet Change"
short_description: "Render and validate fleet edits"
default_prompt: "Use $fleet-change to make this fleet.yaml update safely."
display_name: 'Fleet Change'
short_description: 'Render and validate fleet edits'
default_prompt: 'Use $fleet-change to make this fleet.yaml update safely.'
16 changes: 8 additions & 8 deletions .agents/skills/kb-first/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
interface:
display_name: "KB First"
short_description: "Recall and capture KB context"
default_prompt: "Use $kb-first to gather compact KB context before this task."
display_name: 'KB First'
short_description: 'Recall and capture KB context'
default_prompt: 'Use $kb-first to gather compact KB context before this task.'
dependencies:
tools:
- type: "mcp"
value: "knowledge"
description: "Knowledge-base MCP server"
transport: "streamable_http"
url: "http://knowledge-api.knowledge-system.svc.cluster.local:8080/mcp"
- type: 'mcp'
value: 'knowledge'
description: 'Knowledge-base MCP server'
transport: 'streamable_http'
url: 'http://knowledge-api.knowledge-system.svc.cluster.local:8080/mcp'
6 changes: 3 additions & 3 deletions .agents/skills/open-pr/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
interface:
display_name: "Open PR"
short_description: "Create repo-standard pull requests"
default_prompt: "Use $open-pr to open a personal-stack pull request."
display_name: 'Open PR'
short_description: 'Create repo-standard pull requests'
default_prompt: 'Use $open-pr to open a personal-stack pull request.'
6 changes: 3 additions & 3 deletions .agents/skills/run-tests/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
interface:
display_name: "Run Tests"
short_description: "Choose monorepo validation commands"
default_prompt: "Use $run-tests to validate the files touched by this change."
display_name: 'Run Tests'
short_description: 'Choose monorepo validation commands'
default_prompt: 'Use $run-tests to validate the files touched by this change.'
16 changes: 8 additions & 8 deletions .agents/skills/token-economy/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
interface:
display_name: "Token Economy"
short_description: "Reduce agent token usage cleanly"
default_prompt: "Use $token-economy to keep this agent workflow token-efficient."
display_name: 'Token Economy'
short_description: 'Reduce agent token usage cleanly'
default_prompt: 'Use $token-economy to keep this agent workflow token-efficient.'
dependencies:
tools:
- type: "mcp"
value: "knowledge"
description: "Knowledge-base MCP server"
transport: "streamable_http"
url: "http://knowledge-api.knowledge-system.svc.cluster.local:8080/mcp"
- type: 'mcp'
value: 'knowledge'
description: 'Knowledge-base MCP server'
transport: 'streamable_http'
url: 'http://knowledge-api.knowledge-system.svc.cluster.local:8080/mcp'
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ data class GatewayProperties(
val tmux: Tmux,
val cli: Cli,
val git: Git,
val stagedInputs: StagedInputs = StagedInputs(),
) {
data class Tmux(
val socketName: String,
Expand Down Expand Up @@ -36,4 +37,9 @@ data class GatewayProperties(
data class Git(
val deployKeyDir: String,
)

data class StagedInputs(
val dirName: String = ".agent-inputs",
val maxBytes: Long = 5L * 1024 * 1024,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardOpenOption
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
Expand Down Expand Up @@ -116,6 +118,33 @@ class AgentSessionManager(
tmux.sendKeys(session.tmuxSession, input, enter = enter)
}

fun stageInput(
id: String,
content: String,
requestedName: String?,
): StagedInput {
val session = sessions[id] ?: error("unknown agent: $id")
val bytes = content.toByteArray(Charsets.UTF_8)
require(bytes.isNotEmpty()) { "staged input content is empty" }
require(bytes.size.toLong() <= props.stagedInputs.maxBytes) {
"staged input exceeds ${props.stagedInputs.maxBytes} bytes"
}

val root = Path(session.cwd).toAbsolutePath().normalize()
val dir = root.resolve(props.stagedInputs.dirName).normalize()
require(dir.startsWith(root)) { "staged input directory must stay inside the workspace" }

Files.createDirectories(dir)
val safeName = safeFileName(requestedName)
val fileName = "${timestamp()}-${UUID.randomUUID().toString().take(ID_PREVIEW_CHARS)}-$safeName"
val target = dir.resolve(fileName).normalize()
require(target.startsWith(dir)) { "staged input path must stay inside the staging directory" }

Files.write(target, bytes, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)
log.info("staged {} bytes for agent {} at {}", bytes.size, id, target)
return StagedInput(path = target.toString(), bytes = bytes.size.toLong(), name = safeName)
}

fun capture(
id: String,
historyLines: Int = 1_000,
Expand Down Expand Up @@ -190,8 +219,27 @@ class AgentSessionManager(
AgentKind.SHELL -> listOf("/bin/bash", "-l") to null
}

private fun safeFileName(requestedName: String?): String {
val raw = requestedName?.trim()?.takeIf { it.isNotBlank() } ?: DEFAULT_STAGED_INPUT_NAME
val leaf = raw.replace('\\', '/').substringAfterLast('/')
val safe =
SAFE_NAME_CHARS
.replace(leaf, "-")
.trim('.', '-', '_')
.take(MAX_STAGED_INPUT_NAME_CHARS)
return safe.takeIf { it.isNotBlank() } ?: DEFAULT_STAGED_INPUT_NAME
}

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
private val SAFE_NAME_CHARS = Regex("[^A-Za-z0-9._-]+")
private val STAGED_INPUT_TIMESTAMP =
DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss-SSS").withZone(ZoneOffset.UTC)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.jorisjonkers.personalstack.agentgateway.tmux

data class StagedInput(
val path: String,
val bytes: Long,
val name: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import com.jorisjonkers.personalstack.agentgateway.tmux.AgentSessionManager
import com.jorisjonkers.personalstack.agentgateway.web.dto.AgentResponse
import com.jorisjonkers.personalstack.agentgateway.web.dto.SendInputRequest
import com.jorisjonkers.personalstack.agentgateway.web.dto.SpawnAgentRequest
import com.jorisjonkers.personalstack.agentgateway.web.dto.StageInputRequest
import com.jorisjonkers.personalstack.agentgateway.web.dto.StagedInputResponse
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
Expand Down Expand Up @@ -47,6 +49,17 @@ class AgentController(
return ResponseEntity.accepted().build()
}

@PostMapping("/{id}/staged-inputs")
fun stageInput(
@PathVariable id: String,
@RequestBody req: StageInputRequest,
): ResponseEntity<StagedInputResponse> {
val staged = sessions.stageInput(id, req.content, req.name)
return ResponseEntity
.status(HttpStatus.CREATED)
.body(StagedInputResponse(path = staged.path, bytes = staged.bytes, name = staged.name))
}

@GetMapping("/{id}/capture")
fun capture(
@PathVariable id: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ class ErrorAdvice {
fun onIllegalState(e: IllegalStateException): ResponseEntity<Map<String, String>> =
ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf("error" to (e.message ?: "not-found")))

@ExceptionHandler(IllegalArgumentException::class)
fun onIllegalArgument(e: IllegalArgumentException): ResponseEntity<Map<String, String>> =
ResponseEntity.status(HttpStatus.BAD_REQUEST).body(mapOf("error" to (e.message ?: "bad-request")))

companion object {
private const val STDERR_PREVIEW_CHARS = 2_000
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ data class SendInputRequest(
val enter: Boolean = true,
)

data class StageInputRequest(
val content: String,
val name: String? = null,
)

data class StagedInputResponse(
val path: String,
val bytes: Long,
val name: String,
)

data class AgentResponse(
val id: String,
val kind: AgentKind,
Expand Down
3 changes: 3 additions & 0 deletions services/agent-gateway/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ agent-gateway:
# The deploy-key Secret is mounted into the Pod at this path by
# assistant-api when it creates the runner Pod (Step 3).
deploy-key-dir: /var/run/secrets/agents/github-deploy-key
staged-inputs:
dir-name: .agent-inputs
max-bytes: 5242880

management:
endpoints:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.awaitility.Awaitility.await
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
Expand All @@ -15,7 +16,10 @@ import java.time.Duration
class AgentSessionManagerTest {
private val tmux = mockk<TmuxClient>(relaxed = true)

private fun manager(tmp: Path): AgentSessionManager {
private fun manager(
tmp: Path,
stagedInputs: GatewayProperties.StagedInputs = GatewayProperties.StagedInputs(),
): AgentSessionManager {
val props =
GatewayProperties(
workspaceRoot = "/workspace",
Expand All @@ -28,6 +32,7 @@ class AgentSessionManagerTest {
codexArgs = listOf("--dangerously-bypass-approvals-and-sandbox"),
),
git = GatewayProperties.Git(deployKeyDir = "/x"),
stagedInputs = stagedInputs,
)
every { tmux.ensureStateDir() } returns tmp
return AgentSessionManager(tmux, props)
Expand Down Expand Up @@ -204,6 +209,46 @@ class AgentSessionManagerTest {
verify { tmux.sendKeys(s.tmuxSession, "list files", enter = true) }
}

@Test
fun `stageInput writes content under session cwd with sanitized name`(
@TempDir tmp: Path,
) {
val workspace = tmp.resolve("workspace")
val mgr = manager(tmp)
val s = mgr.spawn(AgentKind.CLAUDE, workspacePath = workspace.toString())

val staged = mgr.stageInput(s.id, "large document", "../source notes?.md")

assertThat(staged.name).isEqualTo("source-notes-.md")
assertThat(staged.bytes).isEqualTo("large document".toByteArray().size.toLong())
assertThat(Path.of(staged.path).parent).isEqualTo(workspace.resolve(".agent-inputs"))
assertThat(Files.readString(Path.of(staged.path))).isEqualTo("large document")
}

@Test
fun `stageInput rejects content over configured byte cap`(
@TempDir tmp: Path,
) {
val mgr = manager(tmp, GatewayProperties.StagedInputs(maxBytes = 4))
val s = mgr.spawn(AgentKind.SHELL, workspacePath = tmp.resolve("workspace").toString())

assertThatThrownBy { mgr.stageInput(s.id, "12345", "too-large.txt") }
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("exceeds 4 bytes")
}

@Test
fun `stageInput rejects staging directory outside workspace`(
@TempDir tmp: Path,
) {
val mgr = manager(tmp, GatewayProperties.StagedInputs(dirName = "../outside"))
val s = mgr.spawn(AgentKind.SHELL, workspacePath = tmp.resolve("workspace").toString())

assertThatThrownBy { mgr.stageInput(s.id, "content", "input.txt") }
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("inside the workspace")
}

@Test
fun `capture delegates to tmux capture`(
@TempDir tmp: Path,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.jorisjonkers.personalstack.agentgateway.web
import com.jorisjonkers.personalstack.agentgateway.tmux.AgentKind
import com.jorisjonkers.personalstack.agentgateway.tmux.AgentSession
import com.jorisjonkers.personalstack.agentgateway.tmux.AgentSessionManager
import com.jorisjonkers.personalstack.agentgateway.tmux.StagedInput
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
Expand Down Expand Up @@ -81,4 +82,36 @@ class AgentControllerTest {
.content("""{"input":"hi","enter":true}"""),
).andExpect(status().isAccepted)
}

@Test
fun `POST agents id staged-inputs returns staged file metadata`() {
every {
sessions.stageInput("abc12345", "large document", "source.txt")
} returns StagedInput(path = "/workspace/.agent-inputs/20260604-source.txt", bytes = 14, name = "source.txt")

mockMvc
.perform(
post("/agents/abc12345/staged-inputs")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"content":"large document","name":"source.txt"}"""),
).andExpect(status().isCreated)
.andExpect(jsonPath("$.path").value("/workspace/.agent-inputs/20260604-source.txt"))
.andExpect(jsonPath("$.bytes").value(14))
.andExpect(jsonPath("$.name").value("source.txt"))
}

@Test
fun `POST agents id staged-inputs returns 400 on invalid content`() {
every {
sessions.stageInput("abc12345", "", "source.txt")
} throws IllegalArgumentException("staged input content is empty")

mockMvc
.perform(
post("/agents/abc12345/staged-inputs")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"content":"","name":"source.txt"}"""),
).andExpect(status().isBadRequest)
.andExpect(jsonPath("$.error").value("staged input content is empty"))
}
}
Loading
Loading