Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ name: Public CI

on:
push:
branches: [main]
branches: [main, "fix/additive-instruction-snapshot"]
pull_request:
branches: [main]

permissions:
contents: read

concurrency:
group: public-ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Expand All @@ -32,10 +35,42 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile

- name: Build SDK
- name: Build SDK (including declarations)
id: sdk
continue-on-error: true
run: bun run build:sdk

- name: Additive instruction regression tests
run: |
FREEBUFF_MODE=true bun run cli/scripts/prebuild-agents.ts
bun test sdk/src/__tests__/append-instructions.test.ts cli/src/utils/append-instructions.test.ts
bun test cli/src/utils/append-instructions-admission.test.ts

- name: Stock-source negative control for SDK composition
run: |
git fetch --depth=1 origin 80c2f596b5af1a331fb2904f392a8d77485eb4f2
cp sdk/src/run.ts /tmp/patched-run.ts
git show 80c2f596b5af1a331fb2904f392a8d77485eb4f2:sdk/src/run.ts > sdk/src/run.ts
set +e
FREEBUFF_MODE=true bun run cli/scripts/prebuild-agents.ts
bun test sdk/src/__tests__/append-instructions.test.ts > /tmp/negative-control.log 2>&1
result=$?
set -e
cp /tmp/patched-run.ts sdk/src/run.ts
cat /tmp/negative-control.log
test "$result" -ne 0
grep -q 'HOST_RULE_CANARY' /tmp/negative-control.log

- name: Build Freebuff binary
env:
NEXT_PUBLIC_CB_ENVIRONMENT: prod
NEXT_PUBLIC_CODEBUFF_APP_URL: https://codebuff.com
NEXT_PUBLIC_SUPPORT_EMAIL: support@freebuff.com
NEXT_PUBLIC_POSTHOG_API_KEY: phc_public_placeholder
NEXT_PUBLIC_POSTHOG_HOST_URL: https://us.i.posthog.com
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: pk_test_placeholder
NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL: https://billing.stripe.com/p/login/test
NEXT_PUBLIC_WEB_PORT: '3000'
run: bun freebuff/cli/build.ts 0.0.0-ci

- name: Smoke test Freebuff binary
Expand All @@ -52,3 +87,17 @@ jobs:
chmod +x cli/bin/freebuff
cli/bin/freebuff --version
bun cli/scripts/smoke-binary.ts cli/bin/freebuff

- name: Upload exact-head Linux prerelease (not a release)
if: always()
uses: actions/upload-artifact@v4
with:
name: freebuff-linux-${{ github.sha }}
path: |
cli/bin/freebuff
cli/bin/tree-sitter.wasm
retention-days: 7

- name: Preserve declaration-build failure
if: steps.sdk.outcome == 'failure'
run: exit 1
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"jimp": "^1.6.0",
"node-machine-id": "^1.1.12",
"open": "^10.1.0",
"picocolors": "1.1.1",
"pino": "9.4.0",
"react": "^19.0.0",
"remark-breaks": "^4.0.0",
Expand Down
5 changes: 3 additions & 2 deletions cli/scripts/build-binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,10 @@ async function main() {
env: process.env,
})

// Ensure SDK assets exist before compiling the CLI
// The binary consumes runtime SDK assets, not TypeScript declarations.
// Declaration generation remains a separate SDK CI check.
log('Building SDK dependencies...')
runCommand('bun', ['run', '--cwd', '../sdk', 'build'], {
runCommand('bun', ['run', '--cwd', '../sdk', 'build', '--no-types'], {
cwd: cliRoot,
env: process.env,
})
Expand Down
5 changes: 4 additions & 1 deletion cli/src/hooks/use-send-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
requestFreebuffChatAdmission,
} from '../state/freebuff-chat-store'
import { capturePendingAttachments } from '../utils/pending-attachments'
import { AppendInstructionsError } from '../utils/append-instructions'
import { randomUUID } from 'node:crypto'

import { useCallback, useEffect, useRef } from 'react'
Expand Down Expand Up @@ -565,7 +566,9 @@ export const useSendMessage = ({
setMessages((prev) => [
...prev,
createErrorChatMessage(
shouldUseByok
error instanceof AppendInstructionsError
? error.message
: shouldUseByok
? byokLoadFailureMessage(error)
: '⚠️ Unable to create the client. Please check your authentication and try again.',
),
Expand Down
23 changes: 23 additions & 0 deletions cli/src/utils/append-instructions-admission.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { expect, test } from 'bun:test'
import { getCodebuffClient, resetCodebuffClient } from './codebuff-client'
import { AppendInstructionsError } from './append-instructions'

test('invalid host input fails real CLI client admission before auth/provider and remains fail closed after reset', async () => {
const original = process.env.FREEBUFF_APPEND_INSTRUCTIONS_FILE
const originalFetch = globalThis.fetch
let requests = 0
globalThis.fetch = (async () => { requests++; throw new Error('provider must not be contacted') }) as typeof fetch
try {
process.env.FREEBUFF_APPEND_INSTRUCTIONS_FILE = '/missing-private-instruction-canary'
for (let attempt = 0; attempt < 2; attempt++) {
resetCodebuffClient()
await expect(getCodebuffClient()).rejects.toBeInstanceOf(AppendInstructionsError)
delete process.env.FREEBUFF_APPEND_INSTRUCTIONS_FILE
}
expect(requests).toBe(0)
} finally {
globalThis.fetch = originalFetch
if (original === undefined) delete process.env.FREEBUFF_APPEND_INSTRUCTIONS_FILE
else process.env.FREEBUFF_APPEND_INSTRUCTIONS_FILE = original
}
})
63 changes: 63 additions & 0 deletions cli/src/utils/append-instructions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { expect, test } from 'bun:test'
import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { AppendInstructionsError, createAppendInstructionsSnapshot, loadAppendInstructions } from './append-instructions'

test('unset is a no-op; process snapshot caches success and failure across client resets', async () => {
expect(await loadAppendInstructions()).toBeUndefined()
let reads = 0
const snapshot = createAppendInstructionsSnapshot(async () => { reads++; return 'first' })
expect(await snapshot('/first')).toBe('first')
expect(await snapshot('/changed')).toBe('first')
expect(reads).toBe(1)
const failed = createAppendInstructionsSnapshot(async () => { reads++; throw new AppendInstructionsError() })
await expect(failed('/bad')).rejects.toBeInstanceOf(AppendInstructionsError)
await expect(failed('/fixed')).rejects.toBeInstanceOf(AppendInstructionsError)
expect(reads).toBe(2)
})

test('bounded regular UTF-8 input preserves Unicode; all failures redact path/content', async () => {
const dir = await mkdtemp(join(tmpdir(), 'freebuff-private-path-'))
try {
const file = join(dir, 'private-canary')
for (const content of [' 你好\n', 'a'.repeat(65536), 'é'.repeat(32768)]) {
await writeFile(file, content)
expect(await loadAppendInstructions(file)).toBe(content)
}
for (const content of ['', ' ', 'a\0b', Buffer.from([255]), 'a'.repeat(65537), 'é'.repeat(32769)]) {
await writeFile(file, content)
try { await loadAppendInstructions(file); throw new Error('accepted invalid input') } catch (error) {
expect(error).toBeInstanceOf(AppendInstructionsError)
expect(String(error)).not.toContain(dir)
expect(String(error)).not.toContain('private-canary')
expect((error as Error).cause).toBeUndefined()
}
}
await writeFile(file, 'valid')
const link = join(dir, 'symlink'); await symlink(file, link)
for (const path of [link, dir, join(dir, 'missing'), 'relative', '']) {
await expect(loadAppendInstructions(path)).rejects.toBeInstanceOf(AppendInstructionsError)
}
} finally { await rm(dir, { recursive: true, force: true }) }
})


test('growth after stat remains bounded and decoder/read failures close the descriptor', async () => {
for (const scenario of ['growth', 'decode', 'read'] as const) {
let closed = 0
const fakeOpen = (async () => ({
stat: async () => ({ isFile: () => true, size: 1 }),
read: async (buffer: Buffer, offset: number, length: number) => {
expect(length).toBeLessThanOrEqual(65537)
if (scenario === 'read') throw new Error('private path/content must disappear')
if (offset) return { bytesRead: 0 }
buffer.fill(scenario === 'decode' ? 255 : 65)
return { bytesRead: scenario === 'growth' ? 65537 : 1 }
},
close: async () => { closed++ },
})) as unknown as Parameters<typeof loadAppendInstructions>[1]
await expect(loadAppendInstructions('/private-canary', fakeOpen)).rejects.toBeInstanceOf(AppendInstructionsError)
expect(closed).toBe(1)
}
})
47 changes: 47 additions & 0 deletions cli/src/utils/append-instructions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { constants } from 'node:fs'
import { open } from 'node:fs/promises'
import { isAbsolute } from 'node:path'
import { AppendInstructionsError, MAX_APPEND_INSTRUCTIONS_BYTES, validateAppendInstructions } from '@codebuff/common/append-instructions'

export { AppendInstructionsError }

/** POSIX file input. Never retain filesystem errors, paths or contents in errors. */
export async function loadAppendInstructions(path?: string, openFile = open): Promise<string | undefined> {
if (path === undefined) return undefined
try {
if (!['linux', 'darwin'].includes(process.platform) ||
!isAbsolute(path) || /[\0\r\n]/.test(path) ||
!constants.O_NOFOLLOW || !constants.O_NONBLOCK) {
throw new AppendInstructionsError()
}
const file = await openFile(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
try {
const stat = await file.stat()
if (!stat.isFile() || stat.size === 0 || stat.size > MAX_APPEND_INSTRUCTIONS_BYTES) {
throw new AppendInstructionsError()
}
const bytes = Buffer.alloc(MAX_APPEND_INSTRUCTIONS_BYTES + 1)
let length = 0
while (length < bytes.length) {
const { bytesRead } = await file.read(bytes, length, bytes.length - length, null)
if (!bytesRead) break
length += bytesRead
}
if (length > MAX_APPEND_INSTRUCTIONS_BYTES) throw new AppendInstructionsError()
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, length))
validateAppendInstructions(text)
return text
} finally {
await file.close()
}
} catch {
throw new AppendInstructionsError()
}
}

/** Cache success AND failure for process lifetime, independent of auth/client resets. */
export function createAppendInstructionsSnapshot(loader = loadAppendInstructions) {
let snapshot: Promise<string | undefined> | undefined
return (path?: string) => snapshot ??= loader(path)
}
export const getProcessAppendInstructions = createAppendInstructionsSnapshot()
6 changes: 6 additions & 0 deletions cli/src/utils/codebuff-client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getProcessAppendInstructions } from './append-instructions'
import { API_KEY_ENV_VAR } from '@codebuff/common/old-constants'
import { AskUserBridge } from '@codebuff/common/utils/ask-user-bridge'
import { CodebuffClient } from '@codebuff/sdk'
Expand Down Expand Up @@ -53,6 +54,10 @@ export function resetCodebuffClient(): void {
export async function getCodebuffClient(
options: { byok?: ResolvedByokConnection } = {},
): Promise<CodebuffClient | null> {
// Validate before auth/client admission, even when a client is already cached.
const appendInstructions = await getProcessAppendInstructions(
getSystemProcessEnv().FREEBUFF_APPEND_INSTRUCTIONS_FILE,
)
const byokIdentity = options.byok
? `${options.byok.id}:${options.byok.revision}`
: null
Expand Down Expand Up @@ -96,6 +101,7 @@ export async function getCodebuffClient(
}
: {}),
cwd: projectRoot,
...(appendInstructions !== undefined ? { appendInstructions } : {}),
// Keeps the model's skill list identical to the one the registry shows
// (utils/skill-registry.ts). The SDK default is project-only so that a
// server embedding it cannot read a home directory by omission; the
Expand Down
20 changes: 20 additions & 0 deletions common/src/append-instructions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/** Creation-time host context; retained in ordinary native conversation history. */
export const APPEND_INSTRUCTIONS_KEY = 'host://append-instructions'
export const MAX_APPEND_INSTRUCTIONS_BYTES = 64 * 1024

export class AppendInstructionsError extends Error {
constructor(message = 'Invalid append instructions configuration; use a readable UTF-8 regular file of 1–65536 bytes.') {
super(message)
this.name = 'AppendInstructionsError'
}
}

export function validateAppendInstructions(value: unknown): asserts value is string {
if (typeof value !== 'string' || value.length > MAX_APPEND_INSTRUCTIONS_BYTES ||
!value.trim() || value.includes('\0') ||
new TextEncoder().encode(value).length > MAX_APPEND_INSTRUCTIONS_BYTES ||
// Reject unpaired UTF-16 surrogates rather than silently replacing input.
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(value)) {
throw new AppendInstructionsError()
}
}
33 changes: 33 additions & 0 deletions docs/append-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Additive creation instructions

Hosts can set `FREEBUFF_APPEND_INSTRUCTIONS_FILE` to an absolute UTF-8 regular
file path on Linux or macOS. The CLI validates it before first client admission,
not at startup. A missing, unreadable, symlink, special, empty, invalid UTF-8,
NUL-containing or oversized file fails admission with a sanitized instruction
configuration error. The byte limit is 65536; input is not truncated. Windows
file input is unsupported and fails closed when configured. Unset is a no-op.

The process reads the file once, including caching failure. File/environment
changes and login/client resets do not refresh it: restart the process for a new
snapshot. SDK hosts can pass `appendInstructions: string` to `CodebuffClient`
or `run`. The same runtime content validation applies.

A fresh conversation adds this snapshot alongside project and home knowledge
under reserved key `host://append-instructions`. Native prompts, model selection,
tools and permissions remain intact. Default bundled Freebuff knowledge-aware
roots consume it; custom definitions omitting the knowledge placeholder do not.
This is not a universal custom-agent system-prompt override.

Later turns and native saved history keep the creation snapshot. Omitting the
option preserves it; identical explicit input is accepted without duplication.
Different input or adding it to preexisting unmarked history is rejected before
inference: start a new conversation. Compaction does not refresh instructions.
The text remains in ordinary local saved history and optional developer traces,
and is sent to the selected model. This is private host context, not a secret
store or memory-only API. Do not put credentials in it. Runtime failure reporting
omits prompt and message-history payloads when host instructions are configured;
this does not promise that model responses never echo instructions.

This public source change is not evidence that npm Freebuff 0.2.26 supports the
input. Qualification of a CI-built prerelease must identify its source and binary
hash independently of the private release source.
7 changes: 5 additions & 2 deletions packages/agent-runtime/src/run-agent-step.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { APPEND_INSTRUCTIONS_KEY } from '@codebuff/common/append-instructions'
import { contextPrunerBudgetForModel } from '@codebuff/common/constants/model-config'
import { compactionThreshold } from '@codebuff/common/constants/compaction-policy'
import {
Expand Down Expand Up @@ -1890,8 +1891,10 @@ export async function loopAgentSteps(
totalSteps,
directCreditsUsed: currentAgentState.directCreditsUsed,
creditsUsed: currentAgentState.creditsUsed,
messageHistory: currentAgentState.messageHistory,
systemPrompt: system,
messageHistory: fileContext.userKnowledgeFiles?.[APPEND_INSTRUCTIONS_KEY]
? '[omitted: host instructions configured]' : currentAgentState.messageHistory,
systemPrompt: fileContext.userKnowledgeFiles?.[APPEND_INSTRUCTIONS_KEY]
? '[omitted: host instructions configured]' : system,
},
'Agent execution failed',
)
Expand Down
Loading
Loading