Skip to content

Commit feacfc2

Browse files
committed
feat(prime): isolate native instance ownership
Refs #199
1 parent 51ce1c4 commit feacfc2

86 files changed

Lines changed: 10390 additions & 1123 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/mobile/src/lib/modelOptions.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,4 +432,27 @@ describe("mobile model options", () => {
432432
expect(resolve(null, null, sticky)).toBe(sticky);
433433
expect(resolve(null, null, null)).toBe(providerDefault.selection);
434434
});
435+
436+
it("omits a disabled fallback selection from mobile options", () => {
437+
const fallback = {
438+
instanceId: ProviderInstanceId.make("primeAgent"),
439+
model: "default",
440+
};
441+
const config = {
442+
providers: [
443+
{
444+
instanceId: "primeAgent",
445+
driver: "primeAgent",
446+
displayName: "Prime Agent",
447+
enabled: false,
448+
installed: true,
449+
status: "disabled",
450+
auth: { status: "authenticated" },
451+
models: [],
452+
},
453+
],
454+
} as unknown as ServerConfig;
455+
456+
expect(buildModelOptions(config, fallback)).toEqual([]);
457+
});
435458
});

apps/mobile/src/lib/modelOptions.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,14 @@ export function buildModelOptions(
297297
const provider = config?.providers.find(
298298
(candidate) => candidate.instanceId === fallbackModelSelection.instanceId,
299299
);
300-
if (getProviderUnavailablePresentation(provider) === null) {
300+
if (
301+
provider !== undefined &&
302+
getProviderAdmissionAvailability({
303+
provider,
304+
instanceId: String(fallbackModelSelection.instanceId),
305+
providerSnapshotKnown: true,
306+
}).status === "available"
307+
) {
301308
const providerLabel = provider
302309
? providerDisplayLabel(provider)
303310
: fallbackModelSelection.instanceId;

apps/server/src/atomicWrite.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import * as Path from "effect/Path";
55
export const writeFileStringAtomically = (input: {
66
readonly filePath: string;
77
readonly contents: string;
8+
/** Optional process-local fence checked immediately before the atomic rename. */
9+
readonly commitGuard?: Effect.Effect<boolean>;
810
}) =>
911
Effect.scoped(
1012
Effect.gen(function* () {
@@ -20,6 +22,7 @@ export const writeFileStringAtomically = (input: {
2022
const tempPath = path.join(tempDirectory, "contents.tmp");
2123

2224
yield* fs.writeFileString(tempPath, input.contents);
25+
if (input.commitGuard !== undefined && !(yield* input.commitGuard)) return;
2326
yield* fs.rename(tempPath, input.filePath);
2427
}),
2528
);

apps/server/src/auth/RpcAuthorization.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export const RPC_REQUIRED_SCOPES = {
6262
[WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope,
6363
[WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope,
6464
[WS_METHODS.serverUpdateSettings]: AuthOrchestrationOperateScope,
65+
[WS_METHODS.serverMutateProviderInstances]: AuthOrchestrationOperateScope,
6566
[WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope,
6667
[WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope,
6768
[WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope,

apps/server/src/mcp/McpProviderSession.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
2+
import type { ProviderRuntimeFence } from "../provider/ProviderDriver.ts";
23

34
export interface McpProviderSessionConfig {
45
readonly environmentId: EnvironmentId;
@@ -10,19 +11,40 @@ export interface McpProviderSessionConfig {
1011
}
1112

1213
const sessionsByThread = new Map<ThreadId, McpProviderSessionConfig>();
14+
const generationsByThread = new Map<ThreadId, object>();
1315

14-
export function setMcpProviderSession(config: McpProviderSessionConfig): void {
16+
export function setMcpProviderSession(
17+
config: McpProviderSessionConfig,
18+
runtimeFence?: ProviderRuntimeFence,
19+
): void {
1520
sessionsByThread.set(config.threadId, config);
21+
if (runtimeFence === undefined) generationsByThread.delete(config.threadId);
22+
else generationsByThread.set(config.threadId, runtimeFence.generation);
1623
}
1724

1825
export function readMcpProviderSession(threadId: ThreadId): McpProviderSessionConfig | undefined {
1926
return sessionsByThread.get(threadId);
2027
}
2128

22-
export function clearMcpProviderSession(threadId: ThreadId): void {
23-
sessionsByThread.delete(threadId);
29+
export function isMcpProviderSessionOwnedByGeneration(
30+
threadId: ThreadId,
31+
runtimeFence: ProviderRuntimeFence,
32+
): boolean {
33+
return generationsByThread.get(threadId) === runtimeFence.generation;
34+
}
35+
36+
export function clearMcpProviderSession(
37+
threadId: ThreadId,
38+
runtimeFence?: ProviderRuntimeFence,
39+
): boolean {
40+
if (runtimeFence !== undefined && generationsByThread.get(threadId) !== runtimeFence.generation) {
41+
return false;
42+
}
43+
generationsByThread.delete(threadId);
44+
return sessionsByThread.delete(threadId);
2445
}
2546

2647
export function clearAllMcpProviderSessions(): void {
2748
sessionsByThread.clear();
49+
generationsByThread.clear();
2850
}

apps/server/src/mcp/McpSessionRegistry.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,25 @@ it.effect("does not keep credentials of other threads alive", () =>
127127
expect(yield* registry.resolve(token)).toBeUndefined();
128128
}),
129129
);
130+
131+
it.effect("keeps the current exact credential when retired issue and cleanup arrive late", () =>
132+
Effect.gen(function* () {
133+
const registry = yield* makeRegistry(() => 1_000);
134+
const threadId = ThreadId.make("thread-generation-fence");
135+
const request = {
136+
threadId,
137+
providerInstanceId: ProviderInstanceId.make("primeAgent"),
138+
};
139+
const first = yield* registry.issue(request);
140+
const firstToken = first.config.authorizationHeader.replace(/^Bearer\s+/, "");
141+
const replacement = yield* registry.issueIfCurrent(request, Effect.succeed(true));
142+
expect(replacement).toBeDefined();
143+
const replacementToken = replacement!.config.authorizationHeader.replace(/^Bearer\s+/, "");
144+
expect(yield* registry.resolve(firstToken)).toBeUndefined();
145+
146+
const retiredIssue = yield* registry.issueIfCurrent(request, Effect.succeed(false));
147+
expect(retiredIssue).toBeUndefined();
148+
yield* registry.revokeProviderSession(first.config.providerSessionId);
149+
expect((yield* registry.resolve(replacementToken))?.threadId).toBe(threadId);
150+
}),
151+
);

apps/server/src/mcp/McpSessionRegistry.ts

Lines changed: 74 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export interface McpIssuedCredential {
2222

2323
export interface McpSessionRegistryShape {
2424
readonly issue: (request: McpCredentialRequest) => Effect.Effect<McpIssuedCredential>;
25+
/** Atomically replace one thread credential only while its provider generation is current. */
26+
readonly issueIfCurrent: (
27+
request: McpCredentialRequest,
28+
isCurrent: Effect.Effect<boolean>,
29+
) => Effect.Effect<McpIssuedCredential | undefined>;
2530
readonly resolve: (
2631
rawToken: string,
2732
) => Effect.Effect<McpInvocationContext.McpInvocationScope | undefined>;
@@ -117,26 +122,26 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
117122
return next.size === records.size ? records : next;
118123
};
119124

120-
const issue: McpSessionRegistryShape["issue"] = Effect.fn("McpSessionRegistry.issue")(
121-
function* (request) {
122-
const issuedAt = yield* currentTimeMillis;
123-
const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie);
124-
const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie);
125-
const tokenHash = yield* hashToken(rawToken);
126-
const scope: McpInvocationContext.McpInvocationScope = {
127-
environmentId,
128-
threadId: ThreadId.make(request.threadId),
129-
providerSessionId,
130-
providerInstanceId: ProviderInstanceId.make(request.providerInstanceId),
131-
capabilities: new Set(["preview"]),
132-
issuedAt,
133-
};
134-
yield* SynchronizedRef.update(state, ({ records }) => {
135-
const next = new Map(pruneDead(records, issuedAt));
136-
next.set(tokenHash, { tokenHash, scope, lastAliveAt: issuedAt });
137-
return { records: next };
138-
});
139-
return {
125+
const prepareCredential = Effect.fn("McpSessionRegistry.prepareCredential")(function* (
126+
request: McpCredentialRequest,
127+
) {
128+
const issuedAt = yield* currentTimeMillis;
129+
const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie);
130+
const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie);
131+
const tokenHash = yield* hashToken(rawToken);
132+
const scope: McpInvocationContext.McpInvocationScope = {
133+
environmentId,
134+
threadId: ThreadId.make(request.threadId),
135+
providerSessionId,
136+
providerInstanceId: ProviderInstanceId.make(request.providerInstanceId),
137+
capabilities: new Set(["preview"]),
138+
issuedAt,
139+
};
140+
return {
141+
issuedAt,
142+
tokenHash,
143+
scope,
144+
credential: {
140145
config: {
141146
environmentId,
142147
threadId: scope.threadId,
@@ -145,10 +150,50 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
145150
endpoint,
146151
authorizationHeader: `Bearer ${rawToken}`,
147152
},
148-
};
153+
} satisfies McpIssuedCredential,
154+
};
155+
});
156+
157+
const issue: McpSessionRegistryShape["issue"] = Effect.fn("McpSessionRegistry.issue")(
158+
function* (request) {
159+
const prepared = yield* prepareCredential(request);
160+
yield* SynchronizedRef.update(state, ({ records }) => {
161+
const next = new Map(pruneDead(records, prepared.issuedAt));
162+
next.set(prepared.tokenHash, {
163+
tokenHash: prepared.tokenHash,
164+
scope: prepared.scope,
165+
lastAliveAt: prepared.issuedAt,
166+
});
167+
return { records: next };
168+
});
169+
return prepared.credential;
149170
},
150171
);
151172

173+
const issueIfCurrent: McpSessionRegistryShape["issueIfCurrent"] = Effect.fn(
174+
"McpSessionRegistry.issueIfCurrent",
175+
)(function* (request, isCurrent) {
176+
const prepared = yield* prepareCredential(request);
177+
return yield* SynchronizedRef.modifyEffect(state, ({ records }) =>
178+
Effect.gen(function* () {
179+
// The generation check and replacement share the registry's single mutation permit.
180+
if (!(yield* isCurrent)) return [undefined, { records }] as const;
181+
const current = pruneDead(records, prepared.issuedAt);
182+
const next = new Map(
183+
Array.from(current).filter(
184+
([, record]) => record.scope.threadId !== prepared.scope.threadId,
185+
),
186+
);
187+
next.set(prepared.tokenHash, {
188+
tokenHash: prepared.tokenHash,
189+
scope: prepared.scope,
190+
lastAliveAt: prepared.issuedAt,
191+
});
192+
return [prepared.credential, { records: next }] as const;
193+
}),
194+
);
195+
});
196+
152197
const resolve: McpSessionRegistryShape["resolve"] = Effect.fn("McpSessionRegistry.resolve")(
153198
function* (rawToken) {
154199
if (rawToken.length === 0) return undefined;
@@ -188,6 +233,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
188233

189234
return McpSessionRegistry.of({
190235
issue,
236+
issueIfCurrent,
191237
resolve,
192238
touch,
193239
revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")(
@@ -224,13 +270,17 @@ export const layer = Layer.effect(McpSessionRegistry, make);
224270

225271
export const issueActiveMcpCredential = (
226272
request: McpCredentialRequest,
273+
isCurrent: Effect.Effect<boolean> = Effect.succeed(true),
227274
): Effect.Effect<McpIssuedCredential | undefined> =>
228275
activeMcpSessionRegistry
229-
? activeMcpSessionRegistry
230-
.revokeThread(request.threadId)
231-
.pipe(Effect.andThen(activeMcpSessionRegistry.issue(request)))
276+
? activeMcpSessionRegistry.issueIfCurrent(request, isCurrent)
232277
: Effect.sync((): McpIssuedCredential | undefined => undefined);
233278

279+
export const revokeActiveMcpProviderSession = (providerSessionId: string): Effect.Effect<void> =>
280+
activeMcpSessionRegistry
281+
? activeMcpSessionRegistry.revokeProviderSession(providerSessionId)
282+
: Effect.void;
283+
234284
/**
235285
* Refreshes the liveness of a thread's MCP credential. Called on every provider
236286
* turn so an active session is never mistaken for an abandoned one.

0 commit comments

Comments
 (0)