| author | |
| committer | |
| log | 0e19c103ef07b6a2c143839d2d2a9cf223915d1e |
| tree | 9c40175790e649bf3a026237b40a0c05b58ce0d0 |
| parent | 32843c2551bd5832d436ef46bff5ab1198cdc39c |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 119 insertions(+), 31 deletions(-)
apps/server/src/mcp/McpHttpServer.test.ts-1| ... | @@ -23,7 +23,6 @@ const invocation = { | ... | @@ -23,7 +23,6 @@ const invocation = { |
| 23 | providerInstanceId: ProviderInstanceId.make("codex"), | 23 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 24 | capabilities: new Set(["preview"] as const), | 24 | capabilities: new Set(["preview"] as const), |
| 25 | issuedAt: 1, | 25 | issuedAt: 1, |
| 26 | expiresAt: Number.MAX_SAFE_INTEGER, | ||
| 27 | }; | 26 | }; |
| 28 | const client = McpSchema.McpServerClient.of({ | 27 | const client = McpSchema.McpServerClient.of({ |
| 29 | clientId: 1, | 28 | clientId: 1, |
apps/server/src/mcp/McpHttpServer.ts+9-1| ... | @@ -74,7 +74,15 @@ const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe( | ... | @@ -74,7 +74,15 @@ const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe( |
| 74 | ? authorization.slice("Bearer ".length).trim() | 74 | ? authorization.slice("Bearer ".length).trim() |
| 75 | : ""; | 75 | : ""; |
| 76 | const invocation = yield* registry.resolve(token); | 76 | const invocation = yield* registry.resolve(token); |
| 77 | if (!invocation) return unauthorized; | 77 | if (!invocation) { |
| 78 | // Without this the only symptom of a dead credential is the agent | ||
| 79 | // quietly losing the whole `t3-code` toolkit for the rest of its | ||
| 80 | // session, with nothing on the server to explain why. | ||
| 81 | yield* Effect.logWarning("rejected MCP request with an unusable credential", { | ||
| 82 | reason: token.length === 0 ? "missing_bearer_token" : "unknown_or_expired_token", | ||
| 83 | }); | ||
| 84 | return unauthorized; | ||
| 85 | } | ||
| 78 | return yield* httpEffect.pipe( | 86 | return yield* httpEffect.pipe( |
| 79 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), | 87 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 80 | Effect.map(normalizeMcpHttpResponse), | 88 | Effect.map(normalizeMcpHttpResponse), |
apps/server/src/mcp/McpInvocationContext.test.ts-1| ... | @@ -17,7 +17,6 @@ it.effect("reports the scoped credential context when preview capability is unav | ... | @@ -17,7 +17,6 @@ it.effect("reports the scoped credential context when preview capability is unav |
| 17 | providerInstanceId: ProviderInstanceId.make("codex"), | 17 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 18 | capabilities: new Set(), | 18 | capabilities: new Set(), |
| 19 | issuedAt: 1, | 19 | issuedAt: 1, |
| 20 | expiresAt: 2, | ||
| 21 | }; | 20 | }; |
| 22 | 21 | ||
| 23 | return Effect.gen(function* () { | 22 | return Effect.gen(function* () { |
apps/server/src/mcp/McpInvocationContext.ts-1| ... | @@ -16,7 +16,6 @@ export interface McpInvocationScope { | ... | @@ -16,7 +16,6 @@ export interface McpInvocationScope { |
| 16 | readonly providerInstanceId: ProviderInstanceId; | 16 | readonly providerInstanceId: ProviderInstanceId; |
| 17 | readonly capabilities: ReadonlySet<McpCapability>; | 17 | readonly capabilities: ReadonlySet<McpCapability>; |
| 18 | readonly issuedAt: number; | 18 | readonly issuedAt: number; |
| 19 | readonly expiresAt: number; | ||
| 20 | } | 19 | } |
| 21 | 20 | ||
| 22 | export class McpInvocationContext extends Context.Service< | 21 | export class McpInvocationContext extends Context.Service< |
apps/server/src/mcp/McpSessionRegistry.test.ts+42-3| ... | @@ -23,8 +23,7 @@ const makeRegistry = (now: () => number, httpServer = fakeHttpServer) => | ... | @@ -23,8 +23,7 @@ const makeRegistry = (now: () => number, httpServer = fakeHttpServer) => |
| 23 | McpSessionRegistry.__testing | 23 | McpSessionRegistry.__testing |
| 24 | .make({ | 24 | .make({ |
| 25 | now, | 25 | now, |
| 26 | idleTimeoutMs: 100, | 26 | livenessWindowMs: 100, |
| 27 | maximumLifetimeMs: 1_000, | ||
| 28 | }) | 27 | }) |
| 29 | .pipe( | 28 | .pipe( |
| 30 | Effect.provideService(HttpServer.HttpServer, httpServer), | 29 | Effect.provideService(HttpServer.HttpServer, httpServer), |
| ... | @@ -75,7 +74,7 @@ it.effect("builds MCP endpoints from the bound server host", () => | ... | @@ -75,7 +74,7 @@ it.effect("builds MCP endpoints from the bound server host", () => |
| 75 | }), | 74 | }), |
| 76 | ); | 75 | ); |
| 77 | 76 | ||
| 78 | it.effect("expires credentials after inactivity", () => | 77 | it.effect("expires credentials once their session stops showing signs of life", () => |
| 79 | Effect.gen(function* () { | 78 | Effect.gen(function* () { |
| 80 | let timestamp = 1_000; | 79 | let timestamp = 1_000; |
| 81 | const registry = yield* makeRegistry(() => timestamp); | 80 | const registry = yield* makeRegistry(() => timestamp); |
| ... | @@ -88,3 +87,43 @@ it.effect("expires credentials after inactivity", () => | ... | @@ -88,3 +87,43 @@ it.effect("expires credentials after inactivity", () => |
| 88 | expect(yield* registry.resolve(token)).toBeUndefined(); | 87 | expect(yield* registry.resolve(token)).toBeUndefined(); |
| 89 | }), | 88 | }), |
| 90 | ); | 89 | ); |
| 90 | |||
| 91 | it.effect("keeps a credential alive across turns that never touch an MCP tool", () => | ||
| 92 | Effect.gen(function* () { | ||
| 93 | let timestamp = 1_000; | ||
| 94 | const registry = yield* makeRegistry(() => timestamp); | ||
| 95 | const threadId = ThreadId.make("thread-3"); | ||
| 96 | const issued = yield* registry.issue({ | ||
| 97 | threadId, | ||
| 98 | providerInstanceId: ProviderInstanceId.make("claude"), | ||
| 99 | }); | ||
| 100 | const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); | ||
| 101 | |||
| 102 | // Well past the liveness window in total, but each turn reports in before | ||
| 103 | // it lapses — this is the long-session case that used to lose the toolkit. | ||
| 104 | for (let turn = 0; turn < 10; turn += 1) { | ||
| 105 | timestamp += 99; | ||
| 106 | yield* registry.touch(threadId); | ||
| 107 | } | ||
| 108 | |||
| 109 | expect((yield* registry.resolve(token))?.threadId).toBe(threadId); | ||
| 110 | }), | ||
| 111 | ); | ||
| 112 | |||
| 113 | it.effect("does not keep credentials of other threads alive", () => | ||
| 114 | Effect.gen(function* () { | ||
| 115 | let timestamp = 1_000; | ||
| 116 | const registry = yield* makeRegistry(() => timestamp); | ||
| 117 | const issued = yield* registry.issue({ | ||
| 118 | threadId: ThreadId.make("thread-4"), | ||
| 119 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 120 | }); | ||
| 121 | const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); | ||
| 122 | |||
| 123 | timestamp += 99; | ||
| 124 | yield* registry.touch(ThreadId.make("thread-unrelated")); | ||
| 125 | timestamp += 2; | ||
| 126 | |||
| 127 | expect(yield* registry.resolve(token)).toBeUndefined(); | ||
| 128 | }), | ||
| 129 | ); |
apps/server/src/mcp/McpSessionRegistry.ts+54-18| ... | @@ -18,7 +18,6 @@ export interface McpCredentialRequest { | ... | @@ -18,7 +18,6 @@ export interface McpCredentialRequest { |
| 18 | 18 | ||
| 19 | export interface McpIssuedCredential { | 19 | export interface McpIssuedCredential { |
| 20 | readonly config: McpProviderSession.McpProviderSessionConfig; | 20 | readonly config: McpProviderSession.McpProviderSessionConfig; |
| 21 | readonly expiresAt: number; | ||
| 22 | } | 21 | } |
| 23 | 22 | ||
| 24 | export interface McpSessionRegistryShape { | 23 | export interface McpSessionRegistryShape { |
| ... | @@ -26,6 +25,12 @@ export interface McpSessionRegistryShape { | ... | @@ -26,6 +25,12 @@ export interface McpSessionRegistryShape { |
| 26 | readonly resolve: ( | 25 | readonly resolve: ( |
| 27 | rawToken: string, | 26 | rawToken: string, |
| 28 | ) => Effect.Effect<McpInvocationContext.McpInvocationScope | undefined>; | 27 | ) => Effect.Effect<McpInvocationContext.McpInvocationScope | undefined>; |
| 28 | /** | ||
| 29 | * Records a sign of life for every credential bound to `threadId`. Provider | ||
| 30 | * turns call this so that a session which is plainly alive keeps its | ||
| 31 | * credential even when it goes a long time without touching an MCP tool. | ||
| 32 | */ | ||
| 33 | readonly touch: (threadId: ThreadId) => Effect.Effect<void>; | ||
| 29 | readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect<void>; | 34 | readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect<void>; |
| 30 | readonly revokeThread: (threadId: ThreadId) => Effect.Effect<void>; | 35 | readonly revokeThread: (threadId: ThreadId) => Effect.Effect<void>; |
| 31 | readonly revokeAll: Effect.Effect<void>; | 36 | readonly revokeAll: Effect.Effect<void>; |
| ... | @@ -39,7 +44,7 @@ export class McpSessionRegistry extends Context.Service< | ... | @@ -39,7 +44,7 @@ export class McpSessionRegistry extends Context.Service< |
| 39 | interface CredentialRecord { | 44 | interface CredentialRecord { |
| 40 | readonly tokenHash: string; | 45 | readonly tokenHash: string; |
| 41 | readonly scope: McpInvocationContext.McpInvocationScope; | 46 | readonly scope: McpInvocationContext.McpInvocationScope; |
| 42 | readonly lastUsedAt: number; | 47 | readonly lastAliveAt: number; |
| 43 | } | 48 | } |
| 44 | 49 | ||
| 45 | interface RegistryState { | 50 | interface RegistryState { |
| ... | @@ -47,13 +52,25 @@ interface RegistryState { | ... | @@ -47,13 +52,25 @@ interface RegistryState { |
| 47 | } | 52 | } |
| 48 | 53 | ||
| 49 | export interface McpSessionRegistryOptions { | 54 | export interface McpSessionRegistryOptions { |
| 50 | readonly idleTimeoutMs?: number; | 55 | readonly livenessWindowMs?: number; |
| 51 | readonly maximumLifetimeMs?: number; | ||
| 52 | readonly now?: () => number; | 56 | readonly now?: () => number; |
| 53 | } | 57 | } |
| 54 | 58 | ||
| 55 | const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1_000; | 59 | /** |
| 56 | const DEFAULT_MAXIMUM_LIFETIME_MS = 8 * 60 * 60 * 1_000; | 60 | * How long a credential outlives the last sign of life from its provider |
| 61 | * session. | ||
| 62 | * | ||
| 63 | * Liveness is refreshed both by MCP traffic and by `touch` on every provider | ||
| 64 | * turn, so a session that is still doing work never expires no matter how long | ||
| 65 | * it goes between browser tool calls. This window therefore only bounds | ||
| 66 | * credentials whose session died without a clean stop — the normal paths | ||
| 67 | * (`stopSession`, `stopAll`) revoke eagerly and do not wait for it. | ||
| 68 | * | ||
| 69 | * The bound matters because `/mcp` is mounted outside the environment auth | ||
| 70 | * stack and is reachable on whatever host the server binds to, so this token is | ||
| 71 | * the only thing guarding the preview toolkit on a remote-reachable server. | ||
| 72 | */ | ||
| 73 | const DEFAULT_LIVENESS_WINDOW_MS = 24 * 60 * 60 * 1_000; | ||
| 57 | 74 | ||
| 58 | const bytesToHex = (bytes: Uint8Array): string => | 75 | const bytesToHex = (bytes: Uint8Array): string => |
| 59 | Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); | 76 | Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); |
| ... | @@ -80,8 +97,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( | ... | @@ -80,8 +97,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( |
| 80 | const httpServer = yield* HttpServer.HttpServer; | 97 | const httpServer = yield* HttpServer.HttpServer; |
| 81 | const state = yield* SynchronizedRef.make<RegistryState>({ records: new Map() }); | 98 | const state = yield* SynchronizedRef.make<RegistryState>({ records: new Map() }); |
| 82 | const currentTimeMillis = options.now ? Effect.sync(options.now) : Clock.currentTimeMillis; | 99 | const currentTimeMillis = options.now ? Effect.sync(options.now) : Clock.currentTimeMillis; |
| 83 | const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; | 100 | const livenessWindowMs = options.livenessWindowMs ?? DEFAULT_LIVENESS_WINDOW_MS; |
| 84 | const maximumLifetimeMs = options.maximumLifetimeMs ?? DEFAULT_MAXIMUM_LIFETIME_MS; | ||
| 85 | const endpoint = | 101 | const endpoint = |
| 86 | httpServer.address._tag === "TcpAddress" | 102 | httpServer.address._tag === "TcpAddress" |
| 87 | ? `http://${getHttpMcpEndpointHost(httpServer.address.hostname)}:${httpServer.address.port}/mcp` | 103 | ? `http://${getHttpMcpEndpointHost(httpServer.address.hostname)}:${httpServer.address.port}/mcp` |
| ... | @@ -92,11 +108,10 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( | ... | @@ -92,11 +108,10 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( |
| 92 | .digest("SHA-256", new TextEncoder().encode(token)) | 108 | .digest("SHA-256", new TextEncoder().encode(token)) |
| 93 | .pipe(Effect.map(bytesToHex), Effect.orDie); | 109 | .pipe(Effect.map(bytesToHex), Effect.orDie); |
| 94 | 110 | ||
| 95 | const pruneExpired = (records: ReadonlyMap<string, CredentialRecord>, timestamp: number) => { | 111 | const pruneDead = (records: ReadonlyMap<string, CredentialRecord>, timestamp: number) => { |
| 96 | const next = new Map( | 112 | const next = new Map( |
| 97 | Array.from(records).filter( | 113 | Array.from(records).filter( |
| 98 | ([, record]) => | 114 | ([, record]) => timestamp - record.lastAliveAt <= livenessWindowMs, |
| 99 | timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs, | ||
| 100 | ), | 115 | ), |
| 101 | ); | 116 | ); |
| 102 | return next.size === records.size ? records : next; | 117 | return next.size === records.size ? records : next; |
| ... | @@ -108,7 +123,6 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( | ... | @@ -108,7 +123,6 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( |
| 108 | const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); | 123 | const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); |
| 109 | const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie); | 124 | const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie); |
| 110 | const tokenHash = yield* hashToken(rawToken); | 125 | const tokenHash = yield* hashToken(rawToken); |
| 111 | const expiresAt = issuedAt + maximumLifetimeMs; | ||
| 112 | const scope: McpInvocationContext.McpInvocationScope = { | 126 | const scope: McpInvocationContext.McpInvocationScope = { |
| 113 | environmentId, | 127 | environmentId, |
| 114 | threadId: ThreadId.make(request.threadId), | 128 | threadId: ThreadId.make(request.threadId), |
| ... | @@ -116,11 +130,10 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( | ... | @@ -116,11 +130,10 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( |
| 116 | providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), | 130 | providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), |
| 117 | capabilities: new Set(["preview"]), | 131 | capabilities: new Set(["preview"]), |
| 118 | issuedAt, | 132 | issuedAt, |
| 119 | expiresAt, | ||
| 120 | }; | 133 | }; |
| 121 | yield* SynchronizedRef.update(state, ({ records }) => { | 134 | yield* SynchronizedRef.update(state, ({ records }) => { |
| 122 | const next = new Map(pruneExpired(records, issuedAt)); | 135 | const next = new Map(pruneDead(records, issuedAt)); |
| 123 | next.set(tokenHash, { tokenHash, scope, lastUsedAt: issuedAt }); | 136 | next.set(tokenHash, { tokenHash, scope, lastAliveAt: issuedAt }); |
| 124 | return { records: next }; | 137 | return { records: next }; |
| 125 | }); | 138 | }); |
| 126 | return { | 139 | return { |
| ... | @@ -132,7 +145,6 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( | ... | @@ -132,7 +145,6 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( |
| 132 | endpoint, | 145 | endpoint, |
| 133 | authorizationHeader: `Bearer ${rawToken}`, | 146 | authorizationHeader: `Bearer ${rawToken}`, |
| 134 | }, | 147 | }, |
| 135 | expiresAt, | ||
| 136 | }; | 148 | }; |
| 137 | }, | 149 | }, |
| 138 | ); | 150 | ); |
| ... | @@ -143,16 +155,32 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( | ... | @@ -143,16 +155,32 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( |
| 143 | const tokenHash = yield* hashToken(rawToken); | 155 | const tokenHash = yield* hashToken(rawToken); |
| 144 | const timestamp = yield* currentTimeMillis; | 156 | const timestamp = yield* currentTimeMillis; |
| 145 | return yield* SynchronizedRef.modify(state, ({ records }) => { | 157 | return yield* SynchronizedRef.modify(state, ({ records }) => { |
| 146 | const current = pruneExpired(records, timestamp); | 158 | const current = pruneDead(records, timestamp); |
| 147 | const record = current.get(tokenHash); | 159 | const record = current.get(tokenHash); |
| 148 | if (!record) return [undefined, { records: current }] as const; | 160 | if (!record) return [undefined, { records: current }] as const; |
| 149 | const next = new Map(current); | 161 | const next = new Map(current); |
| 150 | next.set(tokenHash, { ...record, lastUsedAt: timestamp }); | 162 | next.set(tokenHash, { ...record, lastAliveAt: timestamp }); |
| 151 | return [record.scope, { records: next }] as const; | 163 | return [record.scope, { records: next }] as const; |
| 152 | }); | 164 | }); |
| 153 | }, | 165 | }, |
| 154 | ); | 166 | ); |
| 155 | 167 | ||
| 168 | const touch: McpSessionRegistryShape["touch"] = Effect.fn("McpSessionRegistry.touch")( | ||
| 169 | function* (threadId) { | ||
| 170 | const timestamp = yield* currentTimeMillis; | ||
| 171 | yield* SynchronizedRef.update(state, ({ records }) => { | ||
| 172 | const current = pruneDead(records, timestamp); | ||
| 173 | const next = new Map(current); | ||
| 174 | for (const [tokenHash, record] of current) { | ||
| 175 | if (record.scope.threadId === threadId) { | ||
| 176 | next.set(tokenHash, { ...record, lastAliveAt: timestamp }); | ||
| 177 | } | ||
| 178 | } | ||
| 179 | return { records: next }; | ||
| 180 | }); | ||
| 181 | }, | ||
| 182 | ); | ||
| 183 | |||
| 156 | const revokeWhere = (predicate: (record: CredentialRecord) => boolean) => | 184 | const revokeWhere = (predicate: (record: CredentialRecord) => boolean) => |
| 157 | SynchronizedRef.update(state, ({ records }) => ({ | 185 | SynchronizedRef.update(state, ({ records }) => ({ |
| 158 | records: new Map(Array.from(records).filter(([, record]) => !predicate(record))), | 186 | records: new Map(Array.from(records).filter(([, record]) => !predicate(record))), |
| ... | @@ -161,6 +189,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( | ... | @@ -161,6 +189,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( |
| 161 | return McpSessionRegistry.of({ | 189 | return McpSessionRegistry.of({ |
| 162 | issue, | 190 | issue, |
| 163 | resolve, | 191 | resolve, |
| 192 | touch, | ||
| 164 | revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")( | 193 | revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")( |
| 165 | function* (providerSessionId) { | 194 | function* (providerSessionId) { |
| 166 | yield* revokeWhere((record) => record.scope.providerSessionId === providerSessionId); | 195 | yield* revokeWhere((record) => record.scope.providerSessionId === providerSessionId); |
| ... | @@ -202,6 +231,13 @@ export const issueActiveMcpCredential = ( | ... | @@ -202,6 +231,13 @@ export const issueActiveMcpCredential = ( |
| 202 | .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request))) | 231 | .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request))) |
| 203 | : Effect.sync((): McpIssuedCredential | undefined => undefined); | 232 | : Effect.sync((): McpIssuedCredential | undefined => undefined); |
| 204 | 233 | ||
| 234 | /** | ||
| 235 | * Refreshes the liveness of a thread's MCP credential. Called on every provider | ||
| 236 | * turn so an active session is never mistaken for an abandoned one. | ||
| 237 | */ | ||
| 238 | export const touchActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> => | ||
| 239 | activeMcpSessionRegistry ? activeMcpSessionRegistry.touch(threadId) : Effect.void; | ||
| 240 | |||
| 205 | export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> => | 241 | export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> => |
| 206 | activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void; | 242 | activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void; |
| 207 | 243 |
apps/server/src/mcp/PreviewAutomationBroker.test.ts-1| ... | @@ -31,7 +31,6 @@ const scope = { | ... | @@ -31,7 +31,6 @@ const scope = { |
| 31 | providerInstanceId: ProviderInstanceId.make("codex"), | 31 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 32 | capabilities: new Set(["preview"] as const), | 32 | capabilities: new Set(["preview"] as const), |
| 33 | issuedAt: 1, | 33 | issuedAt: 1, |
| 34 | expiresAt: Number.MAX_SAFE_INTEGER, | ||
| 35 | }; | 34 | }; |
| 36 | 35 | ||
| 37 | const makeHost = (overrides: Partial<PreviewAutomationHost> = {}): PreviewAutomationHost => ({ | 36 | const makeHost = (overrides: Partial<PreviewAutomationHost> = {}): PreviewAutomationHost => ({ |
apps/server/src/mcp/PreviewAutomationBroker.ts+8-5| ... | @@ -22,7 +22,6 @@ import { | ... | @@ -22,7 +22,6 @@ import { |
| 22 | type PreviewAutomationStreamEvent, | 22 | type PreviewAutomationStreamEvent, |
| 23 | } from "@t3tools/contracts"; | 23 | } from "@t3tools/contracts"; |
| 24 | import * as Context from "effect/Context"; | 24 | import * as Context from "effect/Context"; |
| 25 | import * as Clock from "effect/Clock"; | ||
| 26 | import * as Crypto from "effect/Crypto"; | 25 | import * as Crypto from "effect/Crypto"; |
| 27 | import * as Deferred from "effect/Deferred"; | 26 | import * as Deferred from "effect/Deferred"; |
| 28 | import * as Effect from "effect/Effect"; | 27 | import * as Effect from "effect/Effect"; |
| ... | @@ -75,11 +74,18 @@ interface PendingRequest { | ... | @@ -75,11 +74,18 @@ interface PendingRequest { |
| 75 | readonly context: PreviewAutomationRequestErrorContext; | 74 | readonly context: PreviewAutomationRequestErrorContext; |
| 76 | } | 75 | } |
| 77 | 76 | ||
| 77 | /** | ||
| 78 | * A lease pinning one provider session to one desktop runtime. It lives exactly | ||
| 79 | * as long as the connection it names: `connectionId`/`queue` identity is what | ||
| 80 | * makes a lease valid, so a disconnected or replaced host is dropped on the next | ||
| 81 | * lookup. The lease deliberately has no clock of its own — it used to inherit | ||
| 82 | * the MCP credential's expiry, which coupled host stickiness to an unrelated | ||
| 83 | * auth deadline and could migrate a live session to another runtime mid-flow. | ||
| 84 | */ | ||
| 78 | interface HostAssignment { | 85 | interface HostAssignment { |
| 79 | readonly clientId: ClientConnection["clientId"]; | 86 | readonly clientId: ClientConnection["clientId"]; |
| 80 | readonly connectionId: ClientConnection["connectionId"]; | 87 | readonly connectionId: ClientConnection["connectionId"]; |
| 81 | readonly queue: ClientConnection["queue"]; | 88 | readonly queue: ClientConnection["queue"]; |
| 82 | readonly expiresAt: number; | ||
| 83 | readonly tabId?: PreviewTabId; | 89 | readonly tabId?: PreviewTabId; |
| 84 | readonly tabSequence?: number; | 90 | readonly tabSequence?: number; |
| 85 | } | 91 | } |
| ... | @@ -422,13 +428,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { | ... | @@ -422,13 +428,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 422 | ): Effect.fn.Return<A, PreviewAutomationError> { | 428 | ): Effect.fn.Return<A, PreviewAutomationError> { |
| 423 | const timeoutMs = input.timeoutMs ?? 15_000; | 429 | const timeoutMs = input.timeoutMs ?? 15_000; |
| 424 | const deferred = yield* Deferred.make<unknown, PreviewAutomationError>(); | 430 | const deferred = yield* Deferred.make<unknown, PreviewAutomationError>(); |
| 425 | const now = yield* Clock.currentTimeMillis; | ||
| 426 | const route = yield* SynchronizedRef.modify(state, (current) => { | 431 | const route = yield* SynchronizedRef.modify(state, (current) => { |
| 427 | const assignments = new Map( | 432 | const assignments = new Map( |
| 428 | Array.from(current.assignments).filter(([, assignment]) => { | 433 | Array.from(current.assignments).filter(([, assignment]) => { |
| 429 | const connection = current.clients.get(assignment.clientId); | 434 | const connection = current.clients.get(assignment.clientId); |
| 430 | return ( | 435 | return ( |
| 431 | assignment.expiresAt > now && | ||
| 432 | connection?.connectionId === assignment.connectionId && | 436 | connection?.connectionId === assignment.connectionId && |
| 433 | connection.queue === assignment.queue | 437 | connection.queue === assignment.queue |
| 434 | ); | 438 | ); |
| ... | @@ -473,7 +477,6 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { | ... | @@ -473,7 +477,6 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 473 | clientId: connection.clientId, | 477 | clientId: connection.clientId, |
| 474 | connectionId: connection.connectionId, | 478 | connectionId: connection.connectionId, |
| 475 | queue: connection.queue, | 479 | queue: connection.queue, |
| 476 | expiresAt: input.scope.expiresAt, | ||
| 477 | ...(canReuseAssignedTab && assigned.tabId !== undefined ? { tabId: assigned.tabId } : {}), | 480 | ...(canReuseAssignedTab && assigned.tabId !== undefined ? { tabId: assigned.tabId } : {}), |
| 478 | ...(canReuseAssignedTab && assigned.tabSequence !== undefined | 481 | ...(canReuseAssignedTab && assigned.tabSequence !== undefined |
| 479 | ? { tabSequence: assigned.tabSequence } | 482 | ? { tabSequence: assigned.tabSequence } |
apps/server/src/provider/Layers/ProviderService.ts+6| ... | @@ -679,6 +679,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -679,6 +679,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 679 | "provider.kind": routed.adapter.provider, | 679 | "provider.kind": routed.adapter.provider, |
| 680 | ...(input.modelSelection?.model ? { "provider.model": input.modelSelection.model } : {}), | 680 | ...(input.modelSelection?.model ? { "provider.model": input.modelSelection.model } : {}), |
| 681 | }); | 681 | }); |
| 682 | // A turn is the clearest sign a session is still alive. The MCP | ||
| 683 | // credential is minted once at session start and cannot be rotated into | ||
| 684 | // an already-spawned agent process, so we keep the existing token valid | ||
| 685 | // rather than issuing a new one: sessions that go a long time between | ||
| 686 | // browser tool calls used to lose the toolkit outright. | ||
| 687 | yield* McpSessionRegistry.touchActiveMcpThread(input.threadId); | ||
| 682 | const turn = yield* routed.adapter.sendTurn(input); | 688 | const turn = yield* routed.adapter.sendTurn(input); |
| 683 | yield* directory.upsert({ | 689 | yield* directory.upsert({ |
| 684 | threadId: input.threadId, | 690 | threadId: input.threadId, |