From cc916bd2b25f513ab7c8c1f8e04387aa2c5e0f9a Mon Sep 17 00:00:00 2001 From: unlockdigital Date: Thu, 29 Oct 2026 10:59:04 -0500 Subject: [PATCH 1/5] fix(github): validate hosts before selecting credentials Adapt upstream pingdotgg/t3code#26176 and preserve IPv6 enterprise hosts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/server/GitHubCredentials.test.ts | 34 +++++++++++++++++++ .../src/server/GitHubCredentials.ts | 18 ++++++++++ .../GitHubSourceControlProvider.test.ts | 27 +++++++++++++++ .../server/gitHubRepositoryResolution.test.ts | 25 ++++++++++++++ .../src/server/gitHubRepositoryResolution.ts | 6 ++-- 5 files changed, 108 insertions(+), 2 deletions(-) diff --git a/packages/source-control-github/src/server/GitHubCredentials.test.ts b/packages/source-control-github/src/server/GitHubCredentials.test.ts index 61bfc59cc3f87..f94c7d3b1ca3a 100644 --- a/packages/source-control-github/src/server/GitHubCredentials.test.ts +++ b/packages/source-control-github/src/server/GitHubCredentials.test.ts @@ -148,4 +148,38 @@ describe("GitHubCredentials", () => { ); }).pipe(Effect.provide(layer)); }); + + it.effect("never hands a token to a host that can redirect an API request", () => { + vi.stubEnv("GH_TOKEN", "env-token"); + const { layer, calls } = harness(); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + for (const host of [ + "evil.com#.ghe.com", + "evil.com/.ghe.com", + "user@x.ghe.com", + "[::1]@evil.com", + "[::1].ghe.com", + "[not-ipv6]:8443", + "[::1]:65536", + ]) { + expect(GitHubCredentials.environmentToken(host, { GH_TOKEN: "env-token" })).toBeNull(); + expect((yield* Effect.flip(credentials.get(host)))._tag).toBe("GitHubNotSignedInError"); + } + expect(calls).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("retains enterprise credentials for a configured IPv6 host", () => { + vi.stubEnv("GH_HOST", "[::1]:8443"); + vi.stubEnv("GH_ENTERPRISE_TOKEN", "enterprise-token"); + const { layer, calls } = harness(); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const credential = yield* credentials.get("[::1]:8443"); + expect(Redacted.value(credential.token)).toBe("enterprise-token"); + expect(credential.source).toBe("env"); + expect(calls).toEqual([]); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/packages/source-control-github/src/server/GitHubCredentials.ts b/packages/source-control-github/src/server/GitHubCredentials.ts index b2d0b7d0ea34b..e45442a17bab2 100644 --- a/packages/source-control-github/src/server/GitHubCredentials.ts +++ b/packages/source-control-github/src/server/GitHubCredentials.ts @@ -1,3 +1,5 @@ +import * as NodeNet from "node:net"; + import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -90,6 +92,20 @@ function normalizeHost(host: string): string { return host.trim().toLowerCase(); } +const HOSTNAME = + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*(?::\d{1,5})?$/; + +/** API URLs are built from the host text, so reject anything that can redirect a token. */ +export function isGitHubHost(host: string): boolean { + if (HOSTNAME.test(host)) return true; + const ipv6 = /^\[([^\]]+)\](?::(\d{1,5}))?$/.exec(host); + return ( + ipv6 !== null && + NodeNet.isIP(ipv6[1]!) === 6 && + (ipv6[2] === undefined || Number(ipv6[2]) <= 65535) + ); +} + /** Hosts gh treats as GitHub.com-like for `GH_TOKEN`: github.com and GHE.com data residency. */ function isGitHubDotCom(host: string): boolean { return host === "github.com" || host.endsWith(".ghe.com"); @@ -104,6 +120,7 @@ export function environmentToken( host: string, env: Readonly>, ): string | null { + if (!isGitHubHost(host)) return null; const names = isGitHubDotCom(host) ? ["GH_TOKEN", "GITHUB_TOKEN"] : env.GH_HOST?.trim().toLowerCase() === host @@ -220,6 +237,7 @@ export const make = Effect.gen(function* () { return GitHubCredentials.of({ get: Effect.fn("GitHubCredentials.get")(function* (rawHost) { const host = normalizeHost(rawHost); + if (!isGitHubHost(host)) return yield* new GitHubNotSignedInError({ host }); const choice = yield* hostChoice(host); if (choice?.enabled === false) { return yield* new GitHubHostDisabledError({ host }); diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts index a8dfbabf7d282..64892de1f8a3c 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -501,6 +501,33 @@ describe("GitHubSourceControlProvider writes", () => { assert.strictEqual(requests.length, 2); }); }); + + it.effect("rejects a deceptive repository host before making a REST request", () => { + const requests: string[] = []; + return Effect.gen(function* () { + const gh = yield* makeProvider({ + rest: (input) => + Effect.sync(() => { + requests.push(input.host); + return restResponse({ + full_name: "acme/web", + html_url: "https://github.com/acme/web", + ssh_url: "git@github.com:acme/web.git", + }); + }), + }); + for (const repository of [ + "evil.com#.ghe.com/acme/web", + "evil.com?.ghe.com/acme/web", + "user@evil.com/acme/web", + ]) { + yield* Effect.flip(gh.getRepositoryCloneUrls({ cwd: "/repo", repository })); + } + assert.deepStrictEqual(requests, []); + yield* gh.getRepositoryCloneUrls({ cwd: "/repo", repository: "github.com/acme/web" }); + assert.deepStrictEqual(requests, ["github.com"]); + }); + }); }); describe("GitHubSourceControlProvider.checkoutChangeRequest", () => { diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts index c78c1fd7612f8..fa4d20e48aac0 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest"; import { gitHubApiHostForRemote, + parseGitHubRepositorySelector, selectGitHubBaseRepository, } from "./gitHubRepositoryResolution.ts"; @@ -89,3 +90,27 @@ describe("gitHubApiHostForRemote", () => { assert.strictEqual(gitHubApiHostForRemote("git@gitlab.com:a/b.git"), null); }); }); + +describe("parseGitHubRepositorySelector", () => { + it("reads host/owner/name only when the host is a plain hostname", () => { + assert.deepStrictEqual(parseGitHubRepositorySelector("GHE.corp:8443/acme/web", "github.com"), { + host: "ghe.corp:8443", + owner: "acme", + name: "web", + }); + assert.deepStrictEqual(parseGitHubRepositorySelector("[::1]:8443/acme/web", "github.com"), { + host: "[::1]:8443", + owner: "acme", + name: "web", + }); + for (const selector of [ + "evil.com#.ghe.com/acme/web", + "evil.com?.ghe.com/acme/web", + "token@evil.com/acme/web", + "evil.com\\.ghe.com/acme/web", + "-evil.com/acme/web", + ]) { + assert.strictEqual(parseGitHubRepositorySelector(selector, "github.com"), null, selector); + } + }); +}); diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts index d9e2e9743e163..136039f08381e 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts @@ -3,6 +3,7 @@ import { detectSourceControlProviderFromRemoteUrl, isSshRemoteUrl, } from "@t3tools/shared/sourceControl"; +import { isGitHubHost } from "./GitHubCredentials.ts"; export interface GitHubRepositoryLocator { readonly host: string; @@ -127,8 +128,9 @@ export function parseGitHubRepositorySelector( } const parts = trimmed.split("/").filter(Boolean); if (parts.length === 2) return { host: defaultHost, owner: parts[0]!, name: parts[1]! }; - if (parts.length === 3) - return { host: parts[0]!.toLowerCase(), owner: parts[1]!, name: parts[2]! }; + const host = parts[0]?.toLowerCase(); + if (parts.length === 3 && host !== undefined && isGitHubHost(host)) + return { host, owner: parts[1]!, name: parts[2]! }; return null; } From bdf9e63afbd722eff943c848db81c989297fc811 Mon Sep 17 00:00:00 2001 From: unlockdigital Date: Thu, 29 Oct 2026 10:59:59 -0500 Subject: [PATCH 2/5] fix(claude): show commands in approval prompts Backport pingdotgg/t3code#20145. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Adapters/ClaudeAdapterV2.test.ts | 20 +++++++++++++------ .../Adapters/ClaudeAdapterV2.ts | 3 +++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 9fed0cd0f0c9c..6926eff4f12f5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -823,8 +823,11 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { assert.isFunction(canUseTool); const requestEvent = yield* runtime.events.pipe( - Stream.filter((event) => event.type === "runtime_request.updated"), - Stream.runHead, + Stream.takeUntil( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "approval_request", + ), + Stream.runCollect, Effect.forkScoped, ); const command = { command: "node -e 'console.log(42)'" }; @@ -833,6 +836,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { signal: new AbortController().signal, toolUseID: "tool-bash-accept-edits", requestId: "request-bash-accept-edits", + description: "Check the Node runtime", }), ).pipe(Effect.forkScoped); const first = yield* Effect.raceFirst( @@ -845,12 +849,16 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { ); assert.equal(first.type, "request", "the command ran without asking"); if (first.type !== "request") return; - const event = first.event; - if (Option.isNone(event) || event.value.type !== "runtime_request.updated") return; - assert.equal(event.value.runtimeRequest.kind, "command"); + const event = first.event.find((event) => event.type === "runtime_request.updated"); + assert.equal(event?.runtimeRequest.kind, "command"); + const approval = first.event.findLast((event) => event.type === "turn_item.updated"); + assert.equal( + approval?.turnItem.type === "approval_request" ? approval.turnItem.prompt : undefined, + command.command, + ); yield* runtime.respondToRuntimeRequest({ - requestId: event.value.runtimeRequest.id, + requestId: event!.runtimeRequest.id, decision: "accept", }); assert.equal((yield* Fiber.join(decision))?.behavior, "allow"); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 6ede41312ef0a..3f4a5d78bae5b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -9141,7 +9141,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } const requestKind = providerRequestKindFromClaudeTool(toolName); + // The user approves the command itself; Claude's title and description only summarize it. + const command = inputRecordValue(nativeToolInput, "command"); const prompt = + (typeof command === "string" && command.trim() !== "" ? command : undefined) ?? callbackOptions.title ?? callbackOptions.description ?? callbackOptions.decisionReason ?? From 69109b649ddfa8f96b8961932a8c9b66fbf5b451 Mon Sep 17 00:00:00 2001 From: unlockdigital Date: Thu, 29 Oct 2026 10:59:59 -0500 Subject: [PATCH 3/5] fix(server): bound delegated stop effects Backport pingdotgg/t3code#25675. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/orchestration-v2/EffectWorker.ts | 25 +++++- .../FoundationPersistence.test.ts | 89 +++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index ebcb50debd110..c4981f9350ae5 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -543,6 +543,29 @@ export const layerExecutor: Layer.Layer< }), ); +// A running effect blocks later work on its thread and its lease is never reclaimed in-process, +// so a stop that hangs must give up and fall through to the normal retry/fail path. +const DELEGATED_TASKS_STOP_TIMEOUT = Duration.seconds(30); + +const boundExecution = + (effect: EffectOutbox.OrchestrationEffectV2) => + (execution: Effect.Effect) => + effect.request.type === "delegated-tasks.stop" + ? execution.pipe( + Effect.timeoutOrElse({ + duration: DELEGATED_TASKS_STOP_TIMEOUT, + orElse: () => + Effect.fail( + new OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause: `Timed out after ${Duration.format(DELEGATED_TASKS_STOP_TIMEOUT)}.`, + }), + ), + }), + ) + : execution; + export class OrchestrationEffectWorkerError extends Schema.TaggedError()( "OrchestrationEffectWorkerError", { @@ -734,7 +757,7 @@ export const layerWithOptions = ( const execution = executor .execute(effect, { willRetry: effect.attemptCount < maxAttempts }) - .pipe(Effect.as("executed" as const)); + .pipe(boundExecution(effect), Effect.as("executed" as const)); const exit = yield* Effect.exit(Effect.raceFirst(execution, cancellation)).pipe( Effect.ensuring(outbox.clearCancellation(effect.id)), ); diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index accf2de72068e..08e0724c67f02 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -40,6 +40,7 @@ import * as Ref from "effect/Ref"; import * as Scheduler from "effect/Scheduler"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as Tracer from "effect/Tracer"; import * as SqlClient from "effect/sql/SqlClient"; import * as Statement from "effect/sql/Statement"; @@ -2405,6 +2406,94 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { }), ); + it.effect.each([{ replayed: false }, { replayed: true }] as const)( + "starts later thread work behind a delegated-task stop that never returns (replayed: $replayed)", + ({ replayed }) => + Effect.gen(function* () { + const outbox = yield* EffectOutbox.EffectOutboxV2; + const key = `foundation-hung-delegated-stop:${replayed}`; + const threadId = ThreadId.make(`thread:${key}`); + const stopEffectId = `effect:${key}:stop`; + const stopStarted = yield* Deferred.make(); + const turnStarted = yield* Deferred.make(); + yield* outbox.enqueue([ + { + id: stopEffectId, + commandId: CommandId.make(`command:${key}:stop`), + threadId, + request: { type: "delegated-tasks.stop" }, + }, + ]); + if (replayed) { + assert.isTrue( + Option.isSome( + yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }), + ), + ); + assert.equal((yield* outbox.reconcileAfterProcessLoss).requeued, 1); + } + + const layerExecutor = Layer.succeed( + EffectWorker.OrchestrationEffectExecutorV2, + EffectWorker.OrchestrationEffectExecutorV2.of({ + execute: (effect) => + effect.request.type === "delegated-tasks.stop" + ? Deferred.succeed(stopStarted, undefined).pipe(Effect.andThen(Effect.never)) + : Deferred.succeed(turnStarted, undefined), + }), + ); + const layerWorker = EffectWorker.layerWithOptions({ + workerId: "hung-delegated-stop-worker", + }).pipe( + Layer.provide( + Layer.merge(Layer.succeed(EffectOutbox.EffectOutboxV2, outbox), layerExecutor), + ), + ); + + yield* Effect.gen(function* () { + yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped); + yield* Deferred.await(stopStarted); + yield* outbox.enqueue([ + { + id: `effect:${key}:turn`, + commandId: CommandId.make(`command:${key}:turn`), + threadId, + request: { type: "provider-turn.start", runId: RunId.make(`run:${key}`) }, + }, + ]); + yield* outbox.notifyAvailable(); + + const turn = yield* Deferred.await(turnStarted).pipe( + Effect.timeoutOption("1 hour"), + Effect.forkChild, + ); + yield* TestClock.adjust("1 hour"); + assert.isTrue( + Option.isSome(yield* Fiber.join(turn)), + "provider-turn.start never ran behind the hung delegated-tasks.stop", + ); + const stop = yield* outbox.get(stopEffectId); + assert.isTrue(Option.isSome(stop)); + if (Option.isSome(stop)) { + assert.equal(stop.value.status, "failed"); + assert.include(stop.value.lastError ?? "", "Timed out"); + } + }).pipe( + Effect.provide(layerWorker), + Effect.scoped, + Effect.ensuring( + outbox + .cancelUnsettled({ + threadId, + effectTypes: ["delegated-tasks.stop", "provider-turn.start"], + reason: "fixture complete", + }) + .pipe(Effect.orDie), + ), + ); + }), + ); + it.effect( "persists shutdown continuation intent through the real event sink without domain events", () => From 608dc57ab02ff731a1499a991451a647583d029b Mon Sep 17 00:00:00 2001 From: unlockdigital Date: Thu, 29 Oct 2026 10:59:59 -0500 Subject: [PATCH 4/5] fix(copilot): queue follow-ups without interrupting agents Adapt pingdotgg/t3code#26715. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Adapters/AcpRegistryAdapterV2.test.ts | 36 ++++++++ .../orchestration-v2/CommandPolicy.test.ts | 47 ++++++++++ .../src/orchestration-v2/CommandPolicy.ts | 57 ++++++++---- .../SteeringCompletion.integration.test.ts | 87 ++++++++++++++++++- .../src/server/adapter.ts | 8 +- 5 files changed, 213 insertions(+), 22 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index fab0020b7e03e..e086f8e2d1a7c 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1007,6 +1007,42 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + // regression: #26695 cancelling a Copilot prompt kills its background agents + it.effect("never steers Copilot by cancelling its prompt", () => + Effect.gen(function* () { + const steering = Effect.fn("steering")(function* (config: Record) { + const instanceId = ProviderInstanceId.make("acp-registry-steering"); + const adapter = yield* makeAcpRegistryAdapterV2({ + selfInvocation: yield* resolveSelfInvocation(), + instanceId, + settings: yield* decodeAcpRegistryAdapterSettings(config), + environment: {}, + }).pipe(Effect.provide(layerInjectedRuntimeCatalog)); + const { turns } = yield* adapter.getCapabilities(); + return { + supportsInterrupt: turns.supportsInterrupt, + supportsActiveSteering: turns.supportsActiveSteering, + supportsSteeringByInterruptRestart: turns.supportsSteeringByInterruptRestart, + }; + }); + const copilot = { + supportsInterrupt: true, + supportsActiveSteering: false, + supportsSteeringByInterruptRestart: false, + }; + + assert.deepEqual(yield* steering({ agentId: "github-copilot-cli" }), copilot); + assert.deepEqual( + yield* steering({ source: "local", commandPath: "C:\\tools\\copilot.cmd" }), + copilot, + ); + assert.deepEqual(yield* steering({ agentId: "gemini" }), { + ...copilot, + supportsSteeringByInterruptRestart: true, + }); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("settles Claude's accepted foreground reply while retaining child output", () => Effect.gen(function* () { const gate = makeProviderReplayGate(["accepted", "child-output", "wake"]); diff --git a/apps/server/src/orchestration-v2/CommandPolicy.test.ts b/apps/server/src/orchestration-v2/CommandPolicy.test.ts index 1bf294641a01c..cf982c9699f77 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.test.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.test.ts @@ -164,6 +164,53 @@ it.each([ ); }); +// regression: #26695 an interrupt-restart steer cancels Copilot's background agents +it("queues a steer for a provider that cannot steer either way", () => { + const projection = dispatchProjection( + capabilities((current) => ({ + ...current, + turns: { + ...current.turns, + supportsActiveSteering: false, + supportsSteeringByInterruptRestart: false, + supportsQueuedMessages: true, + }, + })), + ); + for (const deliveryIntent of ["auto", "steer"] as const) { + assert.deepEqual( + CommandPolicy.resolveMessageDispatchIntent( + projection, + { type: "start_immediately" }, + deliveryIntent, + ), + { type: "queue_after_active" }, + ); + } + assert.deepEqual( + CommandPolicy.resolveMessageDispatchIntent(projection, { + type: "steer_active", + targetRunId: activeRunId, + }), + { type: "queue_after_active" }, + ); + assert.deepEqual( + CommandPolicy.resolveMessageDispatchIntent( + projection, + { type: "start_immediately" }, + "restart", + ), + { type: "restart_active", targetRunId: activeRunId }, + ); + assert.deepEqual( + CommandPolicy.resolveMessageDispatchIntent(projection, { + type: "steer_active", + targetRunId: RunId.make("command-policy-missing-run"), + }), + { type: "steer_active", targetRunId: RunId.make("command-policy-missing-run") }, + ); +}); + it.each(["/compact", " /LOGOUT "])("queues a steer sent while %s runs", (text) => { const projection = dispatchProjection(baseCapabilities); const maintenance = { diff --git a/apps/server/src/orchestration-v2/CommandPolicy.ts b/apps/server/src/orchestration-v2/CommandPolicy.ts index 923542d877dfe..c6dde9cf8b388 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.ts @@ -139,11 +139,47 @@ export function resolveMessageDispatchIntent( // while one runs waits in the queue like any other follow-up. const target = projection.runs.find((run) => run.id === mode.targetRunId); const targetMessage = projection.messages.find((message) => message.id === target?.userMessageId); - return targetMessage !== undefined && isNativeMaintenanceCommand(targetMessage) + if (targetMessage !== undefined && isNativeMaintenanceCommand(targetMessage)) { + return { type: "queue_after_active" }; + } + // A provider that can neither steer nor restart its turn takes the message after the turn. + const turns = + target === undefined || !isActiveRun(target) + ? undefined + : providerTurnCapabilities(projection, target); + return turns !== undefined && + !turns.supportsActiveSteering && + !(turns.supportsInterrupt && turns.supportsSteeringByInterruptRestart) && + turns.supportsQueuedMessages ? { type: "queue_after_active" } : mode; } +function isActiveRun(run: OrchestrationV2Run): boolean { + return ( + run.status === "preparing" || + run.status === "starting" || + run.status === "running" || + run.status === "waiting" + ); +} + +function providerTurnCapabilities( + projection: OrchestrationV2ThreadProjection, + run: OrchestrationV2Run, +) { + const providerThread = projection.providerThreads.find( + (candidate) => candidate.id === run.providerThreadId, + ); + const providerSession = + providerThread?.providerSessionId == null + ? undefined + : projection.providerSessions.find( + (candidate) => candidate.id === providerThread.providerSessionId, + ); + return providerSession?.capabilities.turns; +} + function resolveDeliveryIntent( projection: OrchestrationV2ThreadProjection, requestedMode: MessageDispatchMode, @@ -151,13 +187,7 @@ function resolveDeliveryIntent( ): MessageDispatchMode { if (deliveryIntent === undefined) return requestedMode; - const activeRun = projection.runs.findLast( - (run) => - run.status === "preparing" || - run.status === "starting" || - run.status === "running" || - run.status === "waiting", - ); + const activeRun = projection.runs.findLast(isActiveRun); if (activeRun === undefined) return { type: "start_immediately" }; if (deliveryIntent === "steer") { return { type: "steer_active", targetRunId: activeRun.id }; @@ -177,16 +207,7 @@ function resolveDeliveryIntent( return { type: "queue_after_active" }; } - const providerThread = projection.providerThreads.find( - (candidate) => candidate.id === activeRun.providerThreadId, - ); - const providerSession = - providerThread?.providerSessionId == null - ? undefined - : projection.providerSessions.find( - (candidate) => candidate.id === providerThread.providerSessionId, - ); - const capabilities = providerSession?.capabilities.turns; + const capabilities = providerTurnCapabilities(projection, activeRun); if (capabilities?.supportsActiveSteering === true) { return { type: "steer_active", targetRunId: activeRun.id }; } diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 208934624d9e1..e050b5fdfcbb1 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -408,16 +408,18 @@ const composerSelection = { const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* ( name: string, canRestart = false, + canSteer = true, ) { const cwd = yield* checkpointWorkspace(name); const events = yield* Queue.unbounded(); const started: ProviderAdapter.ProviderAdapterV2TurnInput[] = []; const steered: string[] = []; + const interrupted: ProviderTurnId[] = []; const capabilities = { ...CodexProviderCapabilitiesV2, turns: { ...CodexProviderCapabilitiesV2.turns, - supportsActiveSteering: true, + supportsActiveSteering: canSteer, supportsSteeringByInterruptRestart: canRestart, }, }; @@ -492,7 +494,10 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* Effect.sync(() => { steered.push(turn.message.text); }), - interruptTurn: () => Effect.void, + interruptTurn: ({ providerTurnId }) => + Effect.sync(() => { + interrupted.push(providerTurnId); + }), respondToRuntimeRequest: () => Effect.void, readThreadSnapshot: () => Effect.die("unused"), rollbackThread: () => Effect.die("unused"), @@ -547,7 +552,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* yield* Fiber.join(running); return threadId; }); - return { events, started, steered, layer, startFirstTurn }; + return { cwd, events, started, steered, interrupted, layer, startFirstTurn }; }); it.effect("sends a selected held message once and preserves the remaining queue order", () => @@ -825,6 +830,82 @@ it.effect("starts a steer that missed the turn on the saved next-turn selection" ), ); +// regression: #26695 cancelling a Copilot prompt to steer it kills its background agents +it.effect("queues a steer until the turn ends when the provider cannot steer it", () => + Effect.scoped( + Effect.gen(function* () { + const { events, started, steered, interrupted, layer, startFirstTurn } = + yield* nextTurnSelectionHarness("steering-unsupported", false, false); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + const first = started[0]!; + const messageId = MessageId.make("message:steer"); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("steer"), + threadId, + messageId, + text: "follow up", + attachments: [], + modelSelection: runSelection, + dispatchMode: { type: "start_immediately" }, + deliveryIntent: "steer", + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + + const held = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(steered, []); + assert.deepEqual(interrupted, []); + assert.equal(started.length, 1); + assert.equal(held.runs.find((run) => run.id === first.runId)?.status, "running"); + const queued = held.runs.find((run) => run.userMessageId === messageId); + assert.equal(queued?.status, "queued"); + + const settled = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "run.updated" && + event.payload.id === first.runId && + event.payload.status === "waiting", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + const turn = held.providerTurns[0]!; + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { ...turn, status: "completed", completedAt: yield* DateTime.now }, + }); + yield* Queue.offer(events, { + type: "turn.terminal", + driver, + providerThreadId: turn.providerThreadId, + providerTurnId: turn.id, + runOrdinal: first.runOrdinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(settled); + yield* worker.drain(); + yield* orchestrator.resumeQueuedRuns; + yield* worker.drain(); + + assert.deepEqual(interrupted, []); + assert.equal(started.length, 2); + assert.equal(started[1]?.runId, queued?.id); + assert.equal(started[1]?.message.text, "follow up"); + }).pipe(Effect.provide(layer)); + }), + ), +); + // A draft that never reaches a paragraph boundary is held back while it // streams. Codex only flushes it at turn end, after the final answer is written, // so the draft must already own its place from before the steer. diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 08b83c2e6a41c..75d8f36270b3b 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -207,7 +207,13 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu const copilotRouting = isCopilot ? makeCopilotSubagentRouting() : undefined; const flavor: AcpAdapterV2Flavor = { driver: ACP_REGISTRY_PROVIDER, - capabilities: AcpProviderCapabilitiesV2, + // Cancelling a Copilot prompt also kills the background agents it started. + capabilities: isCopilot + ? { + ...AcpProviderCapabilitiesV2, + turns: { ...AcpProviderCapabilitiesV2.turns, supportsSteeringByInterruptRestart: false }, + } + : AcpProviderCapabilitiesV2, customModels: readCustomModelEntries(options.settings.customModels).map((entry) => entry.slug), promptFailure: (cause) => acpRegistryPromptFailure(registryAgentId, cause), ...(registryAgentId === "factory-droid" From 0b2f64fd1a28f463a843fbe6196b58bdf84c7b8a Mon Sep 17 00:00:00 2001 From: unlockdigital Date: Thu, 29 Oct 2026 11:00:35 -0500 Subject: [PATCH 5/5] docs(release): distinguish fork and upstream identities Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/unlock-digital/PORTABLE_TRIAL.md | 6 ++++-- docs/unlock-digital/UPSTREAM_SYNC.md | 23 +++++++++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/unlock-digital/PORTABLE_TRIAL.md b/docs/unlock-digital/PORTABLE_TRIAL.md index b920342ab531f..9d45efd710259 100644 --- a/docs/unlock-digital/PORTABLE_TRIAL.md +++ b/docs/unlock-digital/PORTABLE_TRIAL.md @@ -1,6 +1,8 @@ # Unlock Digital: independent Mac trial -Use the Apple-silicon fork preview on an M-series Mac with fresh application state and provider sign-ins. Each preview identifies its source commit and includes a checksum manifest. The upstream release base remains v0.1.2 while selected maintenance fixes and the fork's own features are integrated. +This is an **Unlock Digital fork** preview, not an upstream T3 release. The fork's upstream base remains `pingdotgg/t3code` `v0.1.2` at `044f07257c9684ae4543bd1e1ebf9c394e9e3830`, with selected backports and fork changes layered on it. The fork's plain numeric `v0.1.3` is distinct from upstream `v0.1.3` at `d2c440312e5f39d59827ed466838a623516454d6`. Identify each actual build by its fork repository, full source SHA, and package version. See [release identity and safe comparisons](UPSTREAM_SYNC.md#fork-and-upstream-release-identity). + +The preview workflow only accepts tags shaped like `vX.Y.Z-unlock.N-preview.YYYYMMDD.N` and derives the package version from the full tag suffix. Keep that preview tag and package version distinct from the requested human-facing fork version `v0.1.3`. Each completed preview should identify its exact source commit in `unlockdigital/t3code` and include a checksum manifest. ## Download and install @@ -33,7 +35,7 @@ N2ition is optional and starts without a live grant on a clean installation. An - Quit normally, reopen through the trial launcher, and confirm that the trial conversation and project remain available. - Check permission-denied and revoked-coordinator cases before using delegated approval on important work. Qualify optional connectors before sharing private context. -Keep the test outcome, macOS version, architecture, release tag, and any reproducible failure with your trial notes. Installer integrity and source-machine checks establish the delivered build; this destination workflow establishes its usability on the work Mac. +These steps describe checksum verification and destination-side smoke checks; they do not record completed installer validation. Keep the observed test outcome, macOS version, architecture, release tag, full source SHA, and any reproducible failure with your trial notes. ## Recovery diff --git a/docs/unlock-digital/UPSTREAM_SYNC.md b/docs/unlock-digital/UPSTREAM_SYNC.md index 896ebf8530994..199d20e10c891 100644 --- a/docs/unlock-digital/UPSTREAM_SYNC.md +++ b/docs/unlock-digital/UPSTREAM_SYNC.md @@ -11,6 +11,21 @@ - Upstream development is available through `upstream/main`; the fork keeps one maintained public branch. - `remote.pushDefault` is `origin`. +## Fork and upstream release identity + +The plain numeric version `v0.1.3` identifies an **Unlock Digital fork** release in `unlockdigital/t3code`. Identify a completed fork release by its repository, tag, and full commit SHA from `unlockdigital/t3code`; do not infer its source from this worktree's current `HEAD`. A draft may record a target commit before its tag exists; verify the target, tag, and artifacts together before publishing. + +The fork's recorded upstream base remains `pingdotgg/t3code` `v0.1.2` at `044f07257c9684ae4543bd1e1ebf9c394e9e3830`. Selected backports and fork changes do not make the fork an upstream `v0.1.3` release. The official upstream `v0.1.3` tag resolves to commit `d2c440312e5f39d59827ed466838a623516454d6`. In the reviewed checkout, the unqualified local `refs/tags/v0.1.3` resolves to that upstream commit; it is not a tag for the Unlock Digital release. + +Keep upstream tag inspection in a separate upstream checkout or fetch it into a distinct namespace, then compare full commit SHAs and repository-qualified commit links. For example, in the fork checkout: + +```sh +git fetch --no-tags upstream refs/tags/v0.1.3:refs/remotes/upstream/tags/v0.1.3 +git rev-parse 'refs/remotes/upstream/tags/v0.1.3^{commit}' +``` + +Inspect the existing unqualified tag before using it; do not overwrite it to resolve a collision. + ## Update procedure Use stable upstream releases for routine maintenance. Prioritize applicable security fixes; assess a published upstream fix separately when it precedes a stable release. Keep each customization in a focused commit and use existing settings and skills where they provide the requested behavior. Accepted upstream contributions can reduce the custom changes maintained here. @@ -29,11 +44,11 @@ The update request authorizes this maintenance sequence. Monitoring remains obse Allow the fork a full trial week before proposing contributions. Review proven changes for upstream contribution starting October 30, 2026. Keep security maintenance moving during that trial. Preserve focused customization commits when an upstream contribution lands, and retire their duplicate implementation through an ordinary follow-up change after the next upstream integration. -## Full v0.1.3 integration gates +## Upstream v0.1.3 integration gates -The reviewed official v0.1.3 release is `d2c440312e5f39d59827ed466838a623516454d6`. Focused backports retain the recorded v0.1.2 base until the complete release is merged and verified. Start each full candidate from the current committed fork, and preserve the active tracked and untracked work separately before reconciling it. +The reviewed official upstream `pingdotgg/t3code` `v0.1.3` release is `d2c440312e5f39d59827ed466838a623516454d6`. Focused backports retain the fork's recorded upstream `v0.1.2` base until the complete upstream release is merged and verified. Start each full candidate from the current committed fork, and preserve the active tracked and untracked work separately before reconciling it. -- **Migration history:** this fork records migration 68 as `WorkflowReviews`; v0.1.3 uses 68 for `ShellStopRequestIndex` and 69 for `ThreadMessageSearch`. Existing fork databases have already applied their migration 68. Build and test an explicit history bridge against fresh databases, fork-68 snapshots, and every upstream history the candidate supports. Verify the workflow tables, stop-request index, and search schema together before opening copied application data with the full candidate. Preserve compatible source and data for rollback. +- **Migration history:** this fork records migration 68 as `WorkflowReviews`; upstream `v0.1.3` uses 68 for `ShellStopRequestIndex` and 69 for `ThreadMessageSearch`. The separate project-brief candidate uses 69 for `ProjectBrief`. Existing fork databases have already applied their migration 68. Build and test an explicit history bridge against fresh databases, fork-68 snapshots, project-brief histories if adopted, and every upstream history the candidate supports. Verify the workflow tables, stop-request index, search schema, and any adopted project-brief schema together before opening copied application data with the full candidate. Preserve compatible source and data for rollback. - **Authorization and private routing:** keep authoritative lineage lookup before creating or rotating MCP credentials, retain restricted credentials on lookup failure, and preserve model/account destination checks. Verify child direct-call denial and private routing after resolving provider-session, orchestration, and MCP conflicts. - **Dependencies:** reconcile the workspace, patches, and frozen lockfile together. Preserve the fork's security updates to the MCP SDK, stream-json, KaTeX, and sharp. Assess and remediate the Undici and Zod advisory ranges per runtime consumer; the configured Node runtime's embedded HTTP library and npm dependency versions need separate checks. Recheck current vendor guidance during that work. - **Client data bounds:** a Stop-state repair must retain the ordinary thread projection's bounded command/tool payloads in transit and persisted caches. Verify that settlement repairs preserve status without automatically retaining on-demand details. @@ -70,7 +85,7 @@ About labels the desktop package version separately from the upstream release ba Source builds use manual rebuilds initially. Development-mode desktop updating is disabled by the application; packaged upstream auto-updates belong to the official release app. A future packaged fork needs its own explicit update distribution. Keep desktop and server protocol versions compatible. -The fork's [desktop preview workflow](../../.github/workflows/fork-release.yml) reuses upstream's desktop and CLI packaging. Tag pushes build Apple-silicon macOS; manual dispatch can select `platform=all` for all six desktop targets. It accepts existing tags shaped like `v0.1.2-unlock.2-preview.20261030.1`, verifies that the tagged commit belongs to the default branch and has a successful `Check` result, and creates a draft prerelease with `SHA256SUMS`. A tag push or manual dispatch starts the build. Use a fresh tag for each attempt that produced a release. Complete native platform smoke checks before publishing its draft. These initial artifacts are unsigned, use manual installation, and carry no automatic update feed. Preview packages retain upstream desktop profile identity. Use a disposable OS account or VM for side-by-side testing and a separate `T3CODE_HOME` for server state. Signing, a production update feed, T3 Connect relay configuration, npm publication, and hosted deployments require their own setup. +The fork's [desktop preview workflow](../../.github/workflows/fork-release.yml) reuses upstream's desktop and CLI packaging. It accepts only tags shaped like `vX.Y.Z-unlock.N-preview.YYYYMMDD.N`; its package version is derived from that entire tag after removing the leading `v`. A plain `v0.1.3` tag is not accepted by this preview workflow, so keep the requested human-facing Unlock Digital version distinct from a preview tag and its package version. Tag pushes build Apple-silicon macOS; manual dispatch can select `platform=all` for all six desktop targets. The workflow verifies that the tagged commit belongs to the default branch and has a successful `Check` result, then creates a draft prerelease with `SHA256SUMS`. A tag push or manual dispatch starts the build. Complete native platform smoke checks before publishing its draft. These initial artifacts are unsigned, use manual installation, and carry no automatic update feed. Preview packages retain upstream desktop profile identity. Use a disposable OS account or VM for side-by-side testing and a separate `T3CODE_HOME` for server state. Signing, a production update feed, T3 Connect relay configuration, npm publication, and hosted deployments require their own setup. Rollback restores both a source checkpoint and a database backup compatible with it. Preserve newer state separately before restoring a backup.