-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathdevbox-mounts.ts
More file actions
252 lines (222 loc) · 10.1 KB
/
Copy pathdevbox-mounts.ts
File metadata and controls
252 lines (222 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
#!/usr/bin/env -S npm run tsn -T
/**
---
title: Devbox Mounts (Agent, Code, Object)
slug: devbox-mounts
use_case: Launch a devbox that combines an agent mount for Claude Code, a code mount for the Runloop CLI repo, and an object mount for startup files.
workflow:
- Create or reuse an agent by name
- Create a secret for an agent and route it through agent gateway
- Upload a temporary directory as a storage object with a TTL
- Launch a devbox with agent, code, and object mounts together
- Run Claude Code on Opus 4.5 through the Anthropic agent gateway
- Verify the rl-cli repo and extracted object files are present on the devbox
- Shutdown the devbox and delete the temporary secret and object
tags:
- devbox
- mounts
- agent
- code
- object
- claude-code
- agent-gateway
- ttl
prerequisites:
- RUNLOOP_API_KEY
- ANTHROPIC_API_KEY
run: ANTHROPIC_API_KEY=sk-ant-xxx yarn tsn -T examples/devbox-mounts.ts
test: yarn test:examples
---
*/
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { RunloopSDK } from '@runloop/api-client';
import { wrapRecipe, runAsCli } from './_harness';
import type { RecipeContext, RecipeOutput } from './types';
const CLAUDE_CODE_AGENT_NAME = 'example-claude-code-agent';
const CLAUDE_CODE_AGENT_VERSION = '1.0.0';
const CLAUDE_CODE_PACKAGE = '@anthropic-ai/claude-code';
const CLAUDE_MODEL = 'claude-opus-4-5';
const OBJECT_TTL_MS = 60 * 60 * 1000;
const OBJECT_MOUNT_DIR = '/home/user/bootstrap-assets';
const COPIED_EXAMPLE_FILE_NAME = 'devbox-mounts-source.ts';
const GATEWAY_ENV_PREFIX = 'ANTHROPIC';
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
// example setup: get or create an agent so we can mount it
async function ensureClaudeCodeAgent(sdk: RunloopSDK): Promise<{ agentId: string; reused: boolean }> {
const existingAgentsList = await sdk.agent.list({ name: CLAUDE_CODE_AGENT_NAME, limit: 20 });
const existingAgentDetails = await Promise.all(existingAgentsList.map((candidate) => candidate.getInfo()));
const matchingAgent = existingAgentDetails
.filter(
(info) =>
info.name === CLAUDE_CODE_AGENT_NAME &&
info.version === CLAUDE_CODE_AGENT_VERSION &&
info.source?.type === 'npm' &&
info.source?.npm?.package_name === CLAUDE_CODE_PACKAGE,
)
.sort((left, right) => right.create_time_ms - left.create_time_ms)[0];
if (matchingAgent) {
return { agentId: matchingAgent.id, reused: true };
}
const createdAgent = await sdk.agent.createFromNpm({
name: CLAUDE_CODE_AGENT_NAME,
version: CLAUDE_CODE_AGENT_VERSION,
package_name: CLAUDE_CODE_PACKAGE,
});
return { agentId: createdAgent.id, reused: false };
}
export async function recipe(ctx: RecipeContext): Promise<RecipeOutput> {
const { cleanup } = ctx;
const anthropicApiKey = process.env['ANTHROPIC_API_KEY'];
if (!anthropicApiKey) {
throw new Error('Set ANTHROPIC_API_KEY to run the Claude Code mount example.');
}
const sdk = new RunloopSDK({
bearerToken: process.env['RUNLOOP_API_KEY'],
});
const resourcesCreated: string[] = [];
const { agentId, reused } = await ensureClaudeCodeAgent(sdk);
resourcesCreated.push(reused ? `agent:${agentId}:reused` : `agent:${agentId}`);
// best practice: create a secret for the agent's credentials and use agent gateway to route it through
// so that credentials are not exposed to the agent.
const anthropicSecret = await sdk.secret.create({
name: uniqueName('anthropic-mount-example'),
value: anthropicApiKey,
});
resourcesCreated.push(`secret:${anthropicSecret.name}`);
cleanup.add(`secret:${anthropicSecret.name}`, () => anthropicSecret.delete());
// now create some example files to mount onto the devbox via object mount.
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runloop-mounts-'));
cleanup.add(`tempDir:${tempDir}`, () => fs.rm(tempDir, { recursive: true, force: true }));
await fs.copyFile(__filename, path.join(tempDir, COPIED_EXAMPLE_FILE_NAME));
await fs.writeFile(
path.join(tempDir, 'README.txt'),
'This directory was uploaded with uploadFromDir(), stored as a tgz object, and extracted onto the devbox via object_mount.\n',
);
// Object mounts are a good fit for blobs or archives that should simply appear on the devbox at startup.
// uploadFromDir() compresses the directory as a .tgz, and mounting that archive to a directory path extracts it.
const archive = await sdk.storageObject.uploadFromDir(tempDir, {
name: uniqueName('mount-bootstrap'),
ttl_ms: OBJECT_TTL_MS, // best practice: set a TTL so the object is deleted after a certain time.
metadata: { example: 'devbox-mounts' },
});
resourcesCreated.push(`storageObject:${archive.id}`);
cleanup.add(`storageObject:${archive.id}`, () => archive.delete());
const archiveInfo = await archive.getInfo();
const devbox = await sdk.devbox.create({
name: uniqueName('mounts-example-devbox'),
// Use agent mounts for reusable tools or agents that should be installed onto the devbox.
// For npm-based agents like Claude Code, mounting by name makes it easy to reuse the latest matching agent.
mounts: [
{
type: 'agent_mount',
agent_id: null,
agent_name: CLAUDE_CODE_AGENT_NAME,
},
// Use code mounts for Git projects that should be cloned onto the devbox.
{
type: 'code_mount',
repo_owner: 'runloopai',
repo_name: 'rl-cli',
},
{
type: 'object_mount',
object_id: archive.id,
object_path: OBJECT_MOUNT_DIR,
},
],
// Route Anthropic access through agent gateway so Claude Code sees only a gateway token and URL.
gateways: {
[GATEWAY_ENV_PREFIX]: {
gateway: 'anthropic',
secret: anthropicSecret,
},
},
launch_parameters: {
resource_size_request: 'SMALL',
keep_alive_time_seconds: 60 * 5,
},
});
resourcesCreated.push(`devbox:${devbox.id}`);
cleanup.add(`devbox:${devbox.id}`, () => sdk.devbox.fromId(devbox.id).shutdown());
const devboxInfo = await devbox.getInfo();
const gatewayUrlResult = await devbox.cmd.exec(`echo $${GATEWAY_ENV_PREFIX}_URL`);
const gatewayUrl = (await gatewayUrlResult.stdout()).trim();
const gatewayTokenResult = await devbox.cmd.exec(`echo $${GATEWAY_ENV_PREFIX}`);
const gatewayToken = (await gatewayTokenResult.stdout()).trim();
const claudeVersionResult = await devbox.cmd.exec('claude --version');
const claudeVersion = (await claudeVersionResult.stdout()).trim();
const claudeGatewayCommand = `ANTHROPIC_BASE_URL="$${GATEWAY_ENV_PREFIX}_URL" ANTHROPIC_API_KEY="$${GATEWAY_ENV_PREFIX}" claude --model ${CLAUDE_MODEL} -p "Reply with the exact text mounted-through-agent-gateway and nothing else." --dangerously-skip-permissions`;
// This is where you would invoke Claude Code through the Anthropic agent gateway.
// It is intentionally commented out to avoid paid model usage during example runs.
// const claudePromptResult = await devbox.cmd.exec(claudeGatewayCommand);
// const claudeStdout = (await claudePromptResult.stdout()).trim();
const repoPathResult = await devbox.cmd.exec(
'if [ -d /home/user/rl-cli ]; then printf /home/user/rl-cli; elif [ -d /home/user/rl-clis ]; then printf /home/user/rl-clis; else exit 1; fi',
);
const repoMountPath = (await repoPathResult.stdout()).trim();
const repoPackageJson =
repoMountPath ? await devbox.file.read({ file_path: `${repoMountPath}/package.json` }) : '';
const mountedExamplePath = path.posix.join(OBJECT_MOUNT_DIR, COPIED_EXAMPLE_FILE_NAME);
const mountedExampleContents = await devbox.file.read({ file_path: mountedExamplePath });
return {
resourcesCreated,
checks: [
{
name: 'Claude Code agent exists and is callable on the devbox',
passed: claudeVersionResult.exitCode === 0 && claudeVersion.length > 0,
details: claudeVersion || `exitCode=${claudeVersionResult.exitCode}`,
},
{
name: 'Anthropic access is routed through agent gateway',
passed:
devboxInfo.gateway_specs?.[GATEWAY_ENV_PREFIX] !== undefined &&
gatewayUrlResult.exitCode === 0 &&
gatewayUrl.startsWith('http') &&
gatewayTokenResult.exitCode === 0 &&
gatewayToken.startsWith('gws_') &&
gatewayToken !== anthropicApiKey,
details: `gateway_url=${gatewayUrl}, token_prefix=${gatewayToken.slice(0, 4) || 'missing'}`,
},
{
name: 'Claude Code Opus 4.5 gateway invocation is documented but not executed',
passed:
claudeVersionResult.exitCode === 0 &&
claudeGatewayCommand.includes(`claude --model ${CLAUDE_MODEL}`) &&
claudeGatewayCommand.includes(`ANTHROPIC_BASE_URL="$${GATEWAY_ENV_PREFIX}_URL"`),
details: 'Command is left commented out to avoid agent/model charges during example runs.',
},
{
name: 'rl-cli repository is available through code mount',
passed:
repoPathResult.exitCode === 0 &&
repoMountPath.length > 0 &&
repoPackageJson.includes('"name": "@runloop/rl-cli"'),
details: repoMountPath || `exitCode=${repoPathResult.exitCode}`,
},
{
name: 'object mount extracted the uploaded example file onto the devbox',
passed:
mountedExampleContents.includes('title: Devbox Mounts (Agent, Code, Object)') &&
mountedExampleContents.startsWith('#!/usr/bin/env -S npm run tsn -T'),
details: mountedExamplePath,
},
{
name: 'uploaded object shows TTL and compression details',
passed:
archiveInfo.content_type === 'tgz' &&
archiveInfo.delete_after_time_ms !== null &&
archiveInfo.delete_after_time_ms !== undefined &&
archiveInfo.delete_after_time_ms > archiveInfo.create_time_ms,
details: `content_type=${archiveInfo.content_type}, delete_after_time_ms=${archiveInfo.delete_after_time_ms ?? 'missing'}`,
},
],
};
}
export const runDevboxMountsExample = wrapRecipe({ recipe });
if (require.main === module) {
void runAsCli(runDevboxMountsExample);
}