Skip to content

Commit e5abe64

Browse files
authored
Merge pull request #51 from diggerhq/feat/claude-sdk-abstraction
feat/claude sdk abstraction
2 parents 28c8911 + 6f3a56c commit e5abe64

File tree

23 files changed

+1801
-9
lines changed

23 files changed

+1801
-9
lines changed

cmd/agent/main.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"log"
1010
"os"
1111
"os/signal"
12+
"strings"
1213
"syscall"
1314

1415
"github.com/opensandbox/opensandbox/internal/agent"
@@ -18,6 +19,14 @@ const version = "0.1.0"
1819

1920
func main() {
2021
log.SetFlags(log.Ltime | log.Lmicroseconds)
22+
23+
// Ensure /usr/local/bin and /usr/local/sbin are in PATH for exec.LookPath.
24+
// The init script may not set a full PATH.
25+
path := os.Getenv("PATH")
26+
if !strings.Contains(path, "/usr/local/bin") {
27+
os.Setenv("PATH", "/usr/local/bin:/usr/local/sbin:"+path)
28+
}
29+
2130
log.Printf("osb-agent %s starting", version)
2231

2332
// Listen on vsock port 1024 (inside Firecracker) or Unix socket (testing).

cmd/worker/main.go

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"context"
55
"fmt"
6+
"io"
67
"log"
78
"os"
89
"os/signal"
@@ -92,23 +93,29 @@ func main() {
9293
scrollback := sandbox.NewScrollbackBuffer(0)
9394
done := make(chan struct{})
9495

96+
// Create a pipe for stdin: writes to stdinW are forwarded to the gRPC stream
97+
stdinR, stdinW := io.Pipe()
98+
9599
handle := &sandbox.ExecSessionHandle{
96-
ID: sessionID,
97-
SandboxID: sandboxID,
98-
Command: req.Command,
99-
Args: req.Args,
100-
Running: true,
101-
StartedAt: time.Now(),
102-
Done: done,
103-
Scrollback: scrollback,
100+
ID: sessionID,
101+
SandboxID: sandboxID,
102+
Command: req.Command,
103+
Args: req.Args,
104+
Running: true,
105+
StartedAt: time.Now(),
106+
Done: done,
107+
Scrollback: scrollback,
108+
StdinWriter: stdinW,
104109
OnKill: func(signal int) error {
110+
stdinW.Close()
105111
return agent.ExecSessionKill(context.Background(), sessionID, int32(signal))
106112
},
107113
}
108114

109115
// Attach to the session to pipe output into the host-side scrollback
110116
go func() {
111117
defer close(done)
118+
defer stdinR.Close()
112119
stream, err := agent.ExecSessionAttach(context.Background())
113120
if err != nil {
114121
return
@@ -117,6 +124,25 @@ func main() {
117124
if err := stream.Send(&agentpb.ExecSessionInput{SessionId: sessionID}); err != nil {
118125
return
119126
}
127+
128+
// Forward stdin pipe to gRPC stream in a separate goroutine
129+
go func() {
130+
buf := make([]byte, 4096)
131+
for {
132+
n, err := stdinR.Read(buf)
133+
if err != nil {
134+
return
135+
}
136+
if n > 0 {
137+
data := make([]byte, n)
138+
copy(data, buf[:n])
139+
if err := stream.Send(&agentpb.ExecSessionInput{Stdin: data}); err != nil {
140+
return
141+
}
142+
}
143+
}
144+
}()
145+
120146
for {
121147
msg, err := stream.Recv()
122148
if err != nil {

deploy/ec2/build-rootfs-docker.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,13 @@ exec /usr/local/bin/osb-agent
149149
INIT_EOF
150150
chmod +x "$TMPDIR/init"
151151

152+
# Copy claude-agent-wrapper source (for images that include it)
153+
WRAPPER_DIR="$PROJECT_ROOT/scripts/claude-agent-wrapper"
154+
if [ -d "$WRAPPER_DIR" ]; then
155+
mkdir -p "$TMPDIR/scripts"
156+
cp -r "$WRAPPER_DIR" "$TMPDIR/scripts/claude-agent-wrapper"
157+
fi
158+
152159
# Append agent/init injection to Dockerfile
153160
cat >> "$TMPDIR/Dockerfile" << 'INJECT_EOF'
154161

deploy/firecracker/rootfs/Dockerfile.default

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,18 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
7676
&& rm -rf /var/lib/apt/lists/* \
7777
&& npm install -g npm@latest
7878

79+
# ── Claude Agent SDK + wrapper ───────────────────────────────────────────────
80+
RUN npm install -g @anthropic-ai/claude-agent-sdk @anthropic-ai/claude-code
81+
COPY scripts/claude-agent-wrapper /tmp/claude-agent-wrapper
82+
RUN cd /tmp/claude-agent-wrapper \
83+
&& npm install --ignore-scripts \
84+
&& npx tsc \
85+
&& cp dist/index.js /usr/local/lib/claude-agent-wrapper.js \
86+
&& ln -sf /usr/lib/node_modules /usr/local/lib/node_modules \
87+
&& printf '#!/bin/sh\nexport IS_SANDBOX=1\nexec node --experimental-vm-modules /usr/local/lib/claude-agent-wrapper.js "$@"\n' > /usr/local/bin/claude-agent-wrapper \
88+
&& chmod +x /usr/local/bin/claude-agent-wrapper \
89+
&& rm -rf /tmp/claude-agent-wrapper
90+
7991
# ── Locale ───────────────────────────────────────────────────────────────────
8092
RUN locale-gen en_US.UTF-8
8193
ENV LANG=en_US.UTF-8

internal/agent/exec.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,32 @@ import (
1313
pb "github.com/opensandbox/opensandbox/proto/agent"
1414
)
1515

16-
// baseEnv returns the current OS environment with HOME set to /root.
16+
// baseEnv returns the current OS environment with HOME set to /root and
17+
// PATH guaranteed to include /usr/local/bin and /usr/local/sbin.
1718
// With overlayfs, the entire filesystem is backed by the data disk,
1819
// so /root (the standard root home) has full disk space available.
1920
func baseEnv() []string {
2021
var env []string
22+
hasPath := false
2123
for _, e := range os.Environ() {
2224
if strings.HasPrefix(e, "HOME=") {
2325
continue
2426
}
27+
if strings.HasPrefix(e, "PATH=") {
28+
hasPath = true
29+
path := e[5:]
30+
// Ensure /usr/local/bin and /usr/local/sbin are in PATH
31+
if !strings.Contains(path, "/usr/local/bin") {
32+
path = "/usr/local/bin:/usr/local/sbin:" + path
33+
}
34+
env = append(env, "PATH="+path)
35+
continue
36+
}
2537
env = append(env, e)
2638
}
39+
if !hasPath {
40+
env = append(env, "PATH=/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin")
41+
}
2742
env = append(env, "HOME=/root")
2843
return env
2944
}

internal/api/agent_session.go

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"time"
8+
9+
"github.com/labstack/echo/v4"
10+
"github.com/opensandbox/opensandbox/internal/sandbox"
11+
"github.com/opensandbox/opensandbox/pkg/types"
12+
)
13+
14+
func (s *Server) createAgentSession(c echo.Context) error {
15+
if s.execSessionManager == nil {
16+
return c.JSON(http.StatusServiceUnavailable, errSandboxNotAvailable)
17+
}
18+
19+
id := c.Param("id")
20+
21+
var req types.AgentSessionCreateRequest
22+
if err := c.Bind(&req); err != nil {
23+
return c.JSON(http.StatusBadRequest, map[string]string{
24+
"error": "invalid request body: " + err.Error(),
25+
})
26+
}
27+
28+
execReq := types.ExecSessionCreateRequest{
29+
Command: "claude-agent-wrapper",
30+
}
31+
32+
var session *sandbox.ExecSessionHandle
33+
34+
routeOp := func(_ context.Context) error {
35+
var err error
36+
session, err = s.execSessionManager.CreateSession(id, execReq)
37+
return err
38+
}
39+
40+
if s.router != nil {
41+
if err := s.router.Route(c.Request().Context(), id, "agentSessionCreate", routeOp); err != nil {
42+
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
43+
}
44+
} else {
45+
if err := routeOp(c.Request().Context()); err != nil {
46+
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
47+
}
48+
}
49+
50+
// Send configure command if any config options provided
51+
hasConfig := req.Model != "" || req.SystemPrompt != "" || len(req.AllowedTools) > 0 ||
52+
req.PermissionMode != "" || req.MaxTurns > 0 || req.Cwd != "" || len(req.McpServers) > 0
53+
if hasConfig && session.StdinWriter != nil {
54+
configCmd := map[string]interface{}{"type": "configure"}
55+
if req.Model != "" {
56+
configCmd["model"] = req.Model
57+
}
58+
if req.SystemPrompt != "" {
59+
configCmd["systemPrompt"] = req.SystemPrompt
60+
}
61+
if len(req.AllowedTools) > 0 {
62+
configCmd["allowedTools"] = req.AllowedTools
63+
}
64+
if req.PermissionMode != "" {
65+
configCmd["permissionMode"] = req.PermissionMode
66+
}
67+
if req.MaxTurns > 0 {
68+
configCmd["maxTurns"] = req.MaxTurns
69+
}
70+
if req.Cwd != "" {
71+
configCmd["cwd"] = req.Cwd
72+
}
73+
if len(req.McpServers) > 0 {
74+
configCmd["mcpServers"] = req.McpServers
75+
}
76+
configJSON, _ := json.Marshal(configCmd)
77+
session.StdinWriter.Write(append(configJSON, '\n'))
78+
}
79+
80+
// Send initial prompt if provided
81+
if req.Prompt != "" && session.StdinWriter != nil {
82+
promptCmd := map[string]interface{}{
83+
"type": "prompt",
84+
"text": req.Prompt,
85+
}
86+
promptJSON, _ := json.Marshal(promptCmd)
87+
session.StdinWriter.Write(append(promptJSON, '\n'))
88+
}
89+
90+
return c.JSON(http.StatusCreated, types.AgentSessionInfo{
91+
SessionID: session.ID,
92+
SandboxID: id,
93+
Running: true,
94+
StartedAt: session.StartedAt.Format(time.RFC3339),
95+
})
96+
}
97+
98+
func (s *Server) listAgentSessions(c echo.Context) error {
99+
if s.execSessionManager == nil {
100+
return c.JSON(http.StatusServiceUnavailable, errSandboxNotAvailable)
101+
}
102+
103+
id := c.Param("id")
104+
allSessions := s.execSessionManager.ListSessions(id)
105+
106+
var agentSessions []types.AgentSessionInfo
107+
for _, sess := range allSessions {
108+
if sess.Command == "claude-agent-wrapper" {
109+
agentSessions = append(agentSessions, types.AgentSessionInfo{
110+
SessionID: sess.SessionID,
111+
SandboxID: sess.SandboxID,
112+
Running: sess.Running,
113+
StartedAt: sess.StartedAt,
114+
})
115+
}
116+
}
117+
118+
if agentSessions == nil {
119+
agentSessions = []types.AgentSessionInfo{}
120+
}
121+
122+
return c.JSON(http.StatusOK, agentSessions)
123+
}
124+
125+
func (s *Server) sendAgentPrompt(c echo.Context) error {
126+
if s.execSessionManager == nil {
127+
return c.JSON(http.StatusServiceUnavailable, errSandboxNotAvailable)
128+
}
129+
130+
id := c.Param("id")
131+
sessionID := c.Param("sid")
132+
133+
var req types.AgentPromptRequest
134+
if err := c.Bind(&req); err != nil {
135+
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body: " + err.Error()})
136+
}
137+
if req.Text == "" {
138+
return c.JSON(http.StatusBadRequest, map[string]string{"error": "text is required"})
139+
}
140+
141+
session, err := s.execSessionManager.GetSession(sessionID)
142+
if err != nil {
143+
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
144+
}
145+
146+
if session.SandboxID != id {
147+
return c.JSON(http.StatusNotFound, map[string]string{"error": "session not found"})
148+
}
149+
150+
if session.StdinWriter == nil {
151+
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "session stdin not available"})
152+
}
153+
154+
promptCmd := map[string]interface{}{
155+
"type": "prompt",
156+
"text": req.Text,
157+
}
158+
promptJSON, _ := json.Marshal(promptCmd)
159+
session.StdinWriter.Write(append(promptJSON, '\n'))
160+
161+
return c.NoContent(http.StatusNoContent)
162+
}
163+
164+
func (s *Server) interruptAgent(c echo.Context) error {
165+
if s.execSessionManager == nil {
166+
return c.JSON(http.StatusServiceUnavailable, errSandboxNotAvailable)
167+
}
168+
169+
id := c.Param("id")
170+
sessionID := c.Param("sid")
171+
172+
session, err := s.execSessionManager.GetSession(sessionID)
173+
if err != nil {
174+
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
175+
}
176+
177+
if session.SandboxID != id {
178+
return c.JSON(http.StatusNotFound, map[string]string{"error": "session not found"})
179+
}
180+
181+
if session.StdinWriter == nil {
182+
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "session stdin not available"})
183+
}
184+
185+
interruptCmd := map[string]interface{}{"type": "interrupt"}
186+
interruptJSON, _ := json.Marshal(interruptCmd)
187+
session.StdinWriter.Write(append(interruptJSON, '\n'))
188+
189+
return c.NoContent(http.StatusNoContent)
190+
}
191+
192+
func (s *Server) killAgentSession(c echo.Context) error {
193+
if s.execSessionManager == nil {
194+
return c.JSON(http.StatusServiceUnavailable, errSandboxNotAvailable)
195+
}
196+
197+
id := c.Param("id")
198+
sessionID := c.Param("sid")
199+
200+
var body struct {
201+
Signal int `json:"signal"`
202+
}
203+
_ = c.Bind(&body)
204+
205+
if body.Signal == 0 {
206+
body.Signal = 9
207+
}
208+
209+
routeOp := func(_ context.Context) error {
210+
return s.execSessionManager.KillSession(sessionID, body.Signal)
211+
}
212+
213+
if s.router != nil {
214+
if err := s.router.Route(c.Request().Context(), id, "agentSessionKill", routeOp); err != nil {
215+
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
216+
}
217+
} else {
218+
if err := routeOp(c.Request().Context()); err != nil {
219+
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
220+
}
221+
}
222+
223+
return c.NoContent(http.StatusNoContent)
224+
}

internal/api/exec_session.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ func (s *Server) execSessionWebSocket(c echo.Context) error {
9494
})
9595
}
9696

97+
if session.SandboxID != id {
98+
return c.JSON(http.StatusNotFound, map[string]string{"error": "session not found"})
99+
}
100+
97101
if s.router != nil {
98102
s.router.Touch(id)
99103
}

0 commit comments

Comments
 (0)