Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
3249ab8
Add Claude Opus 5 capabilities
yaacovcorcos Jul 24, 2026
ada9658
Merge remote-tracking branch 'origin/main' into maintenance/upstream-…
yaacovcorcos Jul 25, 2026
fbb16a1
Harden Claude capability discovery and Opus gating
yaacovcorcos Jul 25, 2026
108c565
Refresh Claude discovery results
yaacovcorcos Jul 25, 2026
d0c9062
Fail closed on Claude capability boundaries
yaacovcorcos Jul 25, 2026
8e7091a
Scope Claude discovery to the exact runtime
yaacovcorcos Jul 25, 2026
420453b
Bind Claude Opus 5 to the exact runtime
yaacovcorcos Jul 25, 2026
df872a5
Merge remote-tracking branch 'origin/main' into maintenance/upstream-…
yaacovcorcos Jul 26, 2026
e476afc
Harden Claude Opus 5 runtime gating
yaacovcorcos Jul 26, 2026
65c96a0
Keep Claude fresh defaults available
yaacovcorcos Jul 26, 2026
3191557
fix(claude): isolate discovery lifecycle generations
yaacovcorcos Jul 26, 2026
e9ad408
fix(claude): bind discovery cache generations
yaacovcorcos Jul 26, 2026
12b8f83
test(web): bind Claude prefetch generation keys
yaacovcorcos Jul 26, 2026
226f2ad
fix(claude): authorize Opus 5 from exact runtime
yaacovcorcos Jul 26, 2026
925b2f1
fix(claude): make discovery generations monotonic
yaacovcorcos Jul 26, 2026
e0a49b1
Fix provider runtime extraction and setup progress
yaacovcorcos Jul 26, 2026
2f5eb8a
fix(claude): harden discovery lifecycle ownership
yaacovcorcos Jul 26, 2026
af06e26
fix(claude): generation-key native slash-command discovery
yaacovcorcos Jul 26, 2026
07184d5
Merge remote-tracking branch 'origin/main' into maintenance/upstream-…
yaacovcorcos Jul 26, 2026
295c519
feat(agent-gateway): host-served MCP read/coordination tools (Slice 1)
yaacovcorcos Jul 26, 2026
31a7046
fix(agent-gateway): keep contracts barrel frozen + format Slice 1 files
yaacovcorcos Jul 26, 2026
f215938
fix(agent-gateway): close read_thread disclosure oracle + revoke toke…
yaacovcorcos Jul 27, 2026
9560b9d
feat(agent-gateway): drive tools — send_message + interrupt (Slice 2)
yaacovcorcos Jul 26, 2026
5760bc1
fix(agent-gateway): keep AnyCommand as `any` so indexed access typech…
yaacovcorcos Jul 26, 2026
f95488e
refactor(persistence): pin migration 035 to a frozen model-selection …
yaacovcorcos Jul 27, 2026
4672cf8
chore(migration-guard): audited one-time allowance for the 035 decouple
yaacovcorcos Jul 27, 2026
04538d9
Merge origin/main into maintenance/upstream-opus5-20260725
yaacovcorcos Jul 27, 2026
7fe2a49
Merge origin/main (bb78b795, #139) into maintenance/upstream-opus5-20…
yaacovcorcos Jul 27, 2026
a1e3bf0
Merge remote-tracking branch 'origin/main' into agent/pr68-cleanup-20…
yaacovcorcos Jul 27, 2026
ddf27b8
feat(providers): add safe sign-out controls
yaacovcorcos Jul 27, 2026
88164c6
Merge remote-tracking branch 'origin/main' into agent/pr68-cleanup-20…
yaacovcorcos Jul 27, 2026
9cb17a8
fix(providers): close review gaps
yaacovcorcos Jul 27, 2026
9a47c0b
fix(providers): polish status accessibility
yaacovcorcos Jul 27, 2026
61e8886
fix(providers): preserve released contract lineage
yaacovcorcos Jul 27, 2026
329cb74
fix(providers): use native sign-out confirmation
yaacovcorcos Jul 27, 2026
9d98400
Merge remote-tracking branch 'origin/main' into agent/pr68-cleanup-20…
yaacovcorcos Jul 27, 2026
5a14c25
Merge remote-tracking branch 'origin/main' into maintenance/upstream-…
yaacovcorcos Jul 27, 2026
338ad96
Fix Claude discovery generation isolation
yaacovcorcos Jul 27, 2026
d09270f
Merge remote-tracking branch 'origin/agent/pr68-cleanup-20260726' int…
yaacovcorcos Jul 27, 2026
d071fd4
Fix Claude model discovery without init event
yaacovcorcos Jul 27, 2026
68eaaed
perf(git): fetch compact diff scope stats
yaacovcorcos Jul 27, 2026
8d454c9
fix(diff): keep selected scope count current
yaacovcorcos Jul 28, 2026
79fc99f
fix(diff): move stats RPC outside released contracts
yaacovcorcos Jul 28, 2026
89a001c
Merge remote-tracking branch 'origin/main' into agent/pr68-cleanup-20…
yaacovcorcos Jul 28, 2026
7c9fc13
fix: enforce provider archive extraction limits
yaacovcorcos Jul 28, 2026
9fe9d20
Merge commit '7c9fc1384a09a10124141e8faa0c518611640919' into integrat…
yaacovcorcos Jul 28, 2026
f50b693
Merge commit 'd071fd46ab01b9b95cc4c2955301f4a17ab2ef24' into integrat…
yaacovcorcos Jul 28, 2026
b1f7e56
fix(web): close project actions before editing
yaacovcorcos Jul 28, 2026
f2c7473
test(web): cover project action focus handoff
yaacovcorcos Jul 28, 2026
c31be8b
feat(git): discover committed PR templates safely
yaacovcorcos Jul 28, 2026
6b4e896
Merge commit 'f2c7473a964dc72fa004267b7f23ffeac5014e4a' into integrat…
yaacovcorcos Jul 28, 2026
d10abae
Merge PR #146 into desktop integration queue
yaacovcorcos Jul 28, 2026
afb4081
Merge commit 'f215938ccabe9dec94fc70fe7059d0f7854e0489' into integrat…
yaacovcorcos Jul 28, 2026
d8cbce7
Merge commit '5760bc1950b203e3f1ac2c222c924cab7c49a3b3' into integrat…
yaacovcorcos Jul 28, 2026
e71f62b
Merge remote-tracking branch 'origin/integration/desktop-queue-202607…
yaacovcorcos Jul 28, 2026
a24cc12
fix(server): harden agent gateway session boundaries
yaacovcorcos Jul 28, 2026
242a4d4
fix(server): close agent gateway review gaps
yaacovcorcos Jul 28, 2026
86b97d5
fix(server): bind gateway limits to session lifecycle
yaacovcorcos Jul 28, 2026
dad795b
fix(agent-gateway): preserve honest message provenance
yaacovcorcos Jul 28, 2026
e92da04
fix(orchestration): retain agent dispatch provenance
yaacovcorcos Jul 28, 2026
9e67226
Merge agent gateway queue adaptation
yaacovcorcos Jul 28, 2026
66e341f
Merge aligned compact diff statistics
yaacovcorcos Jul 28, 2026
e355652
fix(git): honor GitHub PR template variants
yaacovcorcos Jul 28, 2026
672a4d4
Merge commit '66e341f2825abe7831d175c70af5c3bd25515120' into feature/…
yaacovcorcos Jul 28, 2026
287fc65
fix(git): require canonical PR template names
yaacovcorcos Jul 28, 2026
fd7e8e9
fix(git): require dotted template extensions
yaacovcorcos Jul 28, 2026
20846fa
Merge safe PR template discovery
yaacovcorcos Jul 28, 2026
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
61 changes: 61 additions & 0 deletions apps/server/src/agentGateway/Layers/AgentGateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* AgentGatewayLive - Live layer wiring the Scient agent gateway read + drive
* surface.
*
* Composes the credential service, the read-model snapshot query, the
* orchestration engine, and the read/coordination and drive tools into the MCP
* streamable-HTTP transport served by the `POST /mcp` route. Read tools observe
* sibling threads; drive tools (send/interrupt) dispatch orchestration commands
* and are admitted only while the caller's own turn is active.
*
* @module agentGateway/Layers/AgentGateway
*/
import { ThreadId } from "@synara/contracts";
import { Effect, Layer, Option } from "effect";

import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
import { SYNARA_GATEWAY_HARNESS_POLICY } from "../harnessPolicy.ts";
import { makeAgentGatewayMcpTransport } from "../mcpTransport.ts";
import { AgentGateway, type AgentGatewayShape } from "../Services/AgentGateway.ts";
import { AgentGatewayCredentials } from "../Services/AgentGatewayCredentials.ts";
import { makeThreadReadTools } from "../threadReadTools.ts";
import { makeThreadWriteTools } from "../threadWriteTools.ts";

export const makeAgentGateway = Effect.gen(function* () {
const credentials = yield* AgentGatewayCredentials;
const snapshotQuery = yield* ProjectionSnapshotQuery;
const orchestrationEngine = yield* OrchestrationEngineService;

const requireThreadShell = (threadId: string) =>
snapshotQuery.getThreadShellById(ThreadId.makeUnsafe(threadId)).pipe(
Effect.flatMap(
Option.match({
onNone: () => Effect.fail(new Error(`Thread "${threadId}" was not found.`)),
onSome: Effect.succeed,
}),
),
);

const tools = [
...makeThreadReadTools({ snapshotQuery, requireThreadShell }),
...makeThreadWriteTools({
snapshotQuery,
orchestrationEngine,
requireThreadShell,
subscribeSessionRevocations: credentials.subscribeSessionRevocations,
}),
];

return {
handleMcpPost: makeAgentGatewayMcpTransport({
credentials,
snapshotQuery,
tools,
instructions: SYNARA_GATEWAY_HARNESS_POLICY,
requireThreadShell,
}),
} satisfies AgentGatewayShape;
});

export const AgentGatewayLive = Layer.effect(AgentGateway, makeAgentGateway);
198 changes: 198 additions & 0 deletions apps/server/src/agentGateway/Layers/AgentGatewayCredentials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { mkdtempSync, rmSync } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";

import { ThreadId } from "@synara/contracts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { Effect, Layer } from "effect";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import {
deriveServerPaths,
resolveDefaultChatWorkspaceRoot,
resolveDefaultStudioWorkspaceRoot,
ServerConfig,
type ServerConfigShape,
} from "../../config.ts";
import { AgentGatewayCredentials } from "../Services/AgentGatewayCredentials.ts";
import {
AGENT_GATEWAY_MCP_PATH,
AgentGatewayCredentialsLive,
makeAgentGatewayEndpoint,
resolveAgentGatewayEndpointHost,
} from "./AgentGatewayCredentials.ts";

const THREAD = ThreadId.makeUnsafe("thread-1");
const TEST_PORT = 47_321;

describe("resolveAgentGatewayEndpointHost", () => {
it("returns loopback when no host is configured", () => {
expect(resolveAgentGatewayEndpointHost(undefined)).toBe("127.0.0.1");
});

it.each(["0.0.0.0", "::", "[::]"])("returns loopback for the wildcard host %s", (host) => {
expect(resolveAgentGatewayEndpointHost(host)).toBe("127.0.0.1");
});

it("bracket-formats an explicit IPv6 host", () => {
expect(resolveAgentGatewayEndpointHost("::1")).toBe("[::1]");
});

it("passes an explicit IPv4 host through unchanged", () => {
expect(resolveAgentGatewayEndpointHost("192.168.1.5")).toBe("192.168.1.5");
});
});

describe("makeAgentGatewayEndpoint", () => {
it("builds a loopback mcp url with the given port when no host is configured", () => {
const endpoint = makeAgentGatewayEndpoint(undefined, 3773);
expect(endpoint.url).toBe(`http://127.0.0.1:3773${AGENT_GATEWAY_MCP_PATH}`);
});

it("uses the resolved host", () => {
const endpoint = makeAgentGatewayEndpoint("192.168.1.5", 3773);
expect(endpoint.url).toBe(`http://192.168.1.5:3773${AGENT_GATEWAY_MCP_PATH}`);
});

it("reflects a listening port set after construction", () => {
const endpoint = makeAgentGatewayEndpoint(undefined, 3773);
expect(endpoint.url.endsWith(":3773/mcp")).toBe(true);

endpoint.setListeningPort(9999);

expect(endpoint.url).toBe(`http://127.0.0.1:9999${AGENT_GATEWAY_MCP_PATH}`);
});
});

describe("AgentGatewayCredentialsLive", () => {
let root: string;
let homeDir: string;
let baseDir: string;
let cwd: string;

beforeEach(() => {
root = mkdtempSync(path.join(os.tmpdir(), "agent-gateway-credentials-"));
homeDir = path.join(root, "home");
baseDir = path.join(homeDir, ".synara");
cwd = path.join(root, "repo");
});

afterEach(() => {
rmSync(root, { recursive: true, force: true });
});

const makeConfigLayer = () =>
Layer.effect(
ServerConfig,
Effect.gen(function* () {
const derived = yield* deriveServerPaths(baseDir, undefined);
return {
mode: "web",
port: TEST_PORT,
host: undefined,
cwd,
homeDir,
chatWorkspaceRoot: resolveDefaultChatWorkspaceRoot({ homeDir }),
studioWorkspaceRoot: resolveDefaultStudioWorkspaceRoot({ homeDir }),
baseDir,
...derived,
staticDir: undefined,
devUrl: undefined,
noBrowser: true,
authToken: undefined,
autoBootstrapProjectFromCwd: false,
logProviderEvents: false,
logWebSocketEvents: false,
agentGatewayEnabled: false,
} satisfies ServerConfigShape;
}),
);

// AgentGatewayCredentialsLive already composes its own AgentGatewaySessionRegistryLive
// internally (see AgentGatewayCredentials.ts), so the only remaining requirement to
// discharge here is ServerConfig (plus the platform services it needs to derive paths).
const testLayer = () =>
AgentGatewayCredentialsLive.pipe(
Layer.provide(makeConfigLayer()),
Layer.provide(NodeServices.layer),
);

const run = <A>(program: Effect.Effect<A, never, AgentGatewayCredentials>): Promise<A> =>
Effect.runPromise(program.pipe(Effect.provide(testLayer())));

it("mcpEndpointUrl resolves to loopback with the configured port", async () => {
const url = await run(
Effect.gen(function* () {
const credentials = yield* AgentGatewayCredentials;
return credentials.mcpEndpointUrl;
}),
);

expect(url).toBe(`http://127.0.0.1:${TEST_PORT}${AGENT_GATEWAY_MCP_PATH}`);
});

it("issueSessionToken returns an opaque token", async () => {
const token = await run(
Effect.gen(function* () {
const credentials = yield* AgentGatewayCredentials;
return credentials.issueSessionToken(THREAD, "claudeAgent");
}),
);

expect(typeof token).toBe("string");
expect(token.length).toBeGreaterThan(0);
});

it("verifySessionToken resolves an issued token back to its thread id", async () => {
const threadId = await run(
Effect.gen(function* () {
const credentials = yield* AgentGatewayCredentials;
const token = credentials.issueSessionToken(THREAD, "claudeAgent");
return credentials.verifySessionToken(token);
}),
);

expect(threadId).toBe(THREAD);
});

it("verifySessionToken returns null for an unknown token", async () => {
const result = await run(
Effect.gen(function* () {
const credentials = yield* AgentGatewayCredentials;
return credentials.verifySessionToken("bad");
}),
);

expect(result).toBeNull();
});

it("connectionForThread returns the mcp endpoint url and a bearer token that verifies back to the thread", async () => {
const result = await run(
Effect.gen(function* () {
const credentials = yield* AgentGatewayCredentials;
const connection = credentials.connectionForThread(THREAD, "claudeAgent");
return {
url: connection.url,
mcpEndpointUrl: credentials.mcpEndpointUrl,
verifiedThreadId: credentials.verifySessionToken(connection.bearerToken),
};
}),
);

expect(result.url).toBe(result.mcpEndpointUrl);
expect(result.verifiedThreadId).toBe(THREAD);
});

it("revokeSessionToken invalidates a previously issued bearer token", async () => {
const verifiedAfterRevoke = await run(
Effect.gen(function* () {
const credentials = yield* AgentGatewayCredentials;
const connection = credentials.connectionForThread(THREAD, "claudeAgent");
credentials.revokeSessionToken(connection.bearerToken);
return credentials.verifySessionToken(connection.bearerToken);
}),
);

expect(verifiedAfterRevoke).toBeNull();
});
});
86 changes: 86 additions & 0 deletions apps/server/src/agentGateway/Layers/AgentGatewayCredentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* AgentGatewayCredentialsLive - Live layer for agent gateway credentials.
*
* Issues opaque in-memory credentials. Tokens live for the provider session,
* can be revoked independently, and intentionally do not survive a Scient
* restart.
*
* @module agentGateway/Layers/AgentGatewayCredentials
*/
import { Effect, Layer } from "effect";

import { ServerConfig } from "../../config.ts";
import { formatHostForUrl, isWildcardHost } from "../../startupAccess.ts";
import {
AgentGatewayCredentials,
type AgentGatewayCredentialsShape,
} from "../Services/AgentGatewayCredentials.ts";
import { AgentGatewaySessionRegistry } from "../Services/AgentGatewaySessionRegistry.ts";
import { AgentGatewaySessionRegistryLive } from "./AgentGatewaySessionRegistry.ts";

export const AGENT_GATEWAY_MCP_PATH = "/mcp";

// Providers run as local child processes, so they must target a host the HTTP
// server actually listens on. Wildcard binds cover loopback; an explicit host
// (e.g. `::1` or a LAN address) does not, so reuse it verbatim.
export function resolveAgentGatewayEndpointHost(configHost: string | undefined): string {
if (configHost === undefined || isWildcardHost(configHost)) {
return "127.0.0.1";
}
return formatHostForUrl(configHost);
}

export function makeAgentGatewayEndpoint(configHost: string | undefined, initialPort: number) {
const endpointHost = resolveAgentGatewayEndpointHost(configHost);
let port = initialPort;
return {
get url() {
return `http://${endpointHost}:${port}${AGENT_GATEWAY_MCP_PATH}`;
},
setListeningPort: (listeningPort: number) => {
port = listeningPort;
},
};
}

export const makeAgentGatewayCredentials = Effect.gen(function* () {
const config = yield* ServerConfig;
const sessionRegistry = yield* AgentGatewaySessionRegistry;

const endpoint = makeAgentGatewayEndpoint(config.host, config.port);

const issueSessionToken: AgentGatewayCredentialsShape["issueSessionToken"] = (
threadId,
provider,
) => sessionRegistry.issue(threadId, provider).token;

const verifySessionToken: AgentGatewayCredentialsShape["verifySessionToken"] = (token) =>
sessionRegistry.verify(token)?.threadId ?? null;

return {
get mcpEndpointUrl() {
return endpoint.url;
},
setListeningPort: endpoint.setListeningPort,
issueSessionToken,
verifySessionToken,
verifySession: sessionRegistry.verify,
bindWriteAuthority: sessionRegistry.bindWriteAuthority,
verifyWriteAuthority: sessionRegistry.verifyWriteAuthority,
subscribeSessionRevocations: sessionRegistry.subscribeRevocations,
revokeSessionToken: sessionRegistry.revoke,
connectionForThread: (threadId, provider) => ({
url: endpoint.url,
bearerToken: issueSessionToken(threadId, provider),
}),
} satisfies AgentGatewayCredentialsShape;
});

export const AgentGatewayCredentialsLive = Layer.effect(
AgentGatewayCredentials,
makeAgentGatewayCredentials,
).pipe(Layer.provide(AgentGatewaySessionRegistryLive));

// Single shared composition so every consumer (HTTP gateway, provider
// adapters) reuses the same memoized in-memory session registry.
export const AgentGatewayCredentialsWithSecretsLive = AgentGatewayCredentialsLive.pipe(Layer.orDie);
Loading