Skip to content
Merged
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
10 changes: 5 additions & 5 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
go-version: '1.25'

- name: Cache Go modules
uses: actions/cache@v4
Expand All @@ -41,7 +41,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
go-version: '1.25'

- name: Cache Go modules
uses: actions/cache@v4
Expand All @@ -66,17 +66,17 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
go-version: '1.25'

- name: golangci-lint (clients/go)
uses: golangci/golangci-lint-action@v6
uses: golangci/golangci-lint-action@v9
with:
version: latest
working-directory: clients/go
args: --timeout=5m

- name: golangci-lint (gateway)
uses: golangci/golangci-lint-action@v6
uses: golangci/golangci-lint-action@v9
with:
version: latest
working-directory: gateway
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ pnpm-debug.log*
dist/
build/
.next/
.next-build/
.next-playwright-*/
out/
.turbo/
.vercel/
Expand Down
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Changelog

All notable changes to this project are documented in this file.

## [Unreleased]

### Added

- Add authenticated Streamable HTTP MCP with scoped read and write tools.
- Add OAuth 2.1 with PKCE, dynamic client registration, and browser OIDC.
- Add personal API tokens with scopes, expiry, one-time display, and revocation.
- Add OpenAPI JSON and YAML documents, `llms.txt`, and client authentication guidance.
- Add batch append and blob reads, bounded trace pages, exact turn hydration, and faster typed projection.
- Add responsive token management and debugger views for mobile devices.

### Changed

- Enforce write scopes and stricter proxy-header handling.
- Correct separate tool-result hydration and named-key MessagePack projection. Numeric field tags continue to have priority.

### Fixed

- Pin pnpm 9 in Node 20 container builds.

### Compatibility

- Gateway deployments must now provide a `SESSION_SECRET` of at least 32 bytes.
- Non-GET API requests now require authentication with the `cxdb:write` scope.
- Scoped credentials used with context, metrics, and event reads must include `cxdb:read`. Existing browser sessions and built-in service credentials receive both scopes.
- There are no known breaking stored-data changes.
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ FROM node:20-alpine AS frontend
WORKDIR /app

# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate

# Copy package files
COPY frontend/package.json frontend/pnpm-lock.yaml* ./
Expand All @@ -28,7 +28,7 @@ RUN pnpm build
# ============================================
# Stage 2: Build Rust binary
# ============================================
FROM rust:1.92-bookworm AS backend
FROM rust:1.94.1-bookworm AS backend

WORKDIR /app

Expand Down
12 changes: 6 additions & 6 deletions clients/go/cmd/cxdb-blob-verify/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func main() {
// --- Test 1: PutBlob + GetBlob round-trip ---
run("PutBlob + GetBlob round-trip", func() error {
client := dial()
defer client.Close()
defer func() { _ = client.Close() }()

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
Expand Down Expand Up @@ -100,7 +100,7 @@ func main() {
// --- Test 2: GetBlob nonexistent hash returns ErrBlobNotFound ---
run("GetBlob nonexistent hash (expect ErrBlobNotFound)", func() error {
client := dial()
defer client.Close()
defer func() { _ = client.Close() }()

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
Expand All @@ -123,7 +123,7 @@ func main() {
// --- Test 3: PutBlobIfAbsent deduplication ---
run("PutBlobIfAbsent deduplication", func() error {
client := dial()
defer client.Close()
defer func() { _ = client.Close() }()

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
Expand Down Expand Up @@ -166,7 +166,7 @@ func main() {
// --- Test 4: Large blob (1 MiB) ---
run("Large blob (1 MiB) round-trip", func() error {
client := dial()
defer client.Close()
defer func() { _ = client.Close() }()

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
Expand Down Expand Up @@ -196,7 +196,7 @@ func main() {
// --- Test 5: Connection survives a not-found ---
run("Connection survives not-found", func() error {
client := dial()
defer client.Close()
defer func() { _ = client.Close() }()

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
Expand Down Expand Up @@ -244,7 +244,7 @@ func main() {
if err != nil {
return fmt.Errorf("DialReconnecting: %w", err)
}
defer rc.Close()
defer func() { _ = rc.Close() }()

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
Expand Down
14 changes: 7 additions & 7 deletions clients/go/fs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func mockServer(t *testing.T, handler mockHandler) (addr string, cleanup func())
if err != nil {
return // listener closed
}
defer conn.Close()
defer func() { _ = conn.Close() }()

// --- HELLO handshake ---
helloFrame, err := mockReadFrame(conn)
Expand Down Expand Up @@ -107,7 +107,7 @@ func mockServer(t *testing.T, handler mockHandler) (addr string, cleanup func())
}()

cleanup = func() {
ln.Close()
_ = ln.Close()
wg.Wait()
}
t.Cleanup(cleanup)
Expand Down Expand Up @@ -180,7 +180,7 @@ func TestGetBlob_Success(t *testing.T) {
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer client.Close()
defer func() { _ = client.Close() }()

data, err := client.GetBlob(context.Background(), requestHash)
if err != nil {
Expand All @@ -200,7 +200,7 @@ func TestGetBlob_NotFound(t *testing.T) {
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer client.Close()
defer func() { _ = client.Close() }()

var hash [32]byte
_, err = client.GetBlob(context.Background(), hash)
Expand All @@ -222,7 +222,7 @@ func TestGetBlob_ResponseTooShort(t *testing.T) {
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer client.Close()
defer func() { _ = client.Close() }()

var hash [32]byte
_, err = client.GetBlob(context.Background(), hash)
Expand All @@ -247,7 +247,7 @@ func TestGetBlob_PayloadTruncated(t *testing.T) {
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer client.Close()
defer func() { _ = client.Close() }()

var hash [32]byte
_, err = client.GetBlob(context.Background(), hash)
Expand Down Expand Up @@ -323,7 +323,7 @@ func TestPutBlobThenGetBlob_Roundtrip(t *testing.T) {
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer client.Close()
defer func() { _ = client.Close() }()

blobData := []byte("the quick brown fox jumps over the lazy dog")

Expand Down
18 changes: 9 additions & 9 deletions clients/go/fstree/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func TestE2E_FilesystemSnapshots(t *testing.T) {
if err != nil {
t.Fatalf("Failed to connect to server at %s: %v\nMake sure the server is running", binaryAddr, err)
}
defer client.Close()
defer func() { _ = client.Close() }()

t.Logf("Connected to CXDB server, session ID: %d", client.SessionID())

Expand Down Expand Up @@ -483,9 +483,9 @@ func makePayload(t *testing.T, itemType, text string) []byte {
t.Helper()

item := map[uint64]any{
1: itemType, // type
2: "complete", // status
3: time.Now().UnixMilli(), // timestamp
1: itemType, // type
2: "complete", // status
3: time.Now().UnixMilli(), // timestamp
4: fmt.Sprintf("test-%d", time.Now().UnixNano()), // id
}

Expand Down Expand Up @@ -520,7 +520,7 @@ func verifyHTTPFsListing(t *testing.T, turnID uint64, path string, expectedNames
if err != nil {
t.Fatalf("HTTP GET %s failed: %v", url, err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
Expand Down Expand Up @@ -558,7 +558,7 @@ func verifyHTTPFsFileContent(t *testing.T, turnID uint64, path string, expectedC
if err != nil {
t.Fatalf("HTTP GET %s failed: %v", url, err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
Expand Down Expand Up @@ -591,7 +591,7 @@ func verifyHTTPFsFileNotFound(t *testing.T, turnID uint64, path string) {
if err != nil {
t.Fatalf("HTTP GET %s failed: %v", url, err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != 404 {
t.Errorf("Expected 404 for turn %d path '%s', got %d", turnID, path, resp.StatusCode)
Expand All @@ -615,7 +615,7 @@ func TestE2E_BlobDeduplication(t *testing.T) {
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
defer client.Close()
defer func() { _ = client.Close() }()

// Create unique content
uniqueContent := []byte(fmt.Sprintf("unique content %d", time.Now().UnixNano()))
Expand Down Expand Up @@ -655,7 +655,7 @@ func TestE2E_FsRootInheritance(t *testing.T) {
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
defer client.Close()
defer func() { _ = client.Close() }()

workDir := t.TempDir()
os.WriteFile(filepath.Join(workDir, "test.txt"), []byte("inherited content"), 0644)
Expand Down
12 changes: 11 additions & 1 deletion clients/rust/src/follow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,16 @@ mod tests {
follow_turns(&ctx, event_rx, client.clone(), vec![with_follow_buffer(10)]);

event_tx.send(make_turn_event(context_id, 2, 1)).unwrap();
let mut got = vec![
out.recv_timeout(Duration::from_secs(1))
.expect("backfill turn 1")
.turn
.turn_id,
out.recv_timeout(Duration::from_secs(1))
.expect("backfill turn 2")
.turn
.turn_id,
];

client.set_context(
context_id,
Expand Down Expand Up @@ -495,7 +505,7 @@ mod tests {
event_tx.send(make_turn_event(context_id, 3, 2)).unwrap();
drop(event_tx);

let got: Vec<u64> = out.iter().map(|turn| turn.turn.turn_id).collect();
got.extend(out.iter().map(|turn| turn.turn.turn_id));
if let Some(err) = errs.try_iter().next() {
panic!("unexpected error: {}", err);
}
Expand Down
16 changes: 10 additions & 6 deletions cxtx/src/delivery.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Copyright 2025 StrongDM Inc
// SPDX-License-Identifier: Apache-2.0

use anyhow::{anyhow, Result};
use std::collections::VecDeque;
use std::time::Duration;
Expand Down Expand Up @@ -28,7 +31,7 @@ enum WorkerMessage {
#[derive(Debug, Clone)]
enum QueueItem {
CreateContext,
Append(TurnEnvelope),
Append(Box<TurnEnvelope>),
}

impl DeliveryHandle {
Expand All @@ -54,7 +57,7 @@ impl DeliveryHandle {

pub async fn enqueue_turn(&self, turn: TurnEnvelope) -> Result<()> {
self.tx
.send(WorkerMessage::Enqueue(QueueItem::Append(turn)))
.send(WorkerMessage::Enqueue(QueueItem::Append(Box::new(turn))))
.await
.map_err(|_| anyhow!("delivery worker is no longer running"))
}
Expand Down Expand Up @@ -135,8 +138,9 @@ impl DeliveryWorker {

if self.degraded && self.queue.is_empty() && !self.recovery_turn_enqueued {
self.recovery_turn_enqueued = true;
self.queue
.push_back(QueueItem::Append(self.session.ingest_recovered_turn(0)));
self.queue.push_back(QueueItem::Append(Box::new(
self.session.ingest_recovered_turn(0),
)));
} else if self.degraded
&& self.recovery_turn_enqueued
&& matches!(item, QueueItem::Append(_))
Expand Down Expand Up @@ -223,9 +227,9 @@ impl DeliveryWorker {

self.degraded = true;
self.recovery_turn_enqueued = false;
self.queue.push_back(QueueItem::Append(
self.queue.push_back(QueueItem::Append(Box::new(
self.session.ingest_degraded_turn(self.queue.len(), error),
));
)));
eprintln!("cxtx: CXDB ingest unavailable, entering queued-delivery mode");
}

Expand Down
6 changes: 5 additions & 1 deletion cxtx/src/provider/anthropic.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Copyright 2025 StrongDM Inc
// SPDX-License-Identifier: Apache-2.0

use serde_json::Value;
use std::collections::BTreeMap;

Expand Down Expand Up @@ -425,7 +428,8 @@ fn is_bootstrap_user_block(block: &Value) -> bool {
text.starts_with("<system-reminder>")
&& (text.contains("SessionStart hook additional context")
|| text.contains("The following skills are available for use with the Skill tool:")
|| text.contains("As you answer the user's questions, you can use the following context:"))
|| text
.contains("As you answer the user's questions, you can use the following context:"))
}

fn parse_assistant_content(
Expand Down
8 changes: 4 additions & 4 deletions cxtx/src/provider/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Copyright 2025 StrongDM Inc
// SPDX-License-Identifier: Apache-2.0

pub mod anthropic;
pub mod openai;

Expand Down Expand Up @@ -69,10 +72,7 @@ impl ProviderKind {
out.extend(["-c".to_string(), "prefer_websockets=false".to_string()]);
}
if !has_codex_feature_override(args, "responses_websockets") {
out.extend([
"--disable".to_string(),
"responses_websockets".to_string(),
]);
out.extend(["--disable".to_string(), "responses_websockets".to_string()]);
}
if !has_codex_feature_override(args, "responses_websockets_v2") {
out.extend([
Expand Down
Loading
Loading