@r8s/paperclip 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.
@@ -0,0 +1,450 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, jsx } from '@r8s/core'
3
+ import { Namespace, OperatorContext, SecretContext, RoutingContext } from '@r8s/core/defaults'
4
+ import { runGuardrails, noPlaintextSecrets, validateResource } from '@r8s/core'
5
+ import { operators } from '@r8s/crds'
6
+ import type { r8sElement } from '@r8s/core'
7
+
8
+ // Paperclip recipe tests:
9
+ // 1. Operator declarations (cnpg via Database, deduped via OperatorContext)
10
+ // 2. Rendering: defaults, sandbox agents (hardened sandbox, TCP probes),
11
+ // all props, gateway/ingress adaptation
12
+ // 3. Namespace inheritance from the Platform context
13
+ // 4. Security: no plaintext credentials in rendered output
14
+ import { Paperclip } from '../src/index'
15
+
16
+ /** Render Paperclip inside a Platform-like secrets backend (OpenBao). */
17
+ function renderPaperclip(props: Record<string, unknown>): ReturnType<typeof render> {
18
+ return render(
19
+ jsx(SecretContext.Provider, {
20
+ value: { backend: 'openbao', mount: 'kv', path: 'test' },
21
+ children: jsx(Paperclip, props as never),
22
+ })
23
+ )
24
+ }
25
+
26
+ /** Render Paperclip inside a Platform-like Namespace context (no explicit namespace prop). */
27
+ function renderPaperclipInNamespace(
28
+ namespaceValue: string,
29
+ props: Record<string, unknown>
30
+ ): ReturnType<typeof render> {
31
+ return render(
32
+ jsx(Namespace.Provider, {
33
+ value: namespaceValue,
34
+ children: jsx(SecretContext.Provider, {
35
+ value: { backend: 'openbao', mount: 'kv', path: 'test' },
36
+ children: jsx(Paperclip, props as never),
37
+ }),
38
+ })
39
+ )
40
+ }
41
+
42
+ /** Render Paperclip wrapped only in an OperatorContext (no secrets backend). */
43
+ function renderPaperclipWithContext(operators_: any[], props: Record<string, unknown>): r8sElement {
44
+ return jsx(OperatorContext.Provider, {
45
+ value: operators_,
46
+ children: jsx(Paperclip, props as never),
47
+ })
48
+ }
49
+
50
+ const openbao = { backend: 'openbao', mount: 'kv', path: 'test' }
51
+
52
+ describe('operator declarations', () => {
53
+ it('declares the cnpg operator via the Database recipe', () => {
54
+ const result = renderPaperclip({ host: 'paperclip.example.com' })
55
+ expect(result.operators.some((op) => op.name === 'cnpg')).toBe(true)
56
+ })
57
+
58
+ it('declares cnpg exactly once even with sandbox agents (no redis for paperclip)', () => {
59
+ const result = renderPaperclip({
60
+ host: 'paperclip.example.com',
61
+ agents: { sandboxReplicas: 3 },
62
+ })
63
+ const names = result.operators.map((op) => op.name)
64
+ expect(names.filter((n) => n === 'cnpg')).toHaveLength(1)
65
+ const kinds = result.resources.map((r) => r.kind)
66
+ expect(kinds).not.toContain('RedisCluster')
67
+ })
68
+
69
+ it('deduplicates operators provided via context', () => {
70
+ const result = render(
71
+ renderPaperclipWithContext([operators['cnpg']()], {
72
+ host: 'paperclip.example.com',
73
+ secretsName: 'existing-secrets',
74
+ })
75
+ )
76
+ const names = result.operators.map((op) => op.name)
77
+ expect(names.filter((n) => n === 'cnpg')).toHaveLength(1)
78
+ })
79
+
80
+ it('allows version overrides through context operators', () => {
81
+ const result = render(
82
+ renderPaperclipWithContext([operators['cnpg']('1.25.0')], {
83
+ host: 'paperclip.example.com',
84
+ secretsName: 'existing-secrets',
85
+ })
86
+ )
87
+ const cnpg = result.operators.filter((op) => op.name === 'cnpg')
88
+ expect(cnpg).toHaveLength(1)
89
+ expect(cnpg[0].version).toBe('1.25.0')
90
+ })
91
+ })
92
+
93
+ describe('rendering defaults', () => {
94
+ it('renders main deployment, service, database and endpoint', () => {
95
+ const result = renderPaperclip({ host: 'paperclip.example.com' })
96
+ const kinds = result.resources.map((r) => r.kind)
97
+ expect(kinds).toContain('Deployment')
98
+ expect(kinds).toContain('Service')
99
+ expect(kinds).toContain('Ingress')
100
+ expect(kinds).toContain('Cluster')
101
+ })
102
+
103
+ it('renders the main app with the paperclip image on port 3000', () => {
104
+ const result = renderPaperclip({ host: 'paperclip.example.com' })
105
+ const main = result.resources.find(
106
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip'
107
+ ) as any
108
+ expect(main).toBeDefined()
109
+ const container = main.spec.template.spec.containers[0]
110
+ expect(container.image).toBe('ghcr.io/berget-ai/paperclip:latest')
111
+ expect(container.ports[0].containerPort).toBe(3000)
112
+ expect(main.spec.replicas).toBe(2)
113
+ })
114
+
115
+ it('probes both workloads with TCP sockets (unknown HTTP health contract)', () => {
116
+ const result = renderPaperclip({
117
+ host: 'paperclip.example.com',
118
+ agents: { sandboxReplicas: 2 },
119
+ })
120
+ for (const name of ['paperclip', 'paperclip-agent-sandbox']) {
121
+ const deployment = result.resources.find(
122
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === name
123
+ ) as any
124
+ const container = deployment.spec.template.spec.containers[0]
125
+ expect(container.livenessProbe.tcpSocket).toEqual({ port: 3000 })
126
+ expect(container.readinessProbe.tcpSocket).toEqual({ port: 3000 })
127
+ expect(container.livenessProbe.httpGet).toBeUndefined()
128
+ expect(container.readinessProbe.httpGet).toBeUndefined()
129
+ }
130
+ })
131
+
132
+ it('renders no sandbox deployment when agents is not set', () => {
133
+ const result = renderPaperclip({ host: 'paperclip.example.com' })
134
+ const names = result.resources
135
+ .filter((r) => r.kind === 'Deployment')
136
+ .map((d: any) => d.metadata.name)
137
+ expect(names).toEqual(['paperclip'])
138
+ })
139
+
140
+ it('renders the sandbox agent deployment when agents is set', () => {
141
+ const result = renderPaperclip({
142
+ host: 'paperclip.example.com',
143
+ agents: { sandboxReplicas: 3 },
144
+ })
145
+ const sandbox = result.resources.find(
146
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip-agent-sandbox'
147
+ ) as any
148
+ expect(sandbox).toBeDefined()
149
+ expect(sandbox.spec.replicas).toBe(3)
150
+ expect(sandbox.spec.template.spec.containers[0].image).toBe(
151
+ 'ghcr.io/berget-ai/paperclip:latest'
152
+ )
153
+ expect(sandbox.spec.template.spec.containers[0].command).toEqual([
154
+ 'paperclip',
155
+ 'agent',
156
+ '--sandbox',
157
+ ])
158
+ })
159
+
160
+ it('hardens the sandbox with a locked-down securityContext', () => {
161
+ const result = renderPaperclip({
162
+ host: 'paperclip.example.com',
163
+ agents: {},
164
+ })
165
+ const sandbox = result.resources.find(
166
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip-agent-sandbox'
167
+ ) as any
168
+ const podSpec = sandbox.spec.template.spec
169
+ expect(podSpec.securityContext).toEqual({ seccompProfile: { type: 'RuntimeDefault' } })
170
+ const securityContext = podSpec.containers[0].securityContext
171
+ expect(securityContext.runAsNonRoot).toBe(true)
172
+ expect(securityContext.allowPrivilegeEscalation).toBe(false)
173
+ expect(securityContext.seccompProfile).toEqual({ type: 'RuntimeDefault' })
174
+ expect(securityContext.capabilities.drop).toEqual(['ALL'])
175
+ })
176
+
177
+ it('gives the sandbox explicit resources by default and honours overrides', () => {
178
+ const defaults = renderPaperclip({ host: 'paperclip.example.com', agents: {} })
179
+ const defaultSandbox = defaults.resources.find(
180
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip-agent-sandbox'
181
+ ) as any
182
+ const defaultContainer = defaultSandbox.spec.template.spec.containers[0]
183
+ expect(defaultContainer.resources.requests).toEqual({ cpu: '250m', memory: '256Mi' })
184
+ expect(defaultContainer.resources.limits).toEqual({ cpu: '1000m', memory: '2Gi' })
185
+
186
+ const result = renderPaperclip({
187
+ host: 'paperclip.example.com',
188
+ agents: {
189
+ sandboxReplicas: 1,
190
+ resources: {
191
+ requests: { cpu: '500m', memory: '1Gi' },
192
+ limits: { cpu: '2000m', memory: '4Gi' },
193
+ },
194
+ },
195
+ })
196
+ const sandbox = result.resources.find(
197
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip-agent-sandbox'
198
+ ) as any
199
+ const container = sandbox.spec.template.spec.containers[0]
200
+ expect(container.resources.requests).toEqual({ cpu: '500m', memory: '1Gi' })
201
+ expect(container.resources.limits).toEqual({ cpu: '2000m', memory: '4Gi' })
202
+ })
203
+
204
+ it('defaults sandbox replicas to 2', () => {
205
+ const result = renderPaperclip({ host: 'paperclip.example.com', agents: {} })
206
+ const sandbox = result.resources.find(
207
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip-agent-sandbox'
208
+ ) as any
209
+ expect(sandbox.spec.replicas).toBe(2)
210
+ })
211
+
212
+ it('renders gateway resources when platform uses gateway routing', () => {
213
+ const result = render(
214
+ jsx(RoutingContext.Provider, {
215
+ value: { mode: 'gateway', gatewayClassName: 'eg' },
216
+ children: jsx(SecretContext.Provider, {
217
+ value: openbao as never,
218
+ children: jsx(Paperclip, { host: 'paperclip.example.com' }),
219
+ }),
220
+ })
221
+ )
222
+ const kinds = result.resources.map((r) => r.kind)
223
+ expect(kinds).toContain('HTTPRoute')
224
+ })
225
+
226
+ it('renders a valid Ingress when platform uses ingress routing', () => {
227
+ const result = render(
228
+ jsx(RoutingContext.Provider, {
229
+ value: { mode: 'ingress' },
230
+ children: jsx(SecretContext.Provider, {
231
+ value: openbao as never,
232
+ children: jsx(Paperclip, { host: 'paperclip.example.com' }),
233
+ }),
234
+ })
235
+ )
236
+ const ingress = result.resources.find((r) => r.kind === 'Ingress') as any
237
+ expect(ingress).toBeDefined()
238
+ expect(ingress.spec.rules[0].host).toBe('paperclip.example.com')
239
+ })
240
+
241
+ it('passes resource validation', () => {
242
+ const result = renderPaperclip({
243
+ host: 'paperclip.example.com',
244
+ agents: { sandboxReplicas: 2 },
245
+ })
246
+ for (const resource of result.resources) {
247
+ expect(validateResource(resource)).toEqual([])
248
+ }
249
+ })
250
+ })
251
+
252
+ describe('namespace inheritance', () => {
253
+ it('inherits namespace from the Platform context when namespace prop is not set', () => {
254
+ const result = renderPaperclipInNamespace('agents', {
255
+ host: 'paperclip.example.com',
256
+ agents: { sandboxReplicas: 1 },
257
+ })
258
+ for (const kind of ['Deployment', 'Service', 'Cluster', 'Ingress']) {
259
+ const resource = result.resources.find((r: any) => r.kind === kind)
260
+ expect(resource).toBeDefined()
261
+ expect(resource.metadata.namespace).toBe('agents')
262
+ }
263
+ })
264
+
265
+ it('explicit namespace prop wins over the Platform context', () => {
266
+ const result = renderPaperclipInNamespace('agents', {
267
+ host: 'paperclip.example.com',
268
+ namespace: 'paperclip-ns',
269
+ })
270
+ const main = result.resources.find(
271
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip'
272
+ ) as any
273
+ expect(main.metadata.namespace).toBe('paperclip-ns')
274
+ })
275
+
276
+ it('falls back to default when no Platform namespace is present', () => {
277
+ const result = renderPaperclip({ host: 'paperclip.example.com' })
278
+ const main = result.resources.find(
279
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip'
280
+ ) as any
281
+ expect(main.metadata.namespace).toBe('default')
282
+ })
283
+ })
284
+
285
+ describe('rendering with all props', () => {
286
+ it('accepts the full prop surface', () => {
287
+ const result = renderPaperclip({
288
+ name: 'agents',
289
+ namespace: 'agents',
290
+ version: '0.4.0',
291
+ host: 'agents.example.com',
292
+ replicas: 3,
293
+ dbStorage: '20Gi',
294
+ websockets: true,
295
+ agents: { sandboxReplicas: 4 },
296
+ resources: {
297
+ requests: { memory: '1Gi', cpu: '500m' },
298
+ limits: { memory: '4Gi', cpu: '2000m' },
299
+ },
300
+ tls: { secretName: 'agents-tls', clusterIssuer: 'letsencrypt-prod' },
301
+ })
302
+ expect(result.resources.length).toBeGreaterThan(0)
303
+
304
+ const main = result.resources.find(
305
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'agents'
306
+ ) as any
307
+ const container = main.spec.template.spec.containers[0]
308
+ expect(container.image).toBe('ghcr.io/berget-ai/paperclip:0.4.0')
309
+ expect(main.spec.replicas).toBe(3)
310
+ expect(container.resources.limits.memory).toBe('4Gi')
311
+ const websockets = container.env.find((e: any) => e.name === 'WEBSOCKETS_ENABLED')
312
+ expect(websockets.value).toBe('true')
313
+
314
+ const sandbox = result.resources.find(
315
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'agents-agent-sandbox'
316
+ ) as any
317
+ expect(sandbox.spec.replicas).toBe(4)
318
+
319
+ const cluster = result.resources.find((r) => r.kind === 'Cluster') as any
320
+ expect(cluster.spec.storage.size).toBe('20Gi')
321
+ })
322
+
323
+ it('renders unique env var names (k8s rejects duplicates)', () => {
324
+ const result = renderPaperclip({
325
+ host: 'paperclip.example.com',
326
+ agents: { sandboxReplicas: 1 },
327
+ })
328
+ for (const name of ['paperclip', 'paperclip-agent-sandbox']) {
329
+ const deployment = result.resources.find(
330
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === name
331
+ ) as any
332
+ const env = deployment.spec.template.spec.containers[0].env as Array<{ name: string }>
333
+ const names = env.map((e) => e.name)
334
+ expect(new Set(names).size).toBe(names.length)
335
+ }
336
+ })
337
+ })
338
+
339
+ describe('secrets handling', () => {
340
+ it('provisions the model API key through a secrets backend', () => {
341
+ const result = renderPaperclip({ host: 'paperclip.example.com' })
342
+ const bundle = result.resources.find(
343
+ (r: any) => r.kind === 'OpenBaoStaticSecret' && r.metadata.name === 'paperclip-secrets'
344
+ ) as any
345
+ expect(bundle).toBeDefined()
346
+ expect(bundle.spec.path).toBe('test/paperclip/secrets')
347
+ expect(bundle.spec.destination).toEqual({
348
+ create: true,
349
+ name: 'paperclip-secrets',
350
+ })
351
+ })
352
+
353
+ it('provisions the model API key through Vault', () => {
354
+ const result = render(
355
+ jsx(SecretContext.Provider, {
356
+ value: { backend: 'vault', mount: 'kv', path: 'apps' },
357
+ children: jsx(Paperclip, { host: 'paperclip.example.com' }),
358
+ })
359
+ )
360
+ const kinds = result.resources.map((r) => r.kind)
361
+ expect(kinds).toContain('VaultStaticSecret')
362
+ })
363
+
364
+ it('accepts an explicit secretsName without a backend', () => {
365
+ const result = render(
366
+ jsx(Paperclip, { host: 'paperclip.example.com', secretsName: 'existing-secrets' })
367
+ )
368
+ const kinds = result.resources.map((r) => r.kind)
369
+ expect(kinds).not.toContain('OpenBaoStaticSecret')
370
+ expect(kinds).not.toContain('VaultStaticSecret')
371
+ expect(result.resources.length).toBeGreaterThan(0)
372
+ })
373
+
374
+ it('wires credentials via secretKeyRef (never plaintext env)', () => {
375
+ const result = render(
376
+ jsx(Paperclip, { host: 'paperclip.example.com', secretsName: 'existing-secrets' })
377
+ )
378
+ const main = result.resources.find(
379
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip'
380
+ ) as any
381
+ const env = main.spec.template.spec.containers[0].env
382
+ const modelApiKey = env.find((e: any) => e.name === 'MODEL_API_KEY')
383
+ const dbPassword = env.find((e: any) => e.name === 'PGPASSWORD')
384
+ expect(modelApiKey.valueFrom.secretKeyRef).toEqual({
385
+ name: 'existing-secrets',
386
+ key: 'modelApiKey',
387
+ })
388
+ expect(dbPassword.valueFrom.secretKeyRef).toEqual({
389
+ name: 'paperclip-db-credentials',
390
+ key: 'password',
391
+ })
392
+ expect(modelApiKey.value).toBeUndefined()
393
+ expect(dbPassword.value).toBeUndefined()
394
+ const databaseUrl = env.find((e: any) => e.name === 'DATABASE_URL')
395
+ expect(databaseUrl.value).toContain('$(PGPASSWORD)')
396
+ })
397
+
398
+ it('sandbox workers share the same secrets bundle', () => {
399
+ const result = render(
400
+ jsx(SecretContext.Provider, {
401
+ value: openbao as never,
402
+ children: jsx(Paperclip, {
403
+ host: 'paperclip.example.com',
404
+ agents: { sandboxReplicas: 2 },
405
+ }),
406
+ })
407
+ )
408
+ const sandbox = result.resources.find(
409
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'paperclip-agent-sandbox'
410
+ ) as any
411
+ const env = sandbox.spec.template.spec.containers[0].env
412
+ const modelApiKey = env.find((e: any) => e.name === 'MODEL_API_KEY')
413
+ const dbPassword = env.find((e: any) => e.name === 'PGPASSWORD')
414
+ expect(modelApiKey.valueFrom.secretKeyRef.name).toBe('paperclip-secrets')
415
+ expect(modelApiKey.valueFrom.secretKeyRef.key).toBe('modelApiKey')
416
+ expect(dbPassword.valueFrom.secretKeyRef.name).toBe('paperclip-db-credentials')
417
+ expect(modelApiKey.value).toBeUndefined()
418
+ })
419
+
420
+ it('renders no plaintext credentials anywhere', () => {
421
+ const result = renderPaperclip({
422
+ host: 'paperclip.example.com',
423
+ agents: { sandboxReplicas: 2 },
424
+ })
425
+ const { passed, errors } = runGuardrails(result.resources as any[], [noPlaintextSecrets])
426
+ if (!passed) {
427
+ console.error('Plaintext credential violations:', errors)
428
+ }
429
+ expect(passed).toBe(true)
430
+ })
431
+ })
432
+
433
+ describe('validation errors', () => {
434
+ it('throws when no secrets backend and no model key secret', () => {
435
+ expect(() => render(jsx(Paperclip, { host: 'paperclip.example.com' }))).toThrow(
436
+ /model api key/i
437
+ )
438
+ })
439
+
440
+ it('throws for unknown secrets backends', () => {
441
+ expect(() =>
442
+ render(
443
+ jsx(SecretContext.Provider, {
444
+ value: { backend: 'unknown' as never },
445
+ children: jsx(Paperclip, { host: 'paperclip.example.com' }),
446
+ })
447
+ )
448
+ ).toThrow(/model api key/i)
449
+ })
450
+ })
@@ -0,0 +1,8 @@
1
+ import { Platform } from '@r8s/recipes'
2
+ import { Paperclip } from '@r8s/paperclip'
3
+
4
+ export default (
5
+ <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
6
+ <Paperclip name="paperclip" host="paperclip.example.com" agents={{ sandboxReplicas: 3 }} />
7
+ </Platform>
8
+ )
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@r8s/paperclip",
3
+ "version": "0.2.0",
4
+ "description": "Paperclip — Berget's agent platform (tasks, documents, agent orchestration)",
5
+ "license": "MIT",
6
+ "author": "Berget AI AB",
7
+ "homepage": "https://r8s.berget.ai",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/berget-ai/r8s.git",
11
+ "directory": "packages/paperclip"
12
+ },
13
+ "bugs": "https://github.com/berget-ai/r8s/issues",
14
+ "keywords": [
15
+ "paperclip",
16
+ "agents",
17
+ "documents",
18
+ "automation"
19
+ ],
20
+ "r8s": {
21
+ "category": "Agent Platforms"
22
+ },
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "scripts": {
26
+ "build": "tsc --build",
27
+ "test": "vitest run",
28
+ "clean": "rm -rf dist tsconfig.tsbuildinfo"
29
+ },
30
+ "category": "Collaboration & Productivity",
31
+ "dependencies": {
32
+ "@r8s/core": "^0.2.0",
33
+ "@r8s/crds": "^0.2.0",
34
+ "@r8s/k8s-types": "^0.2.0",
35
+ "@r8s/recipes": "^0.2.0"
36
+ },
37
+ "devDependencies": {
38
+ "typescript": "^5.3.0",
39
+ "vitest": "^1.0.0"
40
+ }
41
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,278 @@
1
+ import { jsx, Fragment, useContext } from '@r8s/core'
2
+ import { Namespace, SecretContext } from '@r8s/core/defaults'
3
+ import { Database, WebService, Endpoint } from '@r8s/recipes'
4
+ import type { SecretRef } from '@r8s/recipes'
5
+
6
+ export interface PaperclipProps {
7
+ /** Resource name (defaults to 'paperclip') */
8
+ name?: string
9
+ /** Kubernetes namespace (defaults to 'default') */
10
+ namespace?: string
11
+ /** Container image tag (defaults to 'latest' — pin a version in production) */
12
+ version?: string
13
+ /** Public hostname for the web app and API (required) */
14
+ host: string
15
+ /** Number of app replicas (defaults to 2) */
16
+ replicas?: number
17
+ /** Storage size for the Postgres cluster (defaults to '10Gi') */
18
+ dbStorage?: string
19
+ /** Enable websockets for live task and agent updates (defaults to false) */
20
+ websockets?: boolean
21
+ /**
22
+ * Sandbox agent workers. Workers run the same image with a command
23
+ * override (paperclip agent --sandbox) and share the model API key
24
+ * and database credentials via secretKeyRef. They run with a hardened
25
+ * securityContext (non-root, no privilege escalation, RuntimeDefault
26
+ * seccomp, all capabilities dropped) by default.
27
+ */
28
+ agents?: {
29
+ /** Number of sandbox agent replicas (defaults to 2) */
30
+ sandboxReplicas?: number
31
+ /** Sandbox container resources (defaults to requests 256Mi/250m, limits 2Gi/1000m) */
32
+ resources?: {
33
+ requests?: { cpu?: string; memory?: string }
34
+ limits?: { cpu?: string; memory?: string }
35
+ }
36
+ }
37
+ /**
38
+ * Name of an existing Secret containing key `modelApiKey`. Paperclip
39
+ * uses this key to call LLM providers on behalf of agents.
40
+ *
41
+ * Required unless a secrets backend (openbao/vault) is configured on
42
+ * the surrounding Platform — the backend then provisions the key
43
+ * automatically. Plaintext keys are not supported.
44
+ */
45
+ secretsName?: string
46
+ /** Requested app resources */
47
+ resources?: {
48
+ requests?: { cpu?: string; memory?: string }
49
+ limits?: { cpu?: string; memory?: string }
50
+ }
51
+ /** TLS configuration (defaults to letsencrypt-prod cluster issuer) */
52
+ tls?: {
53
+ secretName: string
54
+ clusterIssuer: string
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Paperclip — Berget's agent platform (tasks, documents, agent orchestration).
60
+ *
61
+ * @title Paperclip
62
+ * @category Agent Platforms
63
+ *
64
+ * Composes:
65
+ * - CNPG Postgres cluster (tasks, documents, agent state; size via
66
+ * `dbStorage`)
67
+ * - Paperclip Deployment + Service + Endpoint
68
+ * - Optional sandbox agent workers (same image, command override) sharing
69
+ * the model API key via secretKeyRef — hardened with a non-root,
70
+ * no-privilege-escalation securityContext, RuntimeDefault seccomp and
71
+ * all capabilities dropped, with explicit resources by default
72
+ * - Model API key provisioned through the Platform secrets backend
73
+ * (openbao / vault), or referenced from an existing Secret
74
+ * - Optional websockets support (WEBSOCKETS_ENABLED)
75
+ *
76
+ * Probing: the Paperclip image contract does not publish HTTP health
77
+ * endpoints, so both workloads default to TCP socket probes on the
78
+ * container port (3000) to avoid crash-looping on unknown /health /ready
79
+ * paths. If your image serves HTTP /health and /ready — or another
80
+ * readiness endpoint — override `probes` on the workload for stricter
81
+ * checks.
82
+ *
83
+ * The namespace is inherited from the surrounding `<Platform>` (via the
84
+ * Namespace context) unless set explicitly.
85
+ *
86
+ * Wrap the component in `<Platform secrets={{ backend: 'openbao' }}>` and
87
+ * the MODEL_API_KEY is provisioned for you. Without a backend you must
88
+ * point `secretsName` at a pre-created Secret.
89
+ *
90
+ * @example
91
+ * import { Platform } from '@r8s/recipes'
92
+ * import { Paperclip } from '@r8s/paperclip'
93
+ *
94
+ * export default (
95
+ * <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
96
+ * <Paperclip
97
+ * name="paperclip"
98
+ * host="paperclip.example.com"
99
+ * agents={{ sandboxReplicas: 3 }}
100
+ * />
101
+ * </Platform>
102
+ * )
103
+ *
104
+ * @example
105
+ * // Existing Secret holding the model API key (key: modelApiKey)
106
+ * import { Paperclip } from '@r8s/paperclip'
107
+ *
108
+ * export default (
109
+ * <Paperclip name="paperclip" host="paperclip.example.com" secretsName="paperclip-secrets" />
110
+ * )
111
+ */
112
+ export function Paperclip(props: PaperclipProps) {
113
+ const {
114
+ name = 'paperclip',
115
+ namespace: namespaceProp,
116
+ version = 'latest',
117
+ host,
118
+ replicas = 2,
119
+ dbStorage = '10Gi',
120
+ websockets = false,
121
+ agents,
122
+ secretsName,
123
+ resources = {
124
+ requests: { memory: '512Mi', cpu: '250m' },
125
+ limits: { memory: '2Gi', cpu: '1000m' },
126
+ },
127
+ tls = { secretName: `${name}-tls`, clusterIssuer: 'letsencrypt-prod' },
128
+ } = props
129
+
130
+ // Inherit namespace from <Platform> context if not explicitly set
131
+ const contextNamespace = useContext(Namespace)
132
+ const namespace =
133
+ namespaceProp ?? (contextNamespace !== 'default' ? contextNamespace : undefined) ?? 'default'
134
+
135
+ const secretProvider = useContext(SecretContext)
136
+ const resources_: ReturnType<typeof jsx>[] = []
137
+
138
+ const image = `ghcr.io/berget-ai/paperclip:${version}`
139
+ const appSecretsName = secretsName ?? `${name}-secrets`
140
+ const sandboxReplicas = agents?.sandboxReplicas ?? 2
141
+ // Sandbox workers run untrusted code — never let them float without
142
+ // explicit container resources.
143
+ const sandboxResources = agents?.resources ?? {
144
+ requests: { cpu: '250m', memory: '256Mi' },
145
+ limits: { cpu: '1000m', memory: '2Gi' },
146
+ }
147
+ // The internal image's probe contract is unknown — probe the TCP port
148
+ // so pods don't crash-loop on missing HTTP /health /ready endpoints.
149
+ const tcpProbes = {
150
+ liveness: { tcp: true } as const,
151
+ readiness: { tcp: true } as const,
152
+ }
153
+
154
+ // --- Secret provisioning -------------------------------------------------
155
+ // The model API key lets Paperclip call LLM providers — never render
156
+ // it as plaintext. With a secrets backend it is provisioned through the
157
+ // backend (key `modelApiKey`); otherwise reference a pre-created Secret.
158
+ if (!secretsName) {
159
+ if (
160
+ !secretProvider ||
161
+ (secretProvider.backend !== 'vault' && secretProvider.backend !== 'openbao')
162
+ ) {
163
+ throw new Error(
164
+ `Paperclip "${name}" requires a model API key (MODEL_API_KEY).\n` +
165
+ `\n` +
166
+ `Paperclip calls LLM providers with a model API key — ` +
167
+ `it must not be rendered as plaintext.\n` +
168
+ `\n` +
169
+ `Fix: configure a secrets backend on the Platform:\n` +
170
+ ` <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>\n` +
171
+ ` <Paperclip name="${name}" host="${host}" />\n` +
172
+ ` </Platform>\n` +
173
+ `\n` +
174
+ `Or reference a pre-created Secret (key: modelApiKey):\n` +
175
+ ` <Paperclip name="${name}" host="${host}" secretsName="${name}-secrets" />`
176
+ )
177
+ }
178
+
179
+ const spec = {
180
+ ...(secretProvider.backend === 'vault'
181
+ ? { vaultAuthRef: secretProvider.authRef }
182
+ : { openbaoAuthRef: secretProvider.authRef }),
183
+ mount: secretProvider.mount,
184
+ type: 'kv-v2' as const,
185
+ path: `${secretProvider.path ?? name}/${name}/secrets`,
186
+ destination: { create: true, name: appSecretsName },
187
+ }
188
+ resources_.push(
189
+ secretProvider.backend === 'vault'
190
+ ? jsx('VaultStaticSecret', {
191
+ apiVersion: 'secrets.hashicorp.com/v1beta1',
192
+ kind: 'VaultStaticSecret',
193
+ metadata: { name: `${name}-secrets`, namespace },
194
+ spec,
195
+ })
196
+ : jsx('OpenBaoStaticSecret', {
197
+ apiVersion: 'secrets.openbao.org/v1beta1',
198
+ kind: 'OpenBaoStaticSecret',
199
+ metadata: { name: `${name}-secrets`, namespace },
200
+ spec,
201
+ })
202
+ )
203
+ }
204
+
205
+ // --- Env wiring ------------------------------------------------------------
206
+ // Every credential is referenced via secretKeyRef (declared before plain
207
+ // env vars by WebService, so dependent $(VAR) expansion resolves).
208
+ // DATABASE_URL is auto-wired from DatabaseContext inside the wrapper.
209
+ const appEnv: Record<string, string> = {
210
+ PORT: '3000',
211
+ ...(websockets ? { WEBSOCKETS_ENABLED: 'true' } : {}),
212
+ }
213
+
214
+ const appSecrets: Record<string, SecretRef> = {
215
+ MODEL_API_KEY: { secret: appSecretsName, key: 'modelApiKey' },
216
+ }
217
+
218
+ // --- Database (CNPG) + app + sandbox agents --------------------------------
219
+ // Database wraps both the main app and the sandbox workers so database
220
+ // credentials stay consistent with the r8s Database recipe (CNPG dedicated
221
+ // cluster provisions the secret).
222
+ resources_.push(
223
+ jsx(Database, {
224
+ name,
225
+ namespace,
226
+ storage: dbStorage,
227
+ children: (
228
+ <>
229
+ <WebService
230
+ name={name}
231
+ namespace={namespace}
232
+ image={image}
233
+ port={3000}
234
+ replicas={replicas}
235
+ probes={tcpProbes}
236
+ resources={resources}
237
+ env={appEnv}
238
+ secrets={appSecrets}
239
+ />
240
+ {agents ? (
241
+ <WebService
242
+ name={`${name}-agent-sandbox`}
243
+ namespace={namespace}
244
+ image={image}
245
+ port={3000}
246
+ replicas={sandboxReplicas}
247
+ command={['paperclip', 'agent', '--sandbox']}
248
+ probes={tcpProbes}
249
+ secrets={appSecrets}
250
+ securityContext={{
251
+ runAsNonRoot: true,
252
+ allowPrivilegeEscalation: false,
253
+ seccompProfile: { type: 'RuntimeDefault' },
254
+ capabilities: { drop: ['ALL'] },
255
+ }}
256
+ podSecurityContext={{ seccompProfile: { type: 'RuntimeDefault' } }}
257
+ resources={sandboxResources}
258
+ />
259
+ ) : null}
260
+ </>
261
+ ),
262
+ })
263
+ )
264
+
265
+ // --- Endpoint ---------------------------------------------------------------
266
+ resources_.push(
267
+ <Endpoint
268
+ name={`${name}-endpoint`}
269
+ namespace={namespace}
270
+ host={host}
271
+ serviceName={name}
272
+ servicePort={3000}
273
+ tls={tls}
274
+ />
275
+ )
276
+
277
+ return jsx(Fragment, { children: resources_ })
278
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "composite": true
7
+ },
8
+ "include": ["src/**/*"],
9
+ "references": [
10
+ { "path": "../core" },
11
+ { "path": "../k8s-types" },
12
+ { "path": "../crds" },
13
+ { "path": "../recipes" }
14
+ ]
15
+ }