@tangle-network/create-agent-app 0.47.1 → 0.47.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
package/template-chat/README.md
CHANGED
|
@@ -51,3 +51,11 @@ Then walk the trail:
|
|
|
51
51
|
Identity comes from the session, never a request body. Inaccessible threads
|
|
52
52
|
read as 404. No mock agent — missing sandbox credentials fail loud. See
|
|
53
53
|
`AGENTS.md` for the full contract.
|
|
54
|
+
|
|
55
|
+
## Artifact index
|
|
56
|
+
|
|
57
|
+
`GET /api/files` lists files under `/home/agent/artifacts` for the signed-in user's workspace.
|
|
58
|
+
The default prompt directs the agent to save user-facing outputs there; other workspace files are not indexed.
|
|
59
|
+
The route returns `ready` with filtered metadata or `warming` when no ready sandbox exists.
|
|
60
|
+
It never provisions or resumes a sandbox, and it does not provide file downloads.
|
|
61
|
+
Hidden files and paths outside the artifact root are excluded.
|
|
@@ -9,3 +9,6 @@ Hard rules:
|
|
|
9
9
|
- Never fabricate a figure (price, identifier, clause, date). Cite a real record or say NOT ON FILE.
|
|
10
10
|
- Route every regulated or client-facing action to a named human for approval; propose, don't execute.
|
|
11
11
|
- State what you did and what evidence backs it. No filler.
|
|
12
|
+
|
|
13
|
+
Save files created for the user under `/home/agent/artifacts` so they appear in the workspace artifact index.
|
|
14
|
+
Keep credentials, configuration, and private working files outside that directory.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createSandboxFileIndexRoute } from '@tangle-network/agent-app/chat-routes'
|
|
2
|
+
import { peekWorkspaceSandbox } from '@tangle-network/agent-app/sandbox'
|
|
3
|
+
import type { ChatApp } from './chat'
|
|
4
|
+
import type { AppEnv } from './env'
|
|
5
|
+
import { createSandboxShell } from './sandbox'
|
|
6
|
+
|
|
7
|
+
/** Agent output directory; configuration and other workspace files are excluded. */
|
|
8
|
+
export const ARTIFACT_ROOT = '/home/agent/artifacts'
|
|
9
|
+
|
|
10
|
+
export function createArtifactIndex(
|
|
11
|
+
env: AppEnv,
|
|
12
|
+
app: ChatApp,
|
|
13
|
+
peek: typeof peekWorkspaceSandbox = peekWorkspaceSandbox,
|
|
14
|
+
) {
|
|
15
|
+
return createSandboxFileIndexRoute({
|
|
16
|
+
authorize: async ({ request }) => {
|
|
17
|
+
const session = await app.auth.getSession(request)
|
|
18
|
+
if (!session) return { status: 'denied', response: Response.json({ error: 'Unauthorized' }, { status: 401 }) }
|
|
19
|
+
const userId = session.user.id
|
|
20
|
+
const requestedWorkspace = new URL(request.url).searchParams.get('workspaceId')
|
|
21
|
+
if (requestedWorkspace !== null && requestedWorkspace !== userId) {
|
|
22
|
+
return { status: 'denied', response: Response.json({ error: 'Not found' }, { status: 404 }) }
|
|
23
|
+
}
|
|
24
|
+
const found = await peek(createSandboxShell(env), { workspaceId: userId, userId })
|
|
25
|
+
if (found.status !== 'running') return { status: 'warming' }
|
|
26
|
+
return { status: 'ready', fs: found.box.fs, root: ARTIFACT_ROOT }
|
|
27
|
+
},
|
|
28
|
+
maxEntries: 1000,
|
|
29
|
+
})
|
|
30
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* POST /api/chat run one turn (NDJSON stream)
|
|
12
12
|
* GET /api/chat/replay/:turnId replay a buffered turn (?fromSeq=)
|
|
13
13
|
* GET /api/chat/running live turn ids on a thread (?threadId=)
|
|
14
|
+
* GET /api/files owned artifact index (no provisioning)
|
|
14
15
|
* POST /api/chat/upload multipart upload → prompt parts
|
|
15
16
|
* GET /api/chat/interactions outstanding agent asks (?threadId=)
|
|
16
17
|
* POST /api/chat/interactions answer an ask
|
|
@@ -18,6 +19,8 @@
|
|
|
18
19
|
* POST /v1/agents/:slug/chat/completions OpenAI-compatible API
|
|
19
20
|
*/
|
|
20
21
|
|
|
22
|
+
import { createArtifactIndex } from './files'
|
|
23
|
+
import type { peekWorkspaceSandbox } from '@tangle-network/agent-app/sandbox'
|
|
21
24
|
import { config } from '../agent.config'
|
|
22
25
|
import { buildChatApp, type ChatApp } from './chat'
|
|
23
26
|
import type { AppEnv } from './env'
|
|
@@ -35,6 +38,8 @@ export interface WorkerAssembly {
|
|
|
35
38
|
): ReturnType<typeof buildGatewayApp>
|
|
36
39
|
/** Test override. Production follows agent.config.ts. */
|
|
37
40
|
gatewayEnabled?: boolean
|
|
41
|
+
/** Test seam; production reads existing sandbox state without provisioning. */
|
|
42
|
+
peekWorkspace?: typeof peekWorkspaceSandbox
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
function attachThreadUrl(response: Response, request: Request): Response {
|
|
@@ -100,6 +105,10 @@ export function createWorker(assembly: WorkerAssembly = defaultAssembly): Export
|
|
|
100
105
|
return attachThreadUrl(await gateway.fetch(request), request)
|
|
101
106
|
}
|
|
102
107
|
|
|
108
|
+
if (pathname === '/api/files' && method === 'GET') {
|
|
109
|
+
return createArtifactIndex(env, app, assembly.peekWorkspace)(request)
|
|
110
|
+
}
|
|
111
|
+
|
|
103
112
|
if (pathname.startsWith('/api/auth/')) return app.auth.auth.handler(request)
|
|
104
113
|
|
|
105
114
|
if (pathname === '/api/chat' && method === 'POST') {
|
|
@@ -21,7 +21,7 @@ import { dirname, join } from 'node:path'
|
|
|
21
21
|
import { fileURLToPath } from 'node:url'
|
|
22
22
|
import Database from 'better-sqlite3'
|
|
23
23
|
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
|
24
|
-
import { describe, expect, it } from 'vitest'
|
|
24
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
25
25
|
|
|
26
26
|
import {
|
|
27
27
|
createSandboxChatProducer,
|
|
@@ -46,7 +46,8 @@ import { buildChatApp, type ChatApp } from '../src/chat'
|
|
|
46
46
|
import type { AppEnv } from '../src/env'
|
|
47
47
|
import { buildGatewayApp } from '../src/gateway'
|
|
48
48
|
import { appSlug } from '../src/sandbox'
|
|
49
|
-
import {
|
|
49
|
+
import { ARTIFACT_ROOT } from '../src/files'
|
|
50
|
+
import { createWorker, type WorkerAssembly } from '../src/worker'
|
|
50
51
|
|
|
51
52
|
const BASE = 'http://localhost:8787'
|
|
52
53
|
const MODEL = 'test/model-1'
|
|
@@ -126,6 +127,7 @@ interface Harness {
|
|
|
126
127
|
async function createHarness(
|
|
127
128
|
produce: (args: ChatTurnProduceArgs<void>) => ChatTurnRouteProducer = () =>
|
|
128
129
|
createSandboxChatProducer({ events: feed(RAW_TURN_EVENTS), model: MODEL }),
|
|
130
|
+
peekWorkspace?: WorkerAssembly['peekWorkspace'],
|
|
129
131
|
): Promise<Harness> {
|
|
130
132
|
const database = openMigratedDb()
|
|
131
133
|
const pending: Promise<unknown>[] = []
|
|
@@ -159,6 +161,7 @@ async function createHarness(
|
|
|
159
161
|
app.routes.turn = (request) =>
|
|
160
162
|
originalTurn(request, { waitUntil: (p) => void pending.push(p) })
|
|
161
163
|
const worker = createWorker({
|
|
164
|
+
peekWorkspace,
|
|
162
165
|
buildChatApp: () => app,
|
|
163
166
|
buildGatewayApp: (_env, chatApp, options) => {
|
|
164
167
|
gatewayBuildCount += 1
|
|
@@ -234,6 +237,42 @@ async function readGatewayText(response: Response): Promise<string> {
|
|
|
234
237
|
// ── the gate ────────────────────────────────────────────────────────────────
|
|
235
238
|
|
|
236
239
|
describe('e2e: fake sandbox producer → streamed turn → persisted transcript', () => {
|
|
240
|
+
it('indexes only owned artifacts through the real authenticated Worker route', async () => {
|
|
241
|
+
expect(config.systemPrompt).toContain(ARTIFACT_ROOT)
|
|
242
|
+
const tree = vi.fn(async () => ({
|
|
243
|
+
root: '/home/agent/artifacts',
|
|
244
|
+
files: [
|
|
245
|
+
{ path: '/home/agent/artifacts/report.txt', size: 7 },
|
|
246
|
+
{ path: '/home/agent/artifacts/.env', size: 99 },
|
|
247
|
+
{ path: '/home/agent/private.txt', size: 99 },
|
|
248
|
+
{ path: '../private.txt', size: 99 },
|
|
249
|
+
],
|
|
250
|
+
stats: { truncated: false },
|
|
251
|
+
}))
|
|
252
|
+
const peek = vi.fn().mockResolvedValue({ status: 'running', box: { fs: { tree } } })
|
|
253
|
+
const h = await createHarness(undefined, peek)
|
|
254
|
+
expect((await h.workerFetch(new Request(`${BASE}/api/files`))).status).toBe(401)
|
|
255
|
+
expect(peek).not.toHaveBeenCalled()
|
|
256
|
+
const denied = await h.workerFetch(new Request(`${BASE}/api/files?workspaceId=another-user`, {
|
|
257
|
+
headers: { cookie: h.cookie },
|
|
258
|
+
}))
|
|
259
|
+
expect(denied.status).toBe(404)
|
|
260
|
+
expect(peek).not.toHaveBeenCalled()
|
|
261
|
+
const response = await h.workerFetch(new Request(`${BASE}/api/files?root=/home/agent`, {
|
|
262
|
+
headers: { cookie: h.cookie },
|
|
263
|
+
}))
|
|
264
|
+
expect(response.status).toBe(200)
|
|
265
|
+
expect(await response.json()).toMatchObject({ status: 'ready', files: [{ path: 'report.txt', name: 'report.txt', size: 7 }] })
|
|
266
|
+
expect(tree).toHaveBeenCalledWith('/home/agent/artifacts', { maxDepth: 12 })
|
|
267
|
+
const session = await h.app.auth.getSession(new Request(BASE, { headers: { cookie: h.cookie } }))
|
|
268
|
+
expect(peek).toHaveBeenCalledWith(expect.anything(), { userId: session!.user.id, workspaceId: session!.user.id })
|
|
269
|
+
peek.mockResolvedValue({ status: 'absent' })
|
|
270
|
+
tree.mockClear()
|
|
271
|
+
const cold = await h.workerFetch(new Request(`${BASE}/api/files`, { headers: { cookie: h.cookie } }))
|
|
272
|
+
expect(await cold.json()).toEqual({ status: 'warming' })
|
|
273
|
+
expect(tree).not.toHaveBeenCalled()
|
|
274
|
+
})
|
|
275
|
+
|
|
237
276
|
it('normalizes path-backed generic files for the sandbox prompt API', () => {
|
|
238
277
|
expect(
|
|
239
278
|
normalizeChatPromptForSandbox([
|