acrylblends 0.0.0-stage → 0.2.0

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.
Files changed (74) hide show
  1. package/bin/acrylblends.js +4 -0
  2. package/dist/index.d.ts +117 -0
  3. package/dist/index.js +434 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +50 -4
  6. package/plugins/agent/agent.ts +152 -0
  7. package/plugins/agent/index.ts +20 -0
  8. package/plugins/agent/memory.ts +24 -0
  9. package/plugins/agent/model.ts +79 -0
  10. package/plugins/agent/plugin.json +10 -0
  11. package/plugins/agent/tools.ts +50 -0
  12. package/plugins/builder/index.ts +193 -0
  13. package/plugins/builder/plugin.json +11 -0
  14. package/plugins/builder/script.ts +162 -0
  15. package/plugins/events/index.ts +42 -0
  16. package/plugins/events/plugin.json +9 -0
  17. package/plugins/extensions/index.ts +738 -0
  18. package/plugins/extensions/ledger.ts +39 -0
  19. package/plugins/extensions/plugin.json +12 -0
  20. package/plugins/extensions/resolve.ts +30 -0
  21. package/plugins/extensions/stage.ts +44 -0
  22. package/plugins/http/index.ts +64 -0
  23. package/plugins/http/plugin.json +10 -0
  24. package/plugins/instance/index.ts +167 -0
  25. package/plugins/instance/plugin.json +10 -0
  26. package/plugins/manifest/index.ts +635 -0
  27. package/plugins/manifest/lock.ts +94 -0
  28. package/plugins/manifest/plugin.json +13 -0
  29. package/plugins/manifest/upgrade-cli.ts +70 -0
  30. package/plugins/manifest/upgrade.ts +97 -0
  31. package/plugins/openai/index.ts +159 -0
  32. package/plugins/openai/plugin.json +11 -0
  33. package/plugins/openai/prompt.ts +31 -0
  34. package/plugins/sandbox/index.ts +150 -0
  35. package/plugins/sandbox/plugin.json +8 -0
  36. package/plugins/sandbox/runner.mjs +18 -0
  37. package/plugins/save/cli.ts +19 -0
  38. package/plugins/save/index.ts +55 -0
  39. package/plugins/save/plugin.json +12 -0
  40. package/plugins/server/index.ts +138 -0
  41. package/plugins/server/plugin.json +10 -0
  42. package/plugins/settings/index.ts +270 -0
  43. package/plugins/settings/merge.ts +79 -0
  44. package/plugins/settings/plugin.json +13 -0
  45. package/plugins/tasks/index.ts +232 -0
  46. package/plugins/tasks/plugin.json +11 -0
  47. package/plugins/tasks/store.ts +86 -0
  48. package/plugins/tsconfig.json +19 -0
  49. package/templates/agent/Dockerfile +15 -0
  50. package/templates/agent/README.md +52 -0
  51. package/templates/agent/blend.yaml +58 -0
  52. package/templates/agent/dockerignore +3 -0
  53. package/templates/agent/gitignore +2 -0
  54. package/templates/agent/package.json +23 -0
  55. package/templates/agent/src/app.ts +98 -0
  56. package/templates/agent/src/main.ts +25 -0
  57. package/templates/agent/src/routes.ts +62 -0
  58. package/templates/agent/template.json +16 -0
  59. package/templates/agent/test/agent.test.ts +45 -0
  60. package/templates/agent/test/app.test.ts +46 -0
  61. package/templates/agent/test/builder.test.ts +186 -0
  62. package/templates/agent/test/model.test.ts +89 -0
  63. package/templates/agent/tsconfig.json +19 -0
  64. package/templates/app/README.md +26 -0
  65. package/templates/app/blend.yaml +23 -0
  66. package/templates/app/gitignore +2 -0
  67. package/templates/app/package.json +23 -0
  68. package/templates/app/src/app.ts +61 -0
  69. package/templates/app/src/main.ts +20 -0
  70. package/templates/app/src/routes.ts +14 -0
  71. package/templates/app/template.json +1 -0
  72. package/templates/app/test/app.test.ts +56 -0
  73. package/templates/app/tsconfig.json +19 -0
  74. package/README.md +0 -3
@@ -0,0 +1,94 @@
1
+ /**
2
+ * `npm run lock`: write `blend.lock.json` (formatVersion 3, the lock ACRYL writes too) next to the manifest, recording what the app is
3
+ * built from: the manifest's identity and digest, the Blueprint it was resolved over, its resolved parameters and rows, and a content
4
+ * digest of every plugin folder in `src/plugins/` and every extension in `extensions/`. Commit it; a diff of the lock is a review of
5
+ * what changed.
6
+ */
7
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
8
+ import { join, resolve } from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+ import {
11
+ type BlendDocument,
12
+ type BlendLock,
13
+ digestFolder,
14
+ generateBlendLock,
15
+ type LockModule,
16
+ manifestDigest,
17
+ parseDefinition,
18
+ resolveDefinition,
19
+ } from '@webboxes/blends-core'
20
+ import { blueprintsIn } from './index.ts'
21
+
22
+ export const LOCK_FILE = 'blend.lock.json'
23
+
24
+ type LockV3 = Extract<BlendLock, { formatVersion: 3 }>
25
+
26
+ function read(file: string): { text: string; document: BlendDocument } {
27
+ const text = readFileSync(file, 'utf8')
28
+ const { document, diagnostics } = parseDefinition(text)
29
+ if (diagnostics.length > 0)
30
+ throw new Error(`${file}:\n${diagnostics.map((d) => `${d.path}: ${d.message}`).join('\n')}`)
31
+ return { text, document: document as BlendDocument }
32
+ }
33
+
34
+ function localModules(appRoot: string, folder: string): LockModule[] {
35
+ const directory = join(appRoot, folder)
36
+ if (!existsSync(directory)) return []
37
+ return readdirSync(directory, { withFileTypes: true })
38
+ .filter((entry) => entry.isDirectory())
39
+ .map((entry) => ({
40
+ name: entry.name,
41
+ origin: 'local' as const,
42
+ digest: `sha256:${digestFolder(join(directory, entry.name))}`,
43
+ source: `${folder}/${entry.name}`,
44
+ }))
45
+ }
46
+
47
+ export function writeLock(appRoot: string): LockV3 {
48
+ const blendFile = join(appRoot, 'blend.yaml')
49
+ const getBlueprint = blueprintsIn(join(appRoot, 'blueprints'))
50
+ const { text, document } = read(blendFile)
51
+ const resolved = resolveDefinition(document, {
52
+ getDefinition: (id) => {
53
+ const file = getBlueprint(id)
54
+ return file === undefined ? undefined : read(file).document
55
+ },
56
+ })
57
+ if (resolved.definition === null)
58
+ throw new Error(
59
+ `${blendFile}:\n${resolved.diagnostics.map((d) => `${d.path}: ${d.message}`).join('\n')}`,
60
+ )
61
+ // A Blend records the Blueprint file it was resolved over, so an upgrade shows up as a lock diff.
62
+ const lineage = document.spec.lineage
63
+ const blueprintFile = lineage ? getBlueprint(lineage.blueprint) : undefined
64
+ const { lock } = generateBlendLock({
65
+ resolved: resolved.definition,
66
+ origin: {
67
+ id: document.metadata.id,
68
+ kind: document.kind,
69
+ version: document.metadata.version,
70
+ digest: manifestDigest(text),
71
+ },
72
+ modules: [...localModules(appRoot, 'src/plugins'), ...localModules(appRoot, 'extensions')],
73
+ ...(lineage
74
+ ? {
75
+ lineage: {
76
+ ...lineage,
77
+ ...(blueprintFile
78
+ ? { digest: manifestDigest(readFileSync(blueprintFile, 'utf8')) }
79
+ : {}),
80
+ },
81
+ }
82
+ : {}),
83
+ })
84
+ writeFileSync(join(appRoot, LOCK_FILE), `${JSON.stringify(lock, null, 2)}\n`)
85
+ return lock as LockV3
86
+ }
87
+
88
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
89
+ const appRoot = resolve(fileURLToPath(new URL('../../..', import.meta.url)))
90
+ const locked = writeLock(appRoot)
91
+ console.log(
92
+ `wrote ${LOCK_FILE}: ${locked.rows.length} rows, ${locked.modules.length} local plugins`,
93
+ )
94
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "manifest",
3
+ "description": "The app as data: blend.yaml lists ordered plugin rows with config, parameters and on/off switches; validated, composed and locked.",
4
+ "requires": [],
5
+ "inject": [],
6
+ "provides": [],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4",
9
+ "yaml": "2.9.0",
10
+ "@webboxes/blends-core": "0.4.0"
11
+ },
12
+ "mount": "in src/app.ts: const manifest = loadManifest(join(chosen.root, 'blend.yaml')); await compose(root, manifest, registry)"
13
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `npm run upgrade -- --from <old blueprint file> --to <new blueprint file> [--apply]`
3
+ *
4
+ * Plans the upgrade of this app's Blend to a newer version of its Blueprint and prints the plan (exit 1 when it is not safe). With
5
+ * `--apply` and a safe plan, it moves `spec.lineage.blueprintVersion` and puts the new Blueprint in `blueprints/<id>.yaml`, where the
6
+ * app finds it. Nothing else is changed; run `npm run lock` afterwards to record the new Blueprint's digest.
7
+ */
8
+ import { copyFileSync, mkdirSync } from 'node:fs'
9
+ import { join, resolve } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+ import { parseArgs } from 'node:util'
12
+ import { blueprintsIn, ManifestError } from './index.ts'
13
+ import { applyUpgrade, describePlan, planUpgrade } from './upgrade.ts'
14
+
15
+ export function runUpgrade(
16
+ argv: readonly string[],
17
+ appRoot: string,
18
+ out: (text: string) => void = console.log,
19
+ ): number {
20
+ let args: ReturnType<typeof parse>
21
+ try {
22
+ args = parse(argv)
23
+ } catch (error) {
24
+ out(`error: ${(error as Error).message}`)
25
+ return 2
26
+ }
27
+ const { from, to, apply } = args.values
28
+ if (!from || !to) {
29
+ out('usage: npm run upgrade -- --from <old blueprint file> --to <new blueprint file> [--apply]')
30
+ return 2
31
+ }
32
+ const blueprints = join(appRoot, 'blueprints')
33
+ try {
34
+ const blendFile = join(appRoot, 'blend.yaml')
35
+ const plan = planUpgrade({
36
+ blendFile,
37
+ oldBlueprint: resolve(from),
38
+ newBlueprint: resolve(to),
39
+ getBlueprint: blueprintsIn(blueprints),
40
+ })
41
+ out(describePlan(plan))
42
+ if (!plan.safe) return 1
43
+ if (apply) {
44
+ applyUpgrade(blendFile, plan)
45
+ mkdirSync(blueprints, { recursive: true })
46
+ copyFileSync(resolve(to), join(blueprints, `${plan.blueprint}.yaml`))
47
+ out(`applied: lineage is now ${plan.blueprint} ${plan.to}; run npm run lock to record it`)
48
+ }
49
+ return 0
50
+ } catch (error) {
51
+ out(
52
+ `error: ${error instanceof ManifestError ? error.message : error instanceof Error ? error.message : String(error)}`,
53
+ )
54
+ return 1
55
+ }
56
+ }
57
+
58
+ const parse = (argv: readonly string[]) =>
59
+ parseArgs({
60
+ args: [...argv],
61
+ strict: true,
62
+ options: { from: { type: 'string' }, to: { type: 'string' }, apply: { type: 'boolean' } },
63
+ })
64
+
65
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
66
+ process.exitCode = runUpgrade(
67
+ process.argv.slice(2),
68
+ resolve(fileURLToPath(new URL('../../..', import.meta.url))),
69
+ )
70
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Upgrading a Blend: what happens when its Blueprint moves to a new version. The plan and its application are ACRYL's own
3
+ * (`@webboxes/blends-core`); this file only reads the Blend and the two Blueprint files and keeps the terminal output.
4
+ *
5
+ * An upgrade is a *plan* before it is an action: what the Blueprint changed, what the Blend can no longer do on it (conflicts),
6
+ * which changes its overrides hide, and how the rows the app mounts would differ. Only a plan with no conflicts is `safe`.
7
+ * Applying it moves `spec.lineage.blueprintVersion` and nothing else, keeping the file's comments.
8
+ */
9
+ import { readFileSync, writeFileSync } from 'node:fs'
10
+ import {
11
+ applyUpgrade as applyToSource,
12
+ type BlendDocument,
13
+ type Diagnostic,
14
+ parseDefinition,
15
+ planUpgrade as planFromDocuments,
16
+ type RowChange,
17
+ type UpgradePlan,
18
+ } from '@webboxes/blends-core'
19
+ import type { LoadOptions } from './index.ts'
20
+
21
+ export type { RowChange, UpgradePlan }
22
+
23
+ const refusal = (diagnostics: readonly Diagnostic[], file?: string) =>
24
+ new Error(
25
+ `${file ? `${file}:\n` : ''}${diagnostics.map((d) => `${d.path}: ${d.message}`).join('\n')}`,
26
+ )
27
+
28
+ function read(file: string): BlendDocument {
29
+ const { document, diagnostics } = parseDefinition(readFileSync(file, 'utf8'))
30
+ if (diagnostics.length > 0) throw refusal(diagnostics, file)
31
+ return document as BlendDocument
32
+ }
33
+
34
+ /**
35
+ * Plan the upgrade of the Blend in `blendFile` from the Blueprint file it was made from (`oldBlueprint`) to a newer one
36
+ * (`newBlueprint`). `getBlueprint` finds the parents of either Blueprint, if they have any. Throws for input that cannot be read
37
+ * as a plan: a Blueprint file with the wrong id, an old Blueprint whose version is not the Blend's lineage version, or a document
38
+ * that is not a Blend. Everything the upgrade would break is in the plan.
39
+ */
40
+ export function planUpgrade(options: {
41
+ blendFile: string
42
+ oldBlueprint: string
43
+ newBlueprint: string
44
+ getBlueprint?: LoadOptions['getBlueprint']
45
+ values?: LoadOptions['values']
46
+ }): UpgradePlan {
47
+ const getDefinition = options.getBlueprint
48
+ ? (id: string) => {
49
+ const file = options.getBlueprint?.(id)
50
+ return file === undefined ? undefined : read(file)
51
+ }
52
+ : undefined
53
+ const { plan, diagnostics } = planFromDocuments({
54
+ blend: read(options.blendFile),
55
+ oldBlueprint: read(options.oldBlueprint),
56
+ newBlueprint: read(options.newBlueprint),
57
+ ...(getDefinition ? { getDefinition } : {}),
58
+ ...(options.values ? { parameters: options.values } : {}),
59
+ })
60
+ if (plan === null) throw refusal(diagnostics)
61
+ return plan
62
+ }
63
+
64
+ /** Move the Blend's lineage to the plan's new version, keeping the file's comments and layout. Refuses an unsafe plan. */
65
+ export function applyUpgrade(blendFile: string, plan: UpgradePlan): void {
66
+ const { source, diagnostics } = applyToSource(readFileSync(blendFile, 'utf8'), plan)
67
+ if (source === null) throw refusal(diagnostics)
68
+ writeFileSync(blendFile, source)
69
+ }
70
+
71
+ /** A short, readable account of a plan, for a terminal. */
72
+ export function describePlan(plan: UpgradePlan): string {
73
+ const line = (change: RowChange) =>
74
+ ` ${change.change.padEnd(7)} ${change.id}${change.fields ? ` (${change.fields.join(', ')})` : ''}`
75
+ const lines = [`${plan.blueprint}: ${plan.from} -> ${plan.to}`]
76
+ lines.push(
77
+ plan.blueprintChanges.length ? 'the Blueprint changed:' : 'the Blueprint has no row changes',
78
+ ...plan.blueprintChanges.map(line),
79
+ )
80
+ if (plan.shadowed.length)
81
+ lines.push(
82
+ 'your overrides hide some of that:',
83
+ ...plan.shadowed.map((entry) => ` ${entry.id}: ${entry.keys.join(', ')}`),
84
+ )
85
+ if (plan.conflicts.length)
86
+ lines.push(
87
+ 'this Blend would break:',
88
+ ...plan.conflicts.map((conflict) => ` ${conflict.path}: ${conflict.message}`),
89
+ )
90
+ else
91
+ lines.push(
92
+ plan.appChanges.length ? 'your app would change:' : 'your app would not change',
93
+ ...plan.appChanges.map(line),
94
+ )
95
+ lines.push(plan.safe ? 'safe to apply' : 'NOT safe: resolve the conflicts first')
96
+ return lines.join('\n')
97
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * A real model for the agent: any OpenAI-compatible chat completions API (OpenAI, DeepSeek, Ollama, LM Studio, a local server), called
3
+ * with `fetch`, no SDK. It provides the same `model` service as the scripted model, so the agent, its tools and the builder do not change.
4
+ * Each step is stateless: the conversation is rebuilt from the run's question and tool-call trace.
5
+ */
6
+ import type { Context } from '@deepseek-ai/cordis'
7
+ import type { Model, ModelInput, Step } from '../agent/index.ts'
8
+ import { SYSTEM_PROMPT } from './prompt.ts'
9
+
10
+ export interface OpenAIConfig {
11
+ /** For example https://api.openai.com/v1, https://api.deepseek.com/v1, http://127.0.0.1:11434/v1 (Ollama). */
12
+ baseUrl: string
13
+ model: string
14
+ /** Sent as a bearer token. Local servers need none. Keep it out of files the app commits. */
15
+ apiKey?: string
16
+ /** Replaces the default system prompt (how to write a Cordis plugin that installs). */
17
+ system?: string
18
+ temperature?: number
19
+ }
20
+
21
+ /** The model configuration from an environment-shaped object, or `undefined` when none is set. Pass it `process.env` in main.ts. */
22
+ export function modelConfigFrom(
23
+ env: Readonly<Record<string, string | undefined>>,
24
+ ): OpenAIConfig | undefined {
25
+ const baseUrl = env.ACRYLBLENDS_MODEL_URL?.trim()
26
+ const model = env.ACRYLBLENDS_MODEL?.trim()
27
+ if (!baseUrl || !model) return undefined
28
+ const apiKey = env.ACRYLBLENDS_MODEL_KEY?.trim()
29
+ return { baseUrl, model, ...(apiKey ? { apiKey } : {}) }
30
+ }
31
+
32
+ interface ToolCallMessage {
33
+ id: string
34
+ type: 'function'
35
+ function: { name: string; arguments: string }
36
+ }
37
+
38
+ type Message =
39
+ | { role: 'system' | 'user'; content: string }
40
+ | {
41
+ role: 'assistant'
42
+ content: string | null
43
+ tool_calls?: ToolCallMessage[]
44
+ reasoning_content?: string
45
+ }
46
+ | { role: 'tool'; tool_call_id: string; content: string }
47
+
48
+ /** The chat messages for a step: system, the question, then each tool call and its result. */
49
+ export function messagesFor(input: ModelInput, system: string): Message[] {
50
+ const messages: Message[] = [
51
+ { role: 'system', content: system },
52
+ { role: 'user', content: input.question },
53
+ ]
54
+ for (const [index, call] of (input.trace ?? []).entries()) {
55
+ const id = `call_${index}`
56
+ messages.push({
57
+ role: 'assistant',
58
+ content: null,
59
+ tool_calls: [
60
+ {
61
+ id,
62
+ type: 'function',
63
+ function: { name: call.tool, arguments: JSON.stringify(call.input ?? {}) },
64
+ },
65
+ ],
66
+ // A thinking model (DeepSeek) refuses the next request unless its reasoning for the call comes back with it.
67
+ ...(call.reasoning !== undefined ? { reasoning_content: call.reasoning } : {}),
68
+ })
69
+ messages.push({
70
+ role: 'tool',
71
+ tool_call_id: id,
72
+ content: JSON.stringify(
73
+ call.error === undefined ? (call.result ?? null) : { error: call.error },
74
+ ),
75
+ })
76
+ }
77
+ return messages
78
+ }
79
+
80
+ export class ModelError extends Error {
81
+ override name = 'ModelError'
82
+ }
83
+
84
+ /** One step: ask the API, and turn its answer into a tool call or final text. */
85
+ export async function complete(
86
+ config: OpenAIConfig,
87
+ input: ModelInput,
88
+ signal?: AbortSignal,
89
+ ): Promise<Step> {
90
+ const response = await fetch(`${config.baseUrl.replace(/\/+$/, '')}/chat/completions`, {
91
+ method: 'POST',
92
+ headers: {
93
+ 'content-type': 'application/json',
94
+ ...(config.apiKey ? { authorization: `Bearer ${config.apiKey}` } : {}),
95
+ },
96
+ body: JSON.stringify({
97
+ model: config.model,
98
+ temperature: config.temperature ?? 0,
99
+ messages: messagesFor(input, config.system ?? SYSTEM_PROMPT),
100
+ tools: (input.tools ?? []).map((tool) => ({
101
+ type: 'function',
102
+ function: {
103
+ name: tool.name,
104
+ description: tool.description,
105
+ parameters: { type: 'object', additionalProperties: true },
106
+ },
107
+ })),
108
+ }),
109
+ ...(signal ? { signal } : {}),
110
+ })
111
+ if (!response.ok)
112
+ throw new ModelError(
113
+ `the model API answered ${response.status}: ${(await response.text()).slice(0, 300)}`,
114
+ )
115
+ const answer = (await response.json()) as {
116
+ choices?: Array<{
117
+ message?: {
118
+ content?: string | null
119
+ reasoning_content?: string | null
120
+ tool_calls?: ToolCallMessage[]
121
+ }
122
+ }>
123
+ }
124
+ const message = answer.choices?.[0]?.message
125
+ if (!message) throw new ModelError('the model API answered without a message')
126
+ const call = message.tool_calls?.[0]
127
+ if (call) {
128
+ let parsed: unknown
129
+ try {
130
+ parsed = call.function.arguments ? JSON.parse(call.function.arguments) : {}
131
+ } catch {
132
+ return {
133
+ tool: call.function.name,
134
+ input: { invalidArguments: call.function.arguments },
135
+ ...(typeof message.reasoning_content === 'string'
136
+ ? { reasoning: message.reasoning_content }
137
+ : {}),
138
+ }
139
+ }
140
+ return {
141
+ tool: call.function.name,
142
+ input: parsed,
143
+ ...(typeof message.reasoning_content === 'string'
144
+ ? { reasoning: message.reasoning_content }
145
+ : {}),
146
+ }
147
+ }
148
+ return { text: (message.content ?? '').trim() || '(no answer)' }
149
+ }
150
+
151
+ /** The plugin: provides `model`, in place of the scripted one. */
152
+ export const openai = {
153
+ name: 'openai',
154
+ apply(ctx: Context, config: OpenAIConfig) {
155
+ if (!config?.baseUrl || !config.model)
156
+ throw new ModelError('openai needs { baseUrl, model } (and apiKey for hosted APIs)')
157
+ ctx.provide('model', { complete: (input) => complete(config, input) } satisfies Model)
158
+ },
159
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "openai",
3
+ "description": "A real model for the agent: any OpenAI-compatible chat API (OpenAI, DeepSeek, Ollama, LM Studio) over fetch, with tool calls. No SDK.",
4
+ "requires": ["agent"],
5
+ "inject": [],
6
+ "provides": ["model"],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4"
9
+ },
10
+ "mount": "use it in place of the scripted model: registry.model = openai, with config { baseUrl, apiKey, model }"
11
+ }
@@ -0,0 +1,31 @@
1
+ /** What a real model is told: the app is built from Cordis plugins, and how to write one that installs. */
2
+ export const SYSTEM_PROMPT = `You are the agent inside a running app built from Cordis plugins (@deepseek-ai/cordis). You extend the app by writing
3
+ Cordis plugins and installing them while it runs. A plugin is the building unit: every capability is one.
4
+
5
+ To add a capability:
6
+ 1. write_plugin { name, files: { "index.ts": "..." } } (name: lowercase letters, digits and dashes; it is the folder name)
7
+ 2. install_plugin { name } -> read the result: ok, state ("active", "pending" with "missing" services, or "failed"), errors
8
+ 3. If it failed, read the error, fix the files with write_plugin, and install again. A failed update keeps the old version running.
9
+
10
+ A plugin's index.ts is TypeScript that Node runs directly (erasable syntax only: no enums, no parameter properties):
11
+
12
+ import type { Context } from '@deepseek-ai/cordis'
13
+ export const name = 'hello' // must equal the folder name
14
+ export const inject = ['http'] // services it needs; it waits (pending) until they exist
15
+ export function apply(ctx: Context) {
16
+ const http = ctx.get('http') as { route(method: string, path: string, handler: (request: { body: unknown }) => { status?: number; body: unknown }): () => void }
17
+ ctx.effect(() => http.route('GET', '/hello', () => ({ body: { text: 'Hello' } }))) // in ctx.effect: removed when the plugin is
18
+ }
19
+ export async function check(ctx: Context) { // optional: must pass, or the install is rolled back
20
+ const http = ctx.get('http') as { handle(r: { method: string; path: string; body: unknown }): Promise<{ status: number }> }
21
+ if ((await http.handle({ method: 'GET', path: '/hello', body: undefined })).status !== 200) throw new Error('GET /hello failed')
22
+ }
23
+
24
+ For a capability that is a pure function of data (parse, convert, compute), write a sandboxed TOOL instead of a plugin. It cannot touch the
25
+ host: it runs in a separate process that can read only its own folder. Files: index.ts exporting run(input) and tool.json:
26
+ { "description": "what it does and its input", "examples": [ { "input": {...}, "output": {...} } ] }
27
+ The examples are run in the sandbox at install and must match exactly, or the install is refused. Use write_plugin and install_plugin for tools too.
28
+
29
+ Rules: import only relative files, node: built-ins and @deepseek-ai/cordis. Provide services with ctx.provide(name, value) and
30
+ use others with ctx.get(name). Put timers, listeners and routes inside ctx.effect(() => cleanup). Use list_plugins and
31
+ read_plugin to see what exists. When you are done, answer in one or two plain sentences saying what you built.`
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The sandbox (`ctx.sandbox`): run code you do not trust, such as a tool an agent just wrote, without giving it the host.
3
+ *
4
+ * Each call starts a fresh Node child process with the permission model on (`node --permission`):
5
+ * - it may read only the tool's own folder (and the runner); every other file read is denied;
6
+ * - it may not write files, start child processes or workers, load native addons, or use the inspector;
7
+ * - it gets an empty environment, so no keys or paths leak in;
8
+ * - it is killed after a time limit, and capped in memory and in output size.
9
+ *
10
+ * What this does NOT do: Node 24 has no network permission, so a tool can still reach the network. Where that matters (a hosted tenant),
11
+ * restrict egress outside the process. The calls are independent: nothing a tool does survives into the next call.
12
+ */
13
+ import { spawn } from 'node:child_process'
14
+ import { realpathSync } from 'node:fs'
15
+ import { dirname, join } from 'node:path'
16
+ import { fileURLToPath, pathToFileURL } from 'node:url'
17
+ import type { Context } from '@deepseek-ai/cordis'
18
+
19
+ export interface SandboxOptions {
20
+ /** Extra folders the tool may read, besides its own. */
21
+ read?: readonly string[]
22
+ /** Wall-clock limit. Default 5000. */
23
+ timeoutMs?: number
24
+ /** Heap limit in MB. Default 128. */
25
+ memoryMb?: number
26
+ /** Largest output kept, in bytes. Default 1 MiB. */
27
+ maxOutputBytes?: number
28
+ }
29
+
30
+ export type SandboxResult =
31
+ | { readonly ok: true; readonly output: unknown }
32
+ | {
33
+ readonly ok: false
34
+ readonly kind: 'denied' | 'timeout' | 'crash' | 'error' | 'output'
35
+ readonly error: string
36
+ }
37
+
38
+ export interface Sandbox {
39
+ /** Run `entry` (a file that exports `run(input)`) with `input`, as described above. Never throws: failures are results. */
40
+ run(entry: string, input: unknown, options?: SandboxOptions): Promise<SandboxResult>
41
+ }
42
+
43
+ declare module '@deepseek-ai/cordis' {
44
+ interface Context {
45
+ sandbox: Sandbox
46
+ }
47
+ }
48
+
49
+ const RUNNER = join(dirname(fileURLToPath(import.meta.url)), 'runner.mjs')
50
+ const MARK = '\n@@acrylblends-result@@'
51
+
52
+ export const sandbox: Sandbox = {
53
+ run(entry, input, options = {}) {
54
+ // The permission system compares real paths (on macOS /var is /private/var), so grant and import real paths.
55
+ try {
56
+ entry = realpathSync(entry)
57
+ } catch {
58
+ return Promise.resolve({ ok: false, kind: 'error', error: `${entry} does not exist` })
59
+ }
60
+ const timeoutMs = options.timeoutMs ?? 5000
61
+ const maxOutput = options.maxOutputBytes ?? 1024 * 1024
62
+ const reads = [dirname(entry), ...(options.read ?? [])].map((path) => {
63
+ try {
64
+ return realpathSync(path)
65
+ } catch {
66
+ return path
67
+ }
68
+ })
69
+ return new Promise((done) => {
70
+ const child = spawn(
71
+ process.execPath,
72
+ [
73
+ '--permission',
74
+ `--allow-fs-read=${RUNNER}`,
75
+ ...reads.map((path) => `--allow-fs-read=${path}`),
76
+ '--disallow-code-generation-from-strings',
77
+ `--max-old-space-size=${options.memoryMb ?? 128}`,
78
+ RUNNER,
79
+ pathToFileURL(entry).href,
80
+ ],
81
+ { env: {}, cwd: dirname(entry), stdio: ['pipe', 'pipe', 'pipe'] },
82
+ )
83
+ let out = ''
84
+ let err = ''
85
+ let finished = false
86
+ const finish = (result: SandboxResult) => {
87
+ if (finished) return
88
+ finished = true
89
+ clearTimeout(timer)
90
+ child.kill('SIGKILL')
91
+ done(result)
92
+ }
93
+ const timer = setTimeout(
94
+ () => finish({ ok: false, kind: 'timeout', error: `no result within ${timeoutMs} ms` }),
95
+ timeoutMs,
96
+ )
97
+ child.stdout.on('data', (chunk: Buffer) => {
98
+ out += chunk
99
+ if (out.length > maxOutput)
100
+ finish({ ok: false, kind: 'output', error: `more than ${maxOutput} bytes of output` })
101
+ })
102
+ child.stderr.on('data', (chunk: Buffer) => {
103
+ if (err.length < 4000) err += chunk
104
+ })
105
+ child.on('error', (error) => finish({ ok: false, kind: 'crash', error: error.message }))
106
+ child.on('close', (code) => {
107
+ const at = out.lastIndexOf(MARK)
108
+ if (at === -1) {
109
+ const denied = /ERR_ACCESS_DENIED/.test(err)
110
+ return finish({
111
+ ok: false,
112
+ kind: denied ? 'denied' : 'crash',
113
+ error: (
114
+ err
115
+ .trim()
116
+ .split('\n')
117
+ .find((line) => /Error/.test(line)) ?? `exited with ${code}`
118
+ ).slice(0, 300),
119
+ })
120
+ }
121
+ let parsed: { ok: boolean; output?: unknown; error?: string; code?: string }
122
+ try {
123
+ parsed = JSON.parse(out.slice(at + MARK.length))
124
+ } catch {
125
+ return finish({
126
+ ok: false,
127
+ kind: 'crash',
128
+ error: 'the tool returned something that is not JSON',
129
+ })
130
+ }
131
+ if (parsed.ok) return finish({ ok: true, output: parsed.output ?? null })
132
+ finish({
133
+ ok: false,
134
+ kind: parsed.code === 'ERR_ACCESS_DENIED' ? 'denied' : 'error',
135
+ error: parsed.error ?? 'the tool failed',
136
+ })
137
+ })
138
+ child.stdin.on('error', () => undefined)
139
+ child.stdin.end(JSON.stringify({ input }))
140
+ })
141
+ },
142
+ }
143
+
144
+ /** The plugin: provides `ctx.sandbox`. */
145
+ export const sandboxPlugin = {
146
+ name: 'sandbox',
147
+ apply(ctx: Context) {
148
+ ctx.provide('sandbox', sandbox)
149
+ },
150
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "sandbox",
3
+ "description": "Run untrusted code (agent-written tools) in a child process under Node's permission model: read only its own folder, no child processes, no env, time and memory limits.",
4
+ "requires": [],
5
+ "inject": [],
6
+ "provides": ["sandbox"],
7
+ "dependencies": { "@deepseek-ai/cordis": "4.0.4" }
8
+ }
@@ -0,0 +1,18 @@
1
+ // Runs inside the sandbox child process, started with `node --permission`. Reads { input } as JSON from stdin, calls the tool's exported
2
+ // `run(input)`, and prints the result after a marker, so anything the tool itself prints cannot be mistaken for the result.
3
+ const MARK = '\n@@acrylblends-result@@'
4
+ const chunks = []
5
+ for await (const chunk of process.stdin) chunks.push(chunk)
6
+ const { input } = JSON.parse(Buffer.concat(chunks).toString('utf8'))
7
+ let result
8
+ try {
9
+ const tool = await import(process.argv[2])
10
+ if (typeof tool.run !== 'function')
11
+ throw Object.assign(new Error('the tool must export a function named run'), {
12
+ code: 'E_NO_RUN',
13
+ })
14
+ result = { ok: true, output: (await tool.run(input)) ?? null }
15
+ } catch (error) {
16
+ result = { ok: false, error: String(error?.message ?? error), code: String(error?.code ?? '') }
17
+ }
18
+ process.stdout.write(`${MARK}${JSON.stringify(result)}\n`)
@@ -0,0 +1,19 @@
1
+ /** `npm run save -- "message"`: save the app folder this file lives in. Prints the result; exits 1 when the save was refused. */
2
+ import { resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { saveFolder } from './index.ts'
5
+
6
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
7
+ const appRoot = fileURLToPath(new URL('../../..', import.meta.url))
8
+ const result = saveFolder(appRoot, process.argv.slice(2).join(' ').trim() || 'save the app')
9
+ if (result.status === 'refused') {
10
+ console.error(`not saved: ${result.reason}`)
11
+ for (const finding of result.secrets ?? [])
12
+ console.error(` ${finding.path}${finding.line ? `:${finding.line}` : ''} ${finding.kind}`)
13
+ process.exitCode = 1
14
+ } else if (result.status === 'nothing-to-save') console.log('nothing to save')
15
+ else
16
+ console.log(
17
+ `saved ${result.commit.slice(0, 12)}${result.pushed ? ` and pushed to ${result.remote}` : ''}`,
18
+ )
19
+ }